From e4d8913680bdc2abe4fbfc43cf3954c6c2c75605 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:21:17 +0200 Subject: [PATCH 001/435] [zigbee] Add zigbee support for number on esp32 (#20243) --- esphome/components/zigbee/__init__.py | 15 +++++++-- esphome/components/zigbee/const.py | 3 +- esphome/components/zigbee/const_esp32.py | 1 + .../zigbee/zigbee_attribute_esp32.h | 25 ++++++++++++++ esphome/components/zigbee/zigbee_ep_esp32.py | 29 ++++++++++++++++ esphome/components/zigbee/zigbee_esp32.py | 33 +++++++++++++++++-- .../components/zigbee/zigbee_helpers_esp32.c | 4 +++ tests/components/zigbee/common.yaml | 1 + 8 files changed, 105 insertions(+), 6 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 02f05ef8a2e..0a99db8958d 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -42,6 +42,7 @@ from .const_zephyr import ( from .zigbee_esp32 import ( final_validate_esp32, validate_binary_sensor_esp32, + validate_number_esp32, validate_sensor_esp32, validate_switch_esp32, zigbee_require_vfs_select, @@ -120,7 +121,9 @@ SENSOR_SCHEMA = ( SWITCH_SCHEMA = ( cv.Schema({}).extend(_get_base_schema(["generic", "on_off"])).extend(zephyr_switch) ) -NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) +NUMBER_SCHEMA = ( + cv.Schema({}).extend(_get_base_schema(["generic"])).extend(zephyr_number) +) def _validate_router_sleepy(config: ConfigType) -> ConfigType: @@ -269,12 +272,18 @@ async def setup_number( max_value: float, step: float, ) -> None: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: + if not config.get(CONF_ZIGBEE_ID): + return from .zigbee_zephyr import zephyr_setup_number await zephyr_setup_number(entity, config, min_value, max_value, step) + else: + from .zigbee_esp32 import add_component as add_number + + CORE.add_job(add_number, entity, config) def consume_endpoint(config: ConfigType) -> ConfigType: @@ -320,7 +329,7 @@ def validate_number(config: ConfigType) -> ConfigType: if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: - return config + return validate_number_esp32(config) return consume_endpoint(config) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index d6abd9d6c07..86cac665b43 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -145,7 +145,8 @@ class AnalogInputType(IntEnum): TIME_SECONDS = 0x0E -ANALOG_INPUT_APPTYPE = { +# Same for input and output +ANALOG_APPTYPE = { (DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS): AnalogInputType.TEMP_DEGREES_C, (DEVICE_CLASS_HUMIDITY, UNIT_PERCENT): AnalogInputType.RELATIVE_HUMIDITY_PERCENT, (DEVICE_CLASS_PRESSURE, UNIT_PASCAL): AnalogInputType.PRESSURE_PASCAL, diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index b8122d14417..bb6c153752b 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -33,6 +33,7 @@ CLUSTER_ID = { "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, "BINARY_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "ANALOG_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, "ILLUMINANCE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, "TEMPERATURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT, "PRESSURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT, diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 2680fc2203e..305febf171a 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -23,6 +23,9 @@ #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif namespace esphome::zigbee { @@ -67,6 +70,9 @@ class ZigbeeAttribute final : public Component { #endif #ifdef USE_SWITCH template void connect(switch_::Switch *device); +#endif +#ifdef USE_NUMBER + template void connect(number::Number *device); #endif bool report_enabled = false; @@ -157,6 +163,25 @@ template void ZigbeeAttribute::connect(switch_::Switch *device) { device->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); } #endif +#ifdef USE_NUMBER +template void ZigbeeAttribute::connect(number::Number *device) { + // Add min, max and step attributes to the analog output cluster + if (this->cluster_id_ == EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT) { + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x0045, this->max_size_, + device->traits.get_min_value()); + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x0041, this->max_size_, + device->traits.get_max_value()); + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x006A, this->max_size_, + device->traits.get_step()); + } + this->add_on_value_callback([this, device](ezb_zcl_attribute_t attribute) { + auto call = device->make_call(); + call.set_value(static_cast(*(T *) attribute.data.value) / this->scale_); + call.perform(); + }); + device->add_on_state_callback([this](float value) { this->set_attr(this->scale_value_(value)); }); +} +#endif } // namespace esphome::zigbee diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index b0f3d17bd69..88f49ef3c16 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -78,6 +78,35 @@ ANALOG_INPUT_EP = { ], } +ANALOG_OUTPUT_EP = { + CONF_CLUSTERS: [ + { + CONF_ID: "ANALOG_OUTPUT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "SINGLE", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "MAP8", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "STRING", + }, + ], + }, + ], +} + BINARY_INPUT_EP = { DEVICE_TYPE: "SIMPLE_SENSOR", CONF_CLUSTERS: [ diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index f48bf9ef0be..463a398033c 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -36,7 +36,7 @@ import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( - ANALOG_INPUT_APPTYPE, + ANALOG_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, CONF_CLUSTER, @@ -67,6 +67,7 @@ from .const_esp32 import ( ) from .zigbee_ep_esp32 import ( ANALOG_INPUT_EP, + ANALOG_OUTPUT_EP, BINARY_INPUT_EP, BINARY_OUTPUT_EP, BINARY_SENSOR_EP_CONFIGS, @@ -229,7 +230,7 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: attr[CONF_LAMBDA] = attr[CONF_LAMBDA][unit] else: ep = copy.deepcopy(ANALOG_INPUT_EP) - apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) + apptype = ANALOG_APPTYPE.get((dev_class, unit)) bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) accuracy = config.get(CONF_ACCURACY_DECIMALS) if apptype is not None: @@ -261,6 +262,34 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: return config +def validate_number_esp32(config: ConfigType) -> ConfigType: + # get application type from device class and meas unit + # if none get BACNET unit from meas unit + dev_class = config.get(CONF_DEVICE_CLASS) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + ep = copy.deepcopy(ANALOG_OUTPUT_EP) + apptype = ANALOG_APPTYPE.get((dev_class, unit)) + bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + if apptype is not None: + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x100, + CONF_VALUE: (apptype << 16) | 0x0100FFFF, + CONF_TYPE: "UINT32", + }, + ) + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x75, + CONF_VALUE: bacunit, + CONF_TYPE: "ENUM16", + }, + ) + setup_attributes(config, ep[CONF_CLUSTERS]) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) + return config + + def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: dev_class = config.get(CONF_DEVICE_CLASS) if config[CONF_CLUSTER] == "device_class": diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 22f014f3ef4..e52513b7408 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -72,6 +72,8 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i return ezb_zcl_time_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT: + return ezb_zcl_analog_output_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT: @@ -113,6 +115,8 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_ return ezb_zcl_time_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT: + return ezb_zcl_analog_output_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT: diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index ce59f4a6ae1..8ef49570729 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -45,6 +45,7 @@ number: max_value: 100 step: 1 unit_of_measurement: "°C" + device_class: temperature time: - platform: zigbee From 40809ffa1046868e2b8476d34013698d6c0f03ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 02:30:48 -1000 Subject: [PATCH 002/435] [sx126x] Cover shared constant and trigger argument send_packet payloads in the component test (#20272) --- tests/components/sx126x/common.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 05794ad1a89..eff5785baee 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -36,6 +36,10 @@ number: min_value: 0 max_value: 100 step: 1 + on_value: + then: + - sx126x.send_packet: !lambda |- + return {0x03, (uint8_t) x}; button: - platform: template @@ -50,3 +54,6 @@ button: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx126x.send_packet: !lambda |- return {0x01, 0x02, (uint8_t)id(sx126x_my_number).state}; + # Same bytes as above on purpose: equal payloads share one flash table + - sx126x.send_packet: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] + - sx126x.send_packet: "hello" From b78f00a037cb5796f3af42ce7f8aeeefb4736519 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 02:31:04 -1000 Subject: [PATCH 003/435] [espnow] Cover a send payload lambda that reads trigger arguments in the component test (#20270) --- tests/components/espnow/common.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index fec0dd9f3e8..973781fb68b 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -36,6 +36,9 @@ espnow: data: [0x01, 0x02, 0x03, 0x04, 0x05] - espnow.broadcast: data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' + - espnow.send: + address: 11:22:33:44:55:66 + data: !lambda 'return std::vector(data, data + size);' - espnow.peer.add: address: 11:22:33:44:55:66 - espnow.peer.add: From f0035c1db69268685f31c7300fd53ca58a1e724e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 02:31:24 -1000 Subject: [PATCH 004/435] [sx127x] Cover shared constant, string and trigger argument send_packet payloads in the component test (#20274) --- tests/components/sx127x/common.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/sx127x/common.yaml b/tests/components/sx127x/common.yaml index b7eadc084fe..aa74b52cda6 100644 --- a/tests/components/sx127x/common.yaml +++ b/tests/components/sx127x/common.yaml @@ -25,6 +25,8 @@ sx127x: then: - sx127x.send_packet: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] + - sx127x.send_packet: !lambda |- + return x; number: - platform: template @@ -34,6 +36,10 @@ number: min_value: 0 max_value: 100 step: 1 + on_value: + then: + - sx127x.send_packet: !lambda |- + return {0x03, (uint8_t) x}; button: - platform: template @@ -49,3 +55,5 @@ button: data: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] - sx127x.send_packet: !lambda |- return {0x01, 0x02, (uint8_t)id(sx127x_my_number).state}; + - sx127x.send_packet: [0xC5, 0x51, 0x78, 0x82, 0xB7, 0xF9, 0x9C, 0x5C] + - sx127x.send_packet: "hello" From 1661f84deb175fb880c483ae757a572945577a25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 02:32:04 -1000 Subject: [PATCH 005/435] [udp] Cover identical constant and trigger argument write payloads in the component test (#20266) --- tests/components/udp/common.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 6824c5cca89..f3a082fdd2e 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -20,6 +20,13 @@ udp: id: my_udp data: !lambda |- return std::vector{1,3,4,5,6}; + - udp.write: + id: my_udp + data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] + - udp.write: + id: my_udp + data: !lambda |- + return std::vector(data.begin(), data.end()); number: - platform: template @@ -37,5 +44,7 @@ button: then: - udp.write: data: [0x01, 0x02, 0x03] + - udp.write: + data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] - udp.write: !lambda |- return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; From 19561d44a2290976bff9f5b5d1dbe7e598f4ca01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 02:32:18 -1000 Subject: [PATCH 006/435] [cc1101] Cover shared, string and trigger argument send_packet payloads in the component test (#20263) --- tests/components/cc1101/common.yaml | 4 ++++ .../cc1101/{test.esp8266.yaml => test.esp8266-ard.yaml} | 0 2 files changed, 4 insertions(+) rename tests/components/cc1101/{test.esp8266.yaml => test.esp8266-ard.yaml} (100%) diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 4d2411e0218..7f511a8d966 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -30,6 +30,8 @@ cc1101: then: - lambda: |- ESP_LOGD("cc1101", "packet %s freq_offset %.0f Hz rssi %.1f dBm lqi %u", format_hex(x).c_str(), freq_offset, rssi, lqi); + - cc1101.send_packet: !lambda |- + return x; button: - platform: template @@ -42,6 +44,8 @@ button: - cc1101.reset: transceiver - cc1101.send_packet: data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef] + - cc1101.send_packet: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef] + - cc1101.send_packet: "ABCDEFGH" - cc1101.send_packet: !lambda |- return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; diff --git a/tests/components/cc1101/test.esp8266.yaml b/tests/components/cc1101/test.esp8266-ard.yaml similarity index 100% rename from tests/components/cc1101/test.esp8266.yaml rename to tests/components/cc1101/test.esp8266-ard.yaml From 51dbd3a63f26a0f83113f31fc87092b014d2553a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 02:33:20 -1000 Subject: [PATCH 007/435] [core] Test that if and while branches resume after delays, stops, nesting and script modes (#20282) --- .../fixtures/branch_resume_async.yaml | 261 ++++++++++++++++++ tests/integration/test_branch_resume_async.py | 140 ++++++++++ 2 files changed, 401 insertions(+) create mode 100644 tests/integration/fixtures/branch_resume_async.yaml create mode 100644 tests/integration/test_branch_resume_async.py diff --git a/tests/integration/fixtures/branch_resume_async.yaml b/tests/integration/fixtures/branch_resume_async.yaml new file mode 100644 index 00000000000..fb093238095 --- /dev/null +++ b/tests/integration/fixtures/branch_resume_async.yaml @@ -0,0 +1,261 @@ +esphome: + name: test-branch-resume-async + +host: + +logger: + level: DEBUG + +script: + - id: delay_in_if + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 50ms + - logger.log: "if-branch-after-delay" + else: + - logger.log: "if-else-should-not-run" + - logger.log: "after-if" + + - id: delay_in_else + then: + - if: + condition: + lambda: 'return false;' + then: + - logger.log: "if-then-should-not-run" + else: + - delay: 50ms + - logger.log: "else-branch-after-delay" + - logger.log: "after-else" + + - id: delay_in_while + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: 'return id(counter) < 3;' + then: + - delay: 20ms + - lambda: 'id(counter) += 1;' + - logger.log: + format: "while-body-%d" + args: ['id(counter)'] + - logger.log: "after-while" + + - id: stop_in_branch + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 500ms + - logger.log: "stopped-branch-should-not-run" + - logger.log: "stopped-after-if-should-not-run" + + - id: parallel_branch + mode: parallel + parameters: + run: int + then: + - if: + condition: + lambda: 'return run >= 0;' + then: + - delay: 100ms + - logger.log: + format: "parallel-branch-done-%d" + args: ['run'] + - logger.log: + format: "parallel-after-if-%d" + args: ['run'] + + - id: nested_if_last_in_while + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: 'return id(counter) < 2;' + then: + - lambda: 'id(counter) += 1;' + - if: + condition: + lambda: 'return true;' + then: + - delay: 20ms + - logger.log: + format: "nested-if-in-while-%d" + args: ['id(counter)'] + - logger.log: "after-nested-while" + + - id: stop_mid_while + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: 'return id(counter) < 5;' + then: + - delay: 200ms + - lambda: 'id(counter) += 1;' + - logger.log: + format: "stop-while-body-%d" + args: ['id(counter)'] + - logger.log: "stop-while-done" + + - id: queued_branch + mode: queued + parameters: + run: int + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 30ms + - logger.log: + format: "queued-branch-%d" + args: ['run'] + - logger.log: + format: "queued-after-if-%d" + args: ['run'] + + - id: restart_branch + mode: restart + parameters: + run: int + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 100ms + - logger.log: + format: "restart-branch-%d" + args: ['run'] + - logger.log: + format: "restart-after-if-%d" + args: ['run'] + + - id: delay_last_in_branch + then: + - if: + condition: + lambda: 'return true;' + then: + - logger.log: "delay-last-start" + - delay: 30ms + - logger.log: "after-delay-last" + + - id: stop_in_if_condition + then: + - if: + condition: + lambda: |- + id(stop_in_if_condition).stop(); + return true; + then: + - logger.log: "if-stopped-by-condition-should-not-run" + else: + - logger.log: "if-else-stopped-by-condition-should-not-run" + - logger.log: "after-if-stopped-by-condition-should-not-run" + - id: stop_in_if_else_condition + then: + - if: + condition: + lambda: |- + id(stop_in_if_else_condition).stop(); + return false; + else: + - logger.log: "else-stopped-by-condition-should-not-run" + - logger.log: "after-else-stopped-by-condition-should-not-run" + - id: stop_in_while_condition + then: + - while: + condition: + lambda: |- + id(stop_in_while_condition).stop(); + return true; + then: + - logger.log: "while-stopped-by-condition-should-not-run" + - logger.log: "after-while-stopped-by-condition-should-not-run" + +globals: + - id: counter + type: int + initial_value: '0' + +api: + actions: + - action: run_delay_in_if + then: + - script.execute: delay_in_if + - action: run_delay_in_else + then: + - script.execute: delay_in_else + - action: run_delay_in_while + then: + - script.execute: delay_in_while + - action: run_stop_in_branch + then: + - script.execute: stop_in_branch + - delay: 100ms + - script.stop: stop_in_branch + - lambda: |- + ESP_LOGI("test", "stop-running=%d", id(stop_in_branch).is_running() ? 1 : 0); + - delay: 600ms + - logger.log: "stop-check-done" + - action: run_nested_if_last_in_while + then: + - script.execute: nested_if_last_in_while + - action: run_stop_mid_while + then: + - script.execute: stop_mid_while + - delay: 300ms + - script.stop: stop_mid_while + - lambda: |- + ESP_LOGI("test", "stop-while-running=%d", id(stop_mid_while).is_running() ? 1 : 0); + - delay: 400ms + - script.execute: stop_mid_while + - action: run_queued_branch + then: + - script.execute: + id: queued_branch + run: 1 + - script.execute: + id: queued_branch + run: 2 + - script.execute: + id: queued_branch + run: 3 + - action: run_restart_branch + then: + - script.execute: + id: restart_branch + run: 1 + - delay: 30ms + - script.execute: + id: restart_branch + run: 2 + - action: run_delay_last_in_branch + then: + - script.execute: delay_last_in_branch + - action: run_stop_in_conditions + then: + - script.execute: stop_in_if_condition + - script.execute: stop_in_if_else_condition + - script.execute: stop_in_while_condition + - delay: 200ms + - logger.log: "stop-in-conditions-done" + - action: run_parallel_branch + then: + - script.execute: + id: parallel_branch + run: 1 + - script.execute: + id: parallel_branch + run: 2 + - script.execute: + id: parallel_branch + run: 3 diff --git a/tests/integration/test_branch_resume_async.py b/tests/integration/test_branch_resume_async.py new file mode 100644 index 00000000000..78064cbe305 --- /dev/null +++ b/tests/integration/test_branch_resume_async.py @@ -0,0 +1,140 @@ +"""Test that if/else and while branches resume correctly after async actions, stop and parallel runs.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_branch_resume_async( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A branch that ends after a delay must resume its owner exactly once.""" + lines: list[str] = [] + waiters: list[tuple[str, asyncio.Future[None]]] = [] + + def has_marker(text: str, line: str) -> bool: + # Whole markers only: "after-if" must not match "queued-after-if-1" + return re.search(rf"(? None: + lines.append(line) + for text, future in waiters: + if has_marker(text, line) and not future.done(): + future.set_result(None) + + def wait_for(text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + waiters.append((text, future)) + return future + + def count(text: str) -> int: + return sum(has_marker(text, line) for line in lines) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service = {s.name: s for s in services} + + done = wait_for("after-if") + await client.execute_service(service["run_delay_in_if"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("if-branch-after-delay") == 1 + assert count("if-else-should-not-run") == 0 + assert count("after-if") == 1 + + done = wait_for("after-else") + await client.execute_service(service["run_delay_in_else"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("else-branch-after-delay") == 1 + assert count("if-then-should-not-run") == 0 + assert count("after-else") == 1 + + done = wait_for("after-while") + await client.execute_service(service["run_delay_in_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert [count(f"while-body-{i}") for i in (1, 2, 3)] == [1, 1, 1] + assert count("while-body-4") == 0 + assert count("after-while") == 1 + + done = wait_for("stop-check-done") + await client.execute_service(service["run_stop_in_branch"], {}) + await asyncio.wait_for(done, timeout=3.0) + assert count("stop-running=0") == 1 + assert count("stopped-branch-should-not-run") == 0 + assert count("stopped-after-if-should-not-run") == 0 + + done = wait_for("after-nested-while") + await client.execute_service(service["run_nested_if_last_in_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert [count(f"nested-if-in-while-{i}") for i in (1, 2)] == [1, 1] + assert count("nested-if-in-while-3") == 0 + assert count("after-nested-while") == 1 + + # Stop during the second iteration's delay: that iteration and the line after the loop never run; + # a fresh run then completes + done = wait_for("stop-while-done") + await client.execute_service(service["run_stop_mid_while"], {}) + await asyncio.wait_for(done, timeout=3.0) + assert count("stop-while-running=0") == 1 + assert count("stop-while-body-1") == 2 + assert [count(f"stop-while-body-{i}") for i in (2, 3, 4, 5)] == [1, 1, 1, 1] + assert count("stop-while-done") == 1 + + done = wait_for("queued-after-if-3") + await client.execute_service(service["run_queued_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + queued = [line for line in lines if "queued-" in line] + order = [ + "queued-branch-1", + "queued-after-if-1", + "queued-branch-2", + "queued-after-if-2", + "queued-branch-3", + "queued-after-if-3", + ] + assert [next(o for o in order if o in line) for line in queued] == order + + done = wait_for("restart-after-if-2") + await client.execute_service(service["run_restart_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + await asyncio.sleep(0.2) + assert count("restart-branch-1") == 0 + assert count("restart-after-if-1") == 0 + assert count("restart-branch-2") == 1 + assert count("restart-after-if-2") == 1 + + done = wait_for("after-delay-last") + await client.execute_service(service["run_delay_last_in_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("delay-last-start") == 1 + assert count("after-delay-last") == 1 + + done = wait_for("parallel-after-if-3") + await client.execute_service(service["run_parallel_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + await asyncio.sleep(0.2) + for run in (1, 2, 3): + assert count(f"parallel-branch-done-{run}") == 1 + assert count(f"parallel-after-if-{run}") == 1 + + # A condition that stops its own script must not start a branch or continue the chain + done = wait_for("stop-in-conditions-done") + await client.execute_service(service["run_stop_in_conditions"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("if-stopped-by-condition-should-not-run") == 0 + assert count("if-else-stopped-by-condition-should-not-run") == 0 + assert count("after-if-stopped-by-condition-should-not-run") == 0 + assert count("else-stopped-by-condition-should-not-run") == 0 + assert count("after-else-stopped-by-condition-should-not-run") == 0 + assert count("while-stopped-by-condition-should-not-run") == 0 + assert count("after-while-stopped-by-condition-should-not-run") == 0 From 72413494b0bdbfc1b1b71c440313855a0d76dd8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 05:29:17 -1000 Subject: [PATCH 008/435] [esp32] Disable mbedTLS TLS and TLS-only crypto when no component needs them (#18877) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/audio/__init__.py | 5 +- esphome/components/esp32/__init__.py | 319 +++++++++++----- esphome/components/esp32/const.py | 1 + esphome/components/http_request/__init__.py | 6 +- esphome/components/mqtt/__init__.py | 5 +- esphome/components/nextion/display.py | 5 +- esphome/components/openthread/__init__.py | 9 +- esphome/components/web_server_idf/__init__.py | 5 +- .../web_server_idf/web_server_idf.cpp | 12 +- esphome/components/wifi/__init__.py | 9 +- esphome/components/zigbee/zigbee_esp32.py | 4 + .../esp32/config/mbedtls_tls_wifi_eap.yaml | 3 - .../esp32/config/tls_keep_opt_out.yaml | 9 + .../esp32/config/tls_sdkconfig_esp_tls.yaml | 9 + .../config/tls_sdkconfig_tls_enabled_n.yaml | 9 + tests/component_tests/esp32/test_esp32.py | 349 +++++++++++++++++- 16 files changed, 626 insertions(+), 133 deletions(-) create mode 100644 tests/component_tests/esp32/config/tls_keep_opt_out.yaml create mode 100644 tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml create mode 100644 tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index b882aaa6b75..1480faa8c3f 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -6,7 +6,7 @@ import esphome.codegen as cg from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, - include_builtin_idf_component, + request_http_client, require_certificate_bundle, ) import esphome.config_validation as cv @@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N async def to_code(config: ConfigType) -> None: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - include_builtin_idf_component("esp_http_client") + request_http_client() # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ce1a66a4d22..df72c590a33 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,6 +1,6 @@ from collections.abc import Callable, Iterable import contextlib -from dataclasses import dataclass +from dataclasses import dataclass, field import itertools import logging import os @@ -76,6 +76,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_MBEDTLS_SDKCONFIG, KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, @@ -237,7 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers - "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf + "esp-tls", # TLS wrapper - re-included by request_tls() "esp_adc", # ADC driver - only needed by adc component "esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back @@ -812,6 +813,97 @@ def request_software_coexistence() -> None: include_builtin_idf_component("esp_coex") +@dataclass +class MbedtlsSdkconfigData: + """Inputs for the mbedTLS sdkconfig flags, reconciled at FINAL. + + Components call the require_mbedtls_*() helpers (and request_tls(), which + sets tls_required and also un-excludes the esp-tls component) rather than + writing the CONFIG_MBEDTLS_* flags directly; _reconcile_mbedtls_sdkconfig() + decides the final values once every to_code has run. + """ + + ecp_required: bool = False # ECDH/ECDSA without TLS (openthread SRP host key) + tls_required: bool = False # mbedTLS TLS role needed without the esp-tls wrapper + tls_server_required: bool = False # server-side TLS/DTLS handshake + tls_extras_required: set[str] = field(default_factory=set) # kept TLS extras + peer_cert_required: bool = False # keep the peer certificate after the handshake + pkcs7_required: bool = False # PKCS#7 parsing + sha512_required: bool = False # SHA-384/SHA-512 + # esp32 advanced disable_mbedtls_* options + disable_tls: bool = True + disable_tls_server: bool = True + disable_tls_extras: bool = True + disable_peer_cert: bool = True + disable_pkcs7: bool = True + + +def _mbedtls_sdkconfig() -> MbedtlsSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_MBEDTLS_SDKCONFIG not in data: + data[KEY_MBEDTLS_SDKCONFIG] = MbedtlsSdkconfigData() + return data[KEY_MBEDTLS_SDKCONFIG] + + +# IDF components that reference esp_tls symbols from their own code, so +# re-including any of them is an implicit TLS request. +_ESP_TLS_LINKING_COMPONENTS = ( + "esp-tls", + "esp_http_client", + "esp_https_ota", + "esp_https_server", + "esp_local_ctrl", + "mqtt", +) + + +def _mbedtls_tls_required() -> bool: + """TLS stays in the build: requested, or an esp_tls-linking component was re-included. + + The exclusion-set signal keeps external components working whose only + obligation before request_tls() existed was include_builtin_idf_component() + of esp-tls or of a component that links it (esp_http_client, IDF mqtt). + """ + if _mbedtls_sdkconfig().tls_required: + return True + excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + return any(name not in excluded for name in _ESP_TLS_LINKING_COMPONENTS) + + +def _mbedtls_tls_compiled_out() -> bool: + """True when this build removes the TLS stack from mbedTLS entirely.""" + return ( + not CORE.using_arduino + and _mbedtls_sdkconfig().disable_tls + and not _mbedtls_tls_required() + ) + + +def require_mbedtls_tls() -> None: + """Keep the mbedTLS TLS stack without compiling the esp-tls wrapper. + + For code that talks to mbedTLS directly (wpa_supplicant's EAP client). + Components that use esp_tls call request_tls() instead. + """ + _mbedtls_sdkconfig().tls_required = True + + +def request_tls() -> None: + """Request the mbedTLS TLS stack and the esp-tls wrapper. + + Without a request TLS and its ECP/PEM-write/CRL/CSR crypto compile out; + hashes, AES and RSA stay available. + """ + require_mbedtls_tls() + include_builtin_idf_component("esp-tls") + + +def request_http_client() -> None: + """Request ESP-IDF's HTTP client; it links esp_tls even for plain http.""" + include_builtin_idf_component("esp_http_client") + request_tls() + + def add_idf_component( *, name: str, @@ -1847,6 +1939,7 @@ CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" +CONF_DISABLE_MBEDTLS_TLS = "disable_mbedtls_tls" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server" CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras" @@ -1862,12 +1955,7 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" # Feature requirement tracking - components can call require_* functions to re-enable # These are stored in CORE.data[KEY_ESP32] dict KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" -KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" -KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" -KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required" -KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required" KEY_FATFS_REQUIRED = "fatfs_required" -KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" @@ -1906,6 +1994,9 @@ def require_certificate_bundle() -> None: certificates (http_request, audio streaming) call this so the bundle is compiled and gen_crt_bundle runs only when something uses it. """ + # esp_crt_bundle.c lives in the mbedtls component and calls + # mbedtls_ssl_conf_*, so a bundle needs the TLS role but not esp-tls. + require_mbedtls_tls() CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True @@ -1931,33 +2022,37 @@ def require_usb_serial_jtag_secondary() -> None: CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True -def require_mbedtls_peer_cert() -> None: - """Mark that mbedTLS peer certificate retention is required by a component. +def require_mbedtls_ecp() -> None: + """Keep mbedTLS elliptic curve support (ECDH/ECDSA) without requesting TLS. - Call this from components that need access to the peer certificate after - the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE - from being disabled. + Call this from components that sign or verify with ECDSA outside a TLS + handshake (openthread's SRP host key). WiFi, Bluetooth and secure boot + select it through Kconfig on their own. """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True + _mbedtls_sdkconfig().ecp_required = True + + +def require_mbedtls_peer_cert() -> None: + """Keep the peer certificate after the TLS handshake (CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE). + + A user sdkconfig_options value takes precedence. + """ + _mbedtls_sdkconfig().peer_cert_required = True def require_mbedtls_pkcs7() -> None: - """Mark that mbedTLS PKCS#7 support is required by a component. - - Call this from components that need PKCS#7 certificate validation. - This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True + """Keep mbedTLS PKCS#7 support (CONFIG_MBEDTLS_PKCS7_C). A user sdkconfig_options value takes precedence.""" + _mbedtls_sdkconfig().pkcs7_required = True def require_mbedtls_tls_server() -> None: - """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + """Widen the TLS role to include the server-side handshake. - Call this from components that accept TLS connections (OpenThread's DTLS - commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from - being selected. + Only affects builds where TLS is compiled in; it prevents + CONFIG_MBEDTLS_TLS_CLIENT_ONLY from being selected. A component that + actually opens or accepts TLS/DTLS sessions must also call request_tls(). """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + _mbedtls_sdkconfig().tls_server_required = True def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: @@ -1970,18 +2065,14 @@ def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied sdkconfig_options value is never overridden either. """ - required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) - required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options) + _mbedtls_sdkconfig().tls_extras_required.update( + MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options + ) def require_mbedtls_sha512() -> None: - """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. - - Call this from components that need to verify TLS certificates or signatures - using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C - and CONFIG_MBEDTLS_SHA512_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True + """Keep mbedTLS SHA-384/SHA-512 (CONFIG_MBEDTLS_SHA384_C / CONFIG_MBEDTLS_SHA512_C).""" + _mbedtls_sdkconfig().sha512_required = True def idf_version() -> cv.Version: @@ -2135,6 +2226,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, @@ -2500,34 +2592,103 @@ MBEDTLS_TLS_ROLE_OPTIONS = ( ) -@coroutine_with_priority(CoroPriority.FINAL) -async def _reconcile_mbedtls_tls_sdkconfig( - disable_tls_server: bool, disable_tls_extras: bool -) -> None: - """Trim mbedTLS to what a TLS client needs unless a component asked otherwise. +# User sdkconfig_options that mean "keep TLS on" when set to y. The +# OpenThread entries compile its DTLS secure transport in, which links +# mbedtls_ssl_*. +_MBEDTLS_TLS_ON_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_ENABLED", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_OPENTHREAD_COMMISSIONER", + "CONFIG_OPENTHREAD_JOINER", + "CONFIG_OPENTHREAD_BORDER_AGENT_ENABLE", + # Border router defaults the border agent (and its DTLS) on. + "CONFIG_OPENTHREAD_BORDER_ROUTER", + # Enterprise WiFi selects TLS back on (see the TLS-off block). + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", +) +# Any user option under these prefixes only makes sense with TLS compiled in. +_TLS_OPTION_PREFIXES = ("CONFIG_ESP_TLS_", "CONFIG_MBEDTLS_SSL_", "CONFIG_ESP_HTTPS_") - Runs at FINAL priority so every require_mbedtls_tls_server() and - require_mbedtls_tls_extras() call has happened. Only the server-side - handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS - connections, but OpenThread's DTLS commissioner does. A user-supplied - sdkconfig_options value always wins; for the TLS role choice, any member - the user set leaves the whole choice alone so the pair cannot conflict. + +def _user_sdkconfig_wants_tls(options: dict[str, Any]) -> bool: + """True when sdkconfig_options turn TLS on or tune something under it; an `n` is never a request.""" + return any( + (name in _MBEDTLS_TLS_ON_OPTIONS and value == "y") + or (name == "CONFIG_MBEDTLS_TLS_DISABLED" and value == "n") + or (name.startswith(_TLS_OPTION_PREFIXES) and value != "n") + for name, value in options.items() + ) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_sdkconfig() -> None: + """Reconcile the mbedTLS sdkconfig flags after every request_tls() / require_mbedtls_*() call. + + mbedtls cannot be excluded from an IDF build (bootloader_support needs its + SHA-256), but with no TLS user the ssl_*.c sources and the TLS-only crypto + compile to empty objects. When TLS stays in, it is trimmed to the client + role and the legacy handshake extras are dropped. User sdkconfig_options + win; a user-chosen TLS role leaves the whole choice alone. """ - data = CORE.data[KEY_ESP32] - sdkconfig = data[KEY_SDKCONFIG_OPTIONS] - if ( - disable_tls_server - and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False) - and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS) + data = _mbedtls_sdkconfig() + idf6 = idf_version() >= cv.Version(6, 0, 0) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + if _mbedtls_tls_compiled_out(): + # IDF 6 made CONFIG_MBEDTLS_TLS_ENABLED a normal bool; on IDF 5 it has + # no prompt and is only reachable through the "None" TLS role choice. + if idf6: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_ENABLED", False) + else: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_DISABLED", True) + # Enterprise WiFi selects TLS back on; wifi writes this itself, but + # esp_wifi can also be in the build without a wifi: block (openthread). + set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + # WiFi (ESP_WIFI_MBEDTLS_CRYPTO) and Bluetooth deliberately stay on + # the select-wins path: an unconditional request from request_wifi() + # would defeat the ECP trim for users who disable that select. + if not data.ecp_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_ECP_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PEM_WRITE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CRL_PARSE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CSR_PARSE_C", False) + elif ( + # TLS stays in: trim it to the client role unless a component accepts + # TLS connections or the user already chose a role. + data.disable_tls_server + and not data.tls_server_required + and not any(option in opts for option in MBEDTLS_TLS_ROLE_OPTIONS) ): add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False) - if disable_tls_extras: - required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + + # The extras run either way: CCM and deterministic ECDSA are plain + # crypto, not TLS-gated, so they matter even with TLS compiled out. + if data.disable_tls_extras: for option in MBEDTLS_TLS_EXTRA_OPTIONS: - if option not in required: + if option not in data.tls_extras_required: set_idf_sdkconfig_default(option, False) + # Keeping the peer certificate costs ~4KB heap per connection. + if data.peer_cert_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) + elif data.disable_peer_cert: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) + + if data.pkcs7_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", True) + elif data.disable_pkcs7: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", False) + + # SHA-384 shares the SHA-512 compression function, so both go together. + # Only IDF 6.0's PSA engine links a ~3KB software fallback for them; on + # IDF 5 they are a single hardware-only option with no code size cost. + if idf6 and not data.sha512_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA384_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA512_C", False) + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: @@ -3150,6 +3311,10 @@ async def to_code(config): for key, flag in SIGNING_SCHEMES.items(): add_idf_sdkconfig_option(flag, scheme == key) + if scheme in (SIGNING_SCHEME_ECDSA256, SIGNING_SCHEME_ECDSA_V1): + # SECURE_SIGNED_APPS selects ECP in Kconfig anyway; requesting it + # keeps the resolved sdkconfig consistent with what ESPHome wrote. + require_mbedtls_ecp() if CONF_SIGNING_KEY in signed_ota: # Private key mode — auto-sign binaries during build @@ -3220,38 +3385,6 @@ async def to_code(config): if advanced[CONF_DISABLE_DEV_NULL_VFS]: add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False) - # Disable keeping peer certificate after TLS handshake - # Saves ~4KB heap per connection, but prevents certificate inspection after handshake - # Components that need it can call require_mbedtls_peer_cert() - if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False): - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) - elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]: - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) - - # Disable PKCS#7 support in mbedTLS - # Only needed for specific certificate validation scenarios - # Components that need it can call require_mbedtls_pkcs7() - if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False): - # Component called require_mbedtls_pkcs7() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True) - elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]: - add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False) - - # Disable SHA-384 and SHA-512 in mbedTLS - # ESPHome doesn't use either algorithm. SHA-384 shares the same - # compression function as SHA-512 (mbedtls_internal_sha512_process), - # so both must be disabled to eliminate the ~3KB software fallback - # that IDF 6.0's PSA parallel engine always links in. - # On IDF < 6.0 these are a single config and hardware-only (no - # software fallback), so there was no code size cost to leaving - # them enabled. - # Components that need SHA-384/SHA-512 can call require_mbedtls_sha512() - if idf_version() >= cv.Version(6, 0, 0) and not CORE.data[KEY_ESP32].get( - KEY_MBEDTLS_SHA512_REQUIRED, False - ): - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) - # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) @@ -3261,12 +3394,14 @@ async def to_code(config): # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) - # FINAL priority: runs after every require_mbedtls_tls_*() call - CORE.add_job( - _reconcile_mbedtls_tls_sdkconfig, - advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER], - advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS], - ) + # FINAL priority: runs after every request_tls() / require_mbedtls_*() call + mbedtls = _mbedtls_sdkconfig() + mbedtls.disable_tls = advanced[CONF_DISABLE_MBEDTLS_TLS] + mbedtls.disable_tls_server = advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER] + mbedtls.disable_tls_extras = advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS] + mbedtls.disable_peer_cert = advanced[CONF_DISABLE_MBEDTLS_PEER_CERT] + mbedtls.disable_pkcs7 = advanced[CONF_DISABLE_MBEDTLS_PKCS7] + CORE.add_job(_reconcile_mbedtls_sdkconfig) # FINAL: require_*() calls can come from to_code at or below this priority, so an # inline read would be iteration-order-dependent; reconcile once after every job ran. @@ -3299,6 +3434,8 @@ async def to_code(config): # so it still gets the CMN variant pinned. if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y": require_certificate_bundle() + if _user_sdkconfig_wants_tls(conf[CONF_SDKCONFIG_OPTIONS]): + request_tls() # Components from YAML are added in a separate coroutine with FINAL priority # Schedule it to run after all other components diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index a0c9809c50c..33751ea7d52 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -23,6 +23,7 @@ KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_CERT_BUNDLE = "cert_bundle" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_NETWORK_SDKCONFIG = "network_sdkconfig" +KEY_MBEDTLS_SDKCONFIG = "mbedtls_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index de35d52a409..5faf9896868 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -202,11 +202,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time). - # esp-tls is re-enabled too because http_request includes - # directly and esp_http_client only pulls it in as a private dependency. - esp32.include_builtin_idf_component("esp_http_client") - esp32.include_builtin_idf_component("esp-tls") + esp32.request_http_client() cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 9418614346f..65a7cf409b8 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -5,6 +5,7 @@ from esphome.components.esp32 import ( add_idf_component, idf_version, include_builtin_idf_component, + request_tls, ) from esphome.config_helpers import ( filter_source_files_from_defines, @@ -358,8 +359,8 @@ async def to_code(config): add_idf_component(name="espressif/mqtt", ref="1.0.0") else: include_builtin_idf_component("mqtt") - # mqtt_client.h drags in esp_tls types; esp-tls is excluded by default - include_builtin_idf_component("esp-tls") + # esp-mqtt links transport_ssl.c (esp_tls) even for plain MQTT + request_tls() cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 73a56e0a42b..18f0a73928d 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -271,10 +271,7 @@ async def to_code(config): ) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - # and esp-tls, whose sdkconfig options below need the component present - esp32.include_builtin_idf_component("esp_http_client") - esp32.include_builtin_idf_component("esp-tls") + esp32.request_http_client() esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index ac14efc8e68..80b28a26285 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,6 +13,7 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_ecp, require_mbedtls_tls_extras, require_mbedtls_tls_server, require_vfs_select, @@ -112,9 +113,9 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - # OpenThread's DTLS commissioner is a TLS server, and its crypto platform - # uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component - # from trimming them out of mbedTLS. + # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; + # setting one under sdkconfig_options keeps TLS in the build automatically. + # The crypto platform uses AES-CCM and deterministic ECDSA directly. require_mbedtls_tls_server() require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") @@ -293,6 +294,8 @@ async def to_code(config: ConfigType) -> None: # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") + # OPENTHREAD_CONFIG_ECDSA_ENABLE: the SRP client host key uses mbedtls_ecdsa_* + require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") if config.get(CONF_FORCE_DATASET): diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5eac63212f2..b9d079b53fb 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -17,9 +17,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server set_idf_sdkconfig_default("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) - # Re-enable esp-tls (excluded by default to save compile time); - # web_server_idf.cpp includes for digest auth - include_builtin_idf_component("esp-tls") + # Re-enable ESP-IDF's HTTP server (excluded by default to save compile time). + # Basic auth uses mbedtls_base64_encode directly, so no TLS stack is needed. include_builtin_idf_component("esp_http_server") diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9798410e238..a35ad652141 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -12,7 +12,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esp_tls_crypto.h" +#include #include #include @@ -554,14 +554,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw constexpr size_t max_digest_len = 350; char digest[max_digest_len]; size_t out; - esp_crypto_base64_encode(reinterpret_cast(digest), max_digest_len, &out, - reinterpret_cast(user_info), user_info_len); + // The buffer bound above makes failure unreachable; reject rather than + // compare against an unwritten digest if that ever changes. + if (mbedtls_base64_encode(reinterpret_cast(digest), max_digest_len, &out, + reinterpret_cast(user_info), user_info_len) != 0) { + return false; + } // Constant-time comparison to avoid timing side channels. // No early return on length mismatch — the length difference is folded // into the accumulator so any mismatch is rejected. const char *provided = auth_str + auth_prefix_len; - size_t digest_len = out; // length from esp_crypto_base64_encode + size_t digest_len = out; // Derive provided_len from the already-sized std::string rather than // rescanning with strlen (avoids attacker-controlled scan length). size_t provided_len = auth.value().size() - auth_prefix_len; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 27ebdbc9763..f357bf75b35 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,6 +12,7 @@ from esphome.components.esp32 import ( get_esp32_variant, only_on_variant, request_wifi, + require_mbedtls_tls, require_mbedtls_tls_extras, ) from esphome.components.network import ( @@ -683,10 +684,10 @@ async def to_code(config): if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) if has_eap: - # wpa_supplicant's EAP client negotiates with whatever the RADIUS - # server offers, and a failed handshake leaves the device off the - # network, so keep every mbedTLS client feature the esp32 platform - # would otherwise trim. + # The supplicant's Kconfig select cannot override the IDF 5 TLS + # role choice; the EAP client talks to mbedTLS directly (no + # esp-tls) and needs every trimmed extra. + require_mbedtls_tls() require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 463a398033c..103e901b13b 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, + require_mbedtls_ecp, require_mbedtls_tls_extras, require_vfs_select, ) @@ -384,6 +385,9 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": name="espressif/esp-zigbee-lib", ref="2.0.4", ) + # The esp-zigbee-lib blobs reference mbedtls_ecp_* (Zigbee Direct, install + # code ECDH); keep ECP without relying on esp_wifi's Kconfig select. + require_mbedtls_ecp() # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. # Keep the esp32 component from trimming them out of mbedTLS. diff --git a/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml index 6c78e062650..ab77a1808ec 100644 --- a/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml +++ b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml @@ -12,6 +12,3 @@ wifi: identity: "user@example.org" username: "user" password: "secret" - -http_request: - verify_ssl: true diff --git a/tests/component_tests/esp32/config/tls_keep_opt_out.yaml b/tests/component_tests/esp32/config/tls_keep_opt_out.yaml new file mode 100644 index 00000000000..58b6692a8d6 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_keep_opt_out.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + disable_mbedtls_tls: false diff --git a/tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml b/tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml new file mode 100644 index 00000000000..888a62b8493 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_sdkconfig_esp_tls.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_ESP_TLS_INSECURE: y diff --git a/tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml b/tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml new file mode 100644 index 00000000000..5bd4e971302 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_sdkconfig_tls_enabled_n.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_MBEDTLS_TLS_ENABLED: n diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index b41234db1a0..04416d7f57a 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -11,25 +11,30 @@ from typing import Any import pytest from esphome.components.esp32 import ( + _ESP_TLS_LINKING_COMPONENTS, + DEFAULT_EXCLUDED_IDF_COMPONENTS, ESP32_FLASH_CHIPS, KEY_FATFS_REQUIRED, - KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, - KEY_MBEDTLS_TLS_SERVER_REQUIRED, KEY_VFS_DIR_REQUIRED, KEY_VFS_SELECT_REQUIRED, KEY_VFS_TERMIOS_REQUIRED, MBEDTLS_TLS_EXTRA_OPTIONS, VARIANT_ESP32, VARIANTS, + MbedtlsSdkconfigData, NetworkSdkconfigData, RawSdkconfigValue, _ota_downgrade_protection_errors, + _reconcile_mbedtls_sdkconfig, _reconcile_network_sdkconfig, _reconcile_vfs_fatfs_sdkconfig, + _user_sdkconfig_wants_tls, ) from esphome.components.esp32.const import ( KEY_ESP32, KEY_EXCLUDE_COMPONENTS, + KEY_IDF_VERSION, + KEY_MBEDTLS_SDKCONFIG, KEY_NETWORK_SDKCONFIG, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT, @@ -322,8 +327,8 @@ def test_esp32_configuration_errors( ("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"), id="i2c_ledc_ac_dimmer", ), - # esp-tls has three owners; a per-owner config makes a dropped - # re-include from any single one fail the test. + # esp-tls comes back through request_tls(); a per-owner config makes + # a dropped request from any single one fail the test. pytest.param( "exclusion_reincludes_http_request.yaml", ("esp-tls", "esp_http_client"), @@ -337,8 +342,9 @@ def test_esp32_configuration_errors( id="mqtt", ), pytest.param( + # Basic auth uses mbedtls_base64_encode directly, so no esp-tls. "exclusion_reincludes_web_server.yaml", - ("esp-tls", "esp_http_server"), + ("esp_http_server",), id="web_server_idf", ), pytest.param( @@ -480,6 +486,303 @@ def test_user_sdkconfig_certificate_bundle_wins( assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False +_TLS_OFF_CRYPTO = { + "CONFIG_MBEDTLS_ECP_C": False, + "CONFIG_MBEDTLS_PEM_WRITE_C": False, + "CONFIG_MBEDTLS_X509_CRL_PARSE_C": False, + "CONFIG_MBEDTLS_X509_CSR_PARSE_C": False, +} +_TLS_OFF_IDF5 = { + "CONFIG_MBEDTLS_TLS_DISABLED": True, + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False, + **_TLS_OFF_CRYPTO, +} +_TLS_OFF_IDF6 = { + "CONFIG_MBEDTLS_TLS_ENABLED": False, + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": False, + **_TLS_OFF_CRYPTO, +} +_PEER_CERT_PKCS7_OFF = { + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": False, + "CONFIG_MBEDTLS_PKCS7_C": False, +} +_TLS_EXTRAS_OFF = dict.fromkeys(MBEDTLS_TLS_EXTRA_OPTIONS, False) +_TLS_CLIENT_ONLY = { + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY": True, + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT": False, +} +_IDF5 = cv.Version(5, 5, 5) +_IDF6 = cv.Version(6, 0, 0) + + +@pytest.mark.parametrize( + ("framework", "idf", "data", "preset", "expected", "excluded"), + [ + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_OFF_IDF5, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf5_no_tls_user", + ), + pytest.param( + # An external component that only re-included esp-tls keeps TLS. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS) - {"esp-tls"}, + id="idf_esp_tls_reincluded", + ), + pytest.param( + # esp_http_client links esp_tls itself, so re-including it counts too. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS) - {"esp_http_client"}, + id="idf_http_client_reincluded", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF6, + MbedtlsSdkconfigData(), + {}, + { + **_TLS_OFF_IDF6, + **_TLS_EXTRAS_OFF, + **_PEER_CERT_PKCS7_OFF, + "CONFIG_MBEDTLS_SHA384_C": False, + "CONFIG_MBEDTLS_SHA512_C": False, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf6_drops_sha512", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF6, + MbedtlsSdkconfigData(sha512_required=True), + {}, + {**_TLS_OFF_IDF6, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf6_sha512_required", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(ecp_required=True), + {}, + { + **{ + k: v + for k, v in _TLS_OFF_IDF5.items() + if k != "CONFIG_MBEDTLS_ECP_C" + }, + **_TLS_EXTRAS_OFF, + **_PEER_CERT_PKCS7_OFF, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_ecp_without_tls", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(), + {"CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y")}, + { + **_TLS_OFF_IDF5, + "CONFIG_MBEDTLS_ECP_C": RawSdkconfigValue("y"), + **_TLS_EXTRAS_OFF, + **_PEER_CERT_PKCS7_OFF, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_user_ecp_wins", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(peer_cert_required=True, pkcs7_required=True), + {}, + { + **_TLS_OFF_IDF5, + **_TLS_EXTRAS_OFF, + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": True, + "CONFIG_MBEDTLS_PKCS7_C": True, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_peer_cert_pkcs7_required", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(disable_peer_cert=False, disable_pkcs7=False), + {}, + {**_TLS_OFF_IDF5, **_TLS_EXTRAS_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_advanced_disables_off", + ), + pytest.param( + # advanced: disable_mbedtls_tls: false keeps TLS with no requester. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(disable_tls=False), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_disable_tls_opt_out", + ), + pytest.param( + # require_mbedtls_tls() keeps TLS with every wrapper still excluded. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(tls_required=True), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_require_mbedtls_tls", + ), + pytest.param( + # TLS kept: a required server role blocks the client-only trim. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(tls_server_required=True), + {}, + {**_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS) - {"esp-tls"}, + id="idf_tls_server_required", + ), + pytest.param( + PlatformFramework.ESP32_ARDUINO, + _IDF5, + MbedtlsSdkconfigData(), + {}, + {**_TLS_CLIENT_ONLY, **_TLS_EXTRAS_OFF, **_PEER_CERT_PKCS7_OFF}, + set(_ESP_TLS_LINKING_COMPONENTS), + id="arduino_keeps_tls", + ), + ], +) +def test_reconcile_mbedtls_sdkconfig( + set_core_config: SetCoreConfigCallable, + framework: PlatformFramework, + idf: cv.Version, + data: MbedtlsSdkconfigData, + preset: dict[str, Any], + expected: dict[str, Any], + excluded: set[str], +) -> None: + """The FINAL-priority reconciler turns TLS off only when nothing requested it; + user sdkconfig_options always win.""" + set_core_config(framework) + CORE.data[KEY_ESP32] = { + KEY_IDF_VERSION: idf, + KEY_SDKCONFIG_OPTIONS: dict(preset), + KEY_MBEDTLS_SDKCONFIG: data, + KEY_EXCLUDE_COMPONENTS: excluded, + } + + asyncio.run(_reconcile_mbedtls_sdkconfig()) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + +def test_esp_tls_linking_components_are_excluded_by_default() -> None: + """The fallback scan is only a real signal while every name is excluded by default.""" + assert set(_ESP_TLS_LINKING_COMPONENTS) <= set(DEFAULT_EXCLUDED_IDF_COMPONENTS) + + +@pytest.mark.parametrize( + ("options", "wants_tls"), + [ + pytest.param({}, False, id="empty"), + pytest.param({"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT": "y"}, True, id="role_y"), + pytest.param({"CONFIG_MBEDTLS_TLS_ENABLED": "n"}, False, id="enabled_n"), + pytest.param({"CONFIG_MBEDTLS_TLS_DISABLED": "n"}, True, id="disabled_n"), + pytest.param({"CONFIG_ESP_TLS_INSECURE": "y"}, True, id="esp_tls_prefix"), + pytest.param( + {"CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": "n"}, + False, + id="prefix_n_is_not_a_request", + ), + pytest.param({"CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP": "y"}, True, id="https_prefix"), + pytest.param({"CONFIG_OPENTHREAD_COMMISSIONER": "y"}, True, id="ot_dtls_y"), + pytest.param({"CONFIG_OPENTHREAD_JOINER": "n"}, False, id="ot_dtls_n"), + pytest.param({"CONFIG_OPENTHREAD_BORDER_ROUTER": "y"}, True, id="ot_br_y"), + pytest.param( + {"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT": "y"}, True, id="wifi_enterprise_y" + ), + pytest.param({"CONFIG_LWIP_IPV6": "y"}, False, id="unrelated"), + ], +) +def test_user_sdkconfig_wants_tls(options: dict[str, Any], wants_tls: bool) -> None: + """The sdkconfig escape hatch reads values, never bare key presence.""" + assert _user_sdkconfig_wants_tls(options) is wants_tls + + +@pytest.mark.parametrize( + ("config_file", "tls_off", "ecp_off", "esp_tls_excluded"), + [ + pytest.param("network_ethernet_only.yaml", True, True, True, id="ethernet_api"), + pytest.param( + "exclusion_reincludes_web_server.yaml", + True, + True, + True, + id="web_server_idf", + ), + pytest.param( + "exclusion_reincludes_http_request.yaml", + False, + False, + False, + id="http_request", + ), + pytest.param("exclusion_reincludes_mqtt.yaml", False, False, False, id="mqtt"), + pytest.param( + "exclusion_reincludes_nextion.yaml", False, False, False, id="nextion" + ), + pytest.param("mbedtls_tls_wifi_eap.yaml", False, False, True, id="wifi_eap"), + # zigbee requests ECP for the esp-zigbee-lib blobs, without TLS. + pytest.param("tls_zigbee_c6.yaml", True, False, True, id="zigbee"), + # A raw bundle keeps the TLS role but no longer compiles esp-tls. + pytest.param( + "certificate_bundle_sdkconfig.yaml", False, False, True, id="raw_bundle" + ), + pytest.param( + "tls_sdkconfig_esp_tls.yaml", False, False, False, id="raw_esp_tls" + ), + # A role option set to n is not a request. + pytest.param( + "tls_sdkconfig_tls_enabled_n.yaml", True, True, True, id="raw_tls_enabled_n" + ), + # ECDSA signed OTA requests ECP itself (SECURE_SIGNED_APPS selects it too). + pytest.param( + "signed_ota_ecdsa256_c6.yaml", True, False, True, id="signed_ota_ecdsa" + ), + ], +) +def test_tls_disabled_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + tls_off: bool, + ecp_off: bool, + esp_tls_excluded: bool, +) -> None: + """TLS is compiled out unless a component or a raw sdkconfig option asks for it.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert (sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True) is tls_off + assert sdkconfig.get("CONFIG_MBEDTLS_ECP_C") is (False if ecp_off else None) + assert ( + "esp-tls" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + ) is esp_tls_excluded + + def test_execute_from_psram_s3_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], @@ -1496,9 +1799,14 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The OpenThread config keeps the DTLS server, CCM and deterministic ECDSA; the rest is trimmed.""" + """Nothing in the OpenThread config links TLS, so the stack is compiled out + and no TLS role is written; the extras trim still runs because CCM and + deterministic ECDSA are plain crypto, and OpenThread keeps those two.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + # require_mbedtls_ecp() keeps ECP for the SRP host key while TLS is off + assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) @@ -1508,14 +1816,31 @@ def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed.""" + """Nothing in the Zigbee config links TLS, so the stack is compiled out and + no role is written; the extras trim still runs and Zigbee keeps CCM and + deterministic ECDSA.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False) + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) +def test_mbedtls_tls_opt_out_keeps_stack_and_trims_role( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """disable_mbedtls_tls: false keeps TLS with no requester; the client-only + and extras trims then still apply.""" + generate_main(component_config_path("tls_keep_opt_out.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_MBEDTLS_TLS_DISABLED" not in sdkconfig + assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_CLIENT_ONLY") is True + assert sdkconfig.get("CONFIG_MBEDTLS_SSL_RENEGOTIATION") is False + + def test_mbedtls_tls_user_sdkconfig_wins( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], @@ -1541,8 +1866,9 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( ) -> None: """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) - assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True - assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS + mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_server_required is True + assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS def test_mbedtls_tls_zigbee_requires_extras( @@ -1551,7 +1877,8 @@ def test_mbedtls_tls_zigbee_requires_extras( ) -> None: """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) - assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS + mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} From cf96c53279539f0a4420daf5fa87b04edcccfcdf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 05:40:12 -1000 Subject: [PATCH 009/435] [espnow] Keep constant send payloads in shared flash tables (#20271) --- esphome/automation.py | 28 +++++++- esphome/components/espnow/__init__.py | 8 +-- esphome/components/espnow/automation.h | 8 ++- esphome/core/automation.h | 64 +++++++++++++++++++ .../espnow/config/payload_tables.yaml | 22 +++++++ .../espnow/test_payload_tables.py | 23 +++++++ .../core/test_templatable_bytes.cpp | 43 +++++++++++++ tests/unit_tests/test_automation.py | 47 ++++++++++++++ 8 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/espnow/config/payload_tables.yaml create mode 100644 tests/component_tests/espnow/test_payload_tables.py create mode 100644 tests/components/core/test_templatable_bytes.cpp diff --git a/esphome/automation.py b/esphome/automation.py index c8d0e4c5743..b69edd04a13 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -21,7 +21,7 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import CORE, ID, EsphomeError, Lambda +from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda from esphome.cpp_generator import ( FlashStringLiteral, LambdaExpression, @@ -35,6 +35,32 @@ from esphome.types import ConfigType, SafeExpType from esphome.util import Registry +def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj: + """Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr.""" + if not data: + return cg.nullptr + return cg.shared_progmem_array( + name, cg.uint8, cg.ArrayInitializer(*(HexInt(x) for x in data)) + ) + + +async def templatable_bytes( + value: Any, + args: TemplateArgsType, + set_template: MockObj, + set_static: MockObj, + table_name: str, +) -> None: + """Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static.""" + if cg.is_template(value): + fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + cg.add(set_template(fn)) + elif len(value) > 0xFFFF: + raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535") + else: + cg.add(set_static(progmem_bytes(table_name, value), len(value))) + + def maybe_simple_id(*validators): """Allow a raw ID to be specified in place of a config block. If the value that's being validated is a dictionary, it's passed as-is to the specified validators. Otherwise, it's diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index c04cf1ed59b..dc36885f2dc 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -24,7 +24,6 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["network"] -byte_vector = cg.std_vector.template(cg.uint8) peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) espnow_ns = cg.esphome_ns.namespace("espnow") @@ -304,11 +303,12 @@ async def send_action( await register_peer(var, config, args) - data = config.get(CONF_DATA, []) + data = config[CONF_DATA] if isinstance(data, str): data = list(data.encode()) - templ = await cg.templatable(data, args, byte_vector, byte_vector) - cg.add(var.set_data(templ)) + await automation.templatable_bytes( + data, args, var.set_data_template, var.set_data_static, "espnow_data" + ) cg.add(var.set_wait_for_sent(config[CONF_WAIT_FOR_SENT])) cg.add(var.set_continue_on_error(config[CONF_CONTINUE_ON_ERROR])) diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 3c9c6e8be95..851b8345c94 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -11,7 +11,7 @@ namespace esphome::espnow { template class SendAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); - TEMPLATABLE_VALUE(std::vector, data); + TEMPLATABLE_BYTES(data) public: void add_on_sent(const std::initializer_list *> &actions) { @@ -58,8 +58,10 @@ template class SendAction final : public Action, public P } }; peer_address_t address = this->address_.value(x...); - std::vector data = this->data_.value(x...); - esp_err_t err = this->parent_->send(address.data(), data, send_callback); + esp_err_t err = ESP_OK; + this->data_.visit( + [&](const uint8_t *data, size_t len) { err = this->parent_->send(address.data(), data, len, send_callback); }, + x...); if (err != ESP_OK) { send_callback(err); } else if (!this->flags_.wait_for_sent) { diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 61f34d70f45..f5921168e02 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -69,6 +69,70 @@ template class TemplatableFn { T (*f_)(X...){nullptr}; }; +/// Byte payload that is either a stateless lambda or a static table, which may be in PROGMEM. +/// 8 bytes on 32-bit; codegen stores constant payloads as shared flash tables. +template class TemplatableBytes { + public: + void set_template(std::vector (*func)(Ts...)) { + this->code_.func = func; + this->len_ = -1; + } + void set_static(const uint8_t *data, uint16_t len) { + this->code_.data = data; + this->len_ = len; + } + bool is_static() const { return this->len_ >= 0; } + /// Only valid when is_static(); may point to PROGMEM, so read it with progmem_memcpy or progmem_read_byte. + const uint8_t *data() const { return this->code_.data; } + /// Only valid when is_static(). + size_t size() const { return static_cast(this->len_); } + std::vector value(const Ts &...x) const { + if (this->len_ < 0) + return this->code_.func(x...); + return to_vector(this->code_.data, this->size()); + } + /// Calls fn(const uint8_t *data, size_t len) with the payload readable from RAM: a lambda's vector, a static + /// table directly, or on ESP8266 a copy of the PROGMEM table (on the stack up to N bytes). + template void visit(F &&fn, const Ts &...x) const { + if (this->len_ < 0) { + const std::vector bytes = this->code_.func(x...); + fn(bytes.data(), bytes.size()); + return; + } +#ifdef USE_ESP8266 + SmallBufferWithHeapFallback buf(this->size()); + if (this->len_ != 0) + progmem_memcpy(buf.get(), this->code_.data, this->size()); + fn(buf.get(), this->size()); +#else + fn(this->code_.data, this->size()); +#endif + } + + protected: + static std::vector to_vector(const uint8_t *data, size_t len) { + std::vector out(len); + // An empty payload is (nullptr, 0), and memcpy from nullptr is undefined even for zero bytes. + if (len != 0) + progmem_memcpy(out.data(), data, len); // byte loads from flash fault on ESP8266 + return out; + } + + union { + std::vector (*func)(Ts...); + const uint8_t *data; + } code_{}; + int32_t len_{-1}; // -1: lambda, otherwise the length of the static table +}; + +#define TEMPLATABLE_BYTES(name) \ + protected: \ + TemplatableBytes name##_{}; \ +\ + public: \ + void set_##name##_template(std::vector (*func)(Ts...)) { this->name##_.set_template(func); } \ + void set_##name##_static(const uint8_t *data, uint16_t len) { this->name##_.set_static(data, len); } + // Forward declaration for TemplatableValue (string specialization needs it) template class TemplatableValue; diff --git a/tests/component_tests/espnow/config/payload_tables.yaml b/tests/component_tests/espnow/config/payload_tables.yaml new file mode 100644 index 00000000000..b94ef5bf872 --- /dev/null +++ b/tests/component_tests/espnow/config/payload_tables.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: test + on_boot: + then: + - espnow.send: + address: 11:22:33:44:55:66 + data: [0x01, 0x02, 0x03] + - espnow.broadcast: + data: [0x01, 0x02, 0x03] + - espnow.broadcast: "OK" + - espnow.broadcast: + data: !lambda return {0x09}; + +esp32: + board: esp32dev + +wifi: + ssid: test + password: password1 + +espnow: diff --git a/tests/component_tests/espnow/test_payload_tables.py b/tests/component_tests/espnow/test_payload_tables.py new file mode 100644 index 00000000000..d696b2e9799 --- /dev/null +++ b/tests/component_tests/espnow/test_payload_tables.py @@ -0,0 +1,23 @@ +"""Tests for ESP-NOW constant send payloads in shared flash tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_payloads_share_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal payloads share one table; lambdas stay templates.""" + main_cpp = generate_main(component_config_path("payload_tables.yaml")) + + tables = dict( + re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + ) + assert sorted(tables.values()) == sorted(["{0x01, 0x02, 0x03}", "{0x4F, 0x4B}"]) + shared = next(k for k, v in tables.items() if v == "{0x01, 0x02, 0x03}") + assert main_cpp.count(f"set_data_static({shared}, 3);") == 2 + assert "set_data_template(" in main_cpp diff --git a/tests/components/core/test_templatable_bytes.cpp b/tests/components/core/test_templatable_bytes.cpp new file mode 100644 index 00000000000..b75b8c1b556 --- /dev/null +++ b/tests/components/core/test_templatable_bytes.cpp @@ -0,0 +1,43 @@ +#include +#include +#include "esphome/core/automation.h" + +namespace esphome::testing { + +static const uint8_t PAYLOAD[] = {1, 2, 3}; + +static std::vector repeat(int count) { return std::vector(count, 7); } + +TEST(TemplatableBytesTest, VisitStaticTable) { + TemplatableBytes<> bytes; + bytes.set_static(PAYLOAD, sizeof(PAYLOAD)); + std::vector seen; + bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); }); + EXPECT_EQ(seen, (std::vector{1, 2, 3})); +} + +TEST(TemplatableBytesTest, VisitWithLargerStackBuffer) { + TemplatableBytes<> bytes; + bytes.set_static(PAYLOAD, sizeof(PAYLOAD)); + size_t seen = 0; + bytes.visit<256>([&](const uint8_t *, size_t len) { seen = len; }); + EXPECT_EQ(seen, sizeof(PAYLOAD)); +} + +TEST(TemplatableBytesTest, VisitEmptyStaticTable) { + TemplatableBytes<> bytes; + bytes.set_static(nullptr, 0); + size_t seen = 1; + bytes.visit([&](const uint8_t *, size_t len) { seen = len; }); + EXPECT_EQ(seen, 0u); +} + +TEST(TemplatableBytesTest, VisitLambdaWithArgument) { + TemplatableBytes bytes; + bytes.set_template(repeat); + std::vector seen; + bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); }, 4); + EXPECT_EQ(seen, (std::vector(4, 7))); +} + +} // namespace esphome::testing diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 3a902a429f6..2bcd32375e6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -20,6 +20,7 @@ from esphome.automation import ( has_non_synchronous_actions, literal_with_length, maybe_simple_id, + progmem_bytes, register_apply_action, register_apply_condition, register_bare_action, @@ -28,6 +29,7 @@ from esphome.automation import ( register_parented_condition, register_simple_action, register_simple_condition, + templatable_bytes, ) import esphome.codegen as cg import esphome.config_validation as cv @@ -989,3 +991,48 @@ async def test_apply_condition_string_lambda_paths( text = _apply_definition(mock_cg) assert expected in text assert ("-> std::string {" in text) is called + + +def test_progmem_bytes_shares_equal_payloads() -> None: + CORE.config = {} + a = progmem_bytes("payload", [1, 2]) + b = progmem_bytes("payload", b"\x01\x02") + assert a is b + assert str(progmem_bytes("payload", [])) == "nullptr" + + +@pytest.mark.asyncio +async def test_templatable_bytes_static_payload() -> None: + CORE.config = {} + var = MockObj("act", "->") + await templatable_bytes( + [0xA1, 0x02], [], var.set_code_template, var.set_code_static, "payload" + ) + assert "act->set_code_static(payload, 2);" in CORE.cpp_main_section + + +@pytest.mark.asyncio +async def test_templatable_bytes_lambda_payload() -> None: + CORE.config = {} + var = MockObj("act", "->") + await templatable_bytes( + Lambda("return {0x01, 0x02};"), + [], + var.set_code_template, + var.set_code_static, + "payload", + ) + text = CORE.cpp_main_section + assert "act->set_code_template(" in text + assert "-> std::vector" in text + assert "set_code_static" not in text + + +@pytest.mark.asyncio +async def test_templatable_bytes_rejects_oversized_payload() -> None: + CORE.config = {} + var = MockObj("act", "->") + with pytest.raises(EsphomeError, match="maximum is 65535"): + await templatable_bytes( + [0] * 0x10000, [], var.set_code_template, var.set_code_static, "payload" + ) From 006f5f0fc90c5398504b3903fd43fcf5669ea6e1 Mon Sep 17 00:00:00 2001 From: Alexander Pohl Date: Wed, 7 Oct 2026 19:09:35 +0200 Subject: [PATCH 010/435] [xiaomi_mccgq02hl] Add Xiaomi Mijia Door/Window Sensor 2 (MCCGQ02HL) (#20104) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/xiaomi_mccgq02hl/__init__.py | 1 + .../xiaomi_mccgq02hl/binary_sensor.py | 65 +++++++ .../xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp | 168 ++++++++++++++++++ .../xiaomi_mccgq02hl/xiaomi_mccgq02hl.h | 50 ++++++ .../binary_sensor/xiaomi_mccgq02hl_test.cpp | 74 ++++++++ .../xiaomi_mccgq02hl/common-ln.yaml | 9 + tests/components/xiaomi_mccgq02hl/common.h | 76 ++++++++ tests/components/xiaomi_mccgq02hl/common.yaml | 14 ++ .../xiaomi_mccgq02hl/test.esp32-idf.yaml | 3 + .../xiaomi_mccgq02hl/test.ln882x-ard.yaml | 3 + .../xiaomi_mccgq02hl/validate.bk72xx-ard.yaml | 23 +++ 12 files changed, 487 insertions(+) create mode 100644 esphome/components/xiaomi_mccgq02hl/__init__.py create mode 100644 esphome/components/xiaomi_mccgq02hl/binary_sensor.py create mode 100644 esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp create mode 100644 esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h create mode 100644 tests/components/xiaomi_mccgq02hl/binary_sensor/xiaomi_mccgq02hl_test.cpp create mode 100644 tests/components/xiaomi_mccgq02hl/common-ln.yaml create mode 100644 tests/components/xiaomi_mccgq02hl/common.h create mode 100644 tests/components/xiaomi_mccgq02hl/common.yaml create mode 100644 tests/components/xiaomi_mccgq02hl/test.esp32-idf.yaml create mode 100644 tests/components/xiaomi_mccgq02hl/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mccgq02hl/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 930bdc08d2a..944c5f2809d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -657,6 +657,7 @@ esphome/components/xiaomi_body_scale/* @dckiller51 esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 esphome/components/xiaomi_lywsd03mmc/* @ahpohl +esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027 esphome/components/xiaomi_mhoc303/* @drug123 esphome/components/xiaomi_mhoc401/* @vevsvevs esphome/components/xiaomi_rtcgq02lm/* @jesserockz diff --git a/esphome/components/xiaomi_mccgq02hl/__init__.py b/esphome/components/xiaomi_mccgq02hl/__init__.py new file mode 100644 index 00000000000..3aecbd82079 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@ahpohl", "@morph027"] diff --git a/esphome/components/xiaomi_mccgq02hl/binary_sensor.py b/esphome/components/xiaomi_mccgq02hl/binary_sensor.py new file mode 100644 index 00000000000..f410ad7f739 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/binary_sensor.py @@ -0,0 +1,65 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor, ble_device_base, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_BINDKEY, + CONF_LIGHT, + CONF_MAC_ADDRESS, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_OPENING, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["ble_device_base", "sensor"] + +xiaomi_mccgq02hl_ns = cg.esphome_ns.namespace("xiaomi_mccgq02hl") +XiaomiMCCGQ02HL = xiaomi_mccgq02hl_ns.class_( + "XiaomiMCCGQ02HL", + binary_sensor.BinarySensor, + cg.Component, + ble_device_base.ESPBTDeviceListener, +) + +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mccgq02hl"), + binary_sensor.binary_sensor_schema( + XiaomiMCCGQ02HL, device_class=DEVICE_CLASS_OPENING + ) + .extend( + { + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Required(CONF_BINDKEY): cv.bind_key, + cv.Optional(CONF_LIGHT): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_LIGHT + ), + 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, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), +) + + +async def to_code(config: ConfigType) -> None: + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + cg.add(var.set_bindkey(config[CONF_BINDKEY])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_LIGHT, var.set_light) diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp new file mode 100644 index 00000000000..88b9865e476 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp @@ -0,0 +1,168 @@ +#include "xiaomi_mccgq02hl.h" +#include "esphome/components/ble_device_base/ble_aes_ccm.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::xiaomi_mccgq02hl { + +static const char *const TAG = "xiaomi_mccgq02hl"; + +static constexpr uint16_t PRODUCT_ID = 0x098b; + +// MiBeacon frame control, low byte (raw[0]) +static constexpr uint8_t FC_ENCRYPTED = 0x08; +static constexpr uint8_t FC_MAC_INCLUDED = 0x10; +static constexpr uint8_t FC_CAPABILITY = 0x20; +static constexpr uint8_t FC_OBJECT = 0x40; + +// Encrypted frames end in a 3-byte extended counter and a 4-byte MIC. +static constexpr size_t MIC_SIZE = 4; +static constexpr size_t ENCRYPTED_TRAILER = 3 + MIC_SIZE; +// Largest encrypted payload accepted; MCCGQ02HL objects are 4 bytes. +static constexpr size_t MAX_PAYLOAD_SIZE = 16; + +// Object ids. The door and light objects come in both the 0x00xx and the +// 0x10xx flavour depending on firmware; the semantics are identical. +static constexpr uint16_t OBJ_LIGHT = 0x0018; +static constexpr uint16_t OBJ_LIGHT_ALT = 0x1018; +static constexpr uint16_t OBJ_DOOR = 0x0019; +static constexpr uint16_t OBJ_DOOR_ALT = 0x1019; +static constexpr uint16_t OBJ_BATTERY = 0x100A; +static constexpr uint16_t OBJ_BATTERY_ALT = 0x4803; + +void XiaomiMCCGQ02HL::dump_config() { + uint8_t mac[MAC_ADDRESS_SIZE]; + ble_device_base::uint64_to_mac_msb_first(this->address_, mac); + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + "Xiaomi MCCGQ02HL\n" + " MAC Address: %s", + format_mac_addr_upper(mac, mac_buf)); + LOG_BINARY_SENSOR(" ", "Opening", this); + LOG_BINARY_SENSOR(" ", "Light", this->light_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); +} + +void XiaomiMCCGQ02HL::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } + +bool XiaomiMCCGQ02HL::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + bool success = false; + for (auto &service_data : device.get_service_datas()) { + if (!service_data.uuid.contains(0x95, 0xFE)) + continue; + + Reading reading; + if (!this->parse_service_data_(service_data.data, reading)) + continue; + + ESP_LOGD(TAG, "%s: open=%s light=%s battery=%s", this->get_name().c_str(), + reading.open.has_value() ? (*reading.open ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no")) + : LOG_STR_LITERAL("-"), + reading.light.has_value() ? (*reading.light ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no")) + : LOG_STR_LITERAL("-"), + reading.battery_level.has_value() ? LOG_STR_LITERAL("updated") : LOG_STR_LITERAL("-")); + + if (reading.open.has_value()) + this->publish_state(*reading.open); + if (reading.light.has_value() && this->light_ != nullptr) + this->light_->publish_state(*reading.light); + if (reading.battery_level.has_value() && this->battery_level_ != nullptr) + this->battery_level_->publish_state(*reading.battery_level); + success = true; + } + return success; +} + +bool XiaomiMCCGQ02HL::decrypt_(const uint8_t *frame, size_t size, size_t offset, uint8_t *plaintext) const { + uint8_t nonce[MAC_ADDRESS_SIZE + 6]; + for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) + nonce[i] = static_cast(this->address_ >> (i * 8)); // MAC, reversed + memcpy(nonce + MAC_ADDRESS_SIZE, frame + 2, 3); // product id + frame count + memcpy(nonce + MAC_ADDRESS_SIZE + 3, frame + size - ENCRYPTED_TRAILER, 3); // extended counter + static constexpr uint8_t AUTH_DATA[1] = {0x11}; + return ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce, sizeof(nonce), AUTH_DATA, sizeof(AUTH_DATA), + frame + offset, size - offset - ENCRYPTED_TRAILER, plaintext, + frame + size - MIC_SIZE, MIC_SIZE); +} + +bool XiaomiMCCGQ02HL::parse_service_data_(const std::vector &data, Reading &reading) { + const size_t size = data.size(); + if (size < 5) + return false; + const uint8_t *raw = data.data(); + + const uint8_t fc = raw[0]; + if (!(fc & FC_OBJECT)) + return false; + if (encode_uint16(raw[3], raw[2]) != PRODUCT_ID) { + ESP_LOGVV(TAG, "Not an MCCGQ02HL frame (product id %02X%02X).", raw[3], raw[2]); + return false; + } + + const uint8_t frame_count = raw[4]; + if (this->last_frame_count_.has_value() && *this->last_frame_count_ == frame_count) { + ESP_LOGVV(TAG, "Duplicate frame %u.", frame_count); + return false; + } + + // The bindkey is required, so plaintext frames are never trusted + if (!(fc & FC_ENCRYPTED)) { + ESP_LOGVV(TAG, "Ignoring unencrypted frame %u.", frame_count); + return false; + } + + const size_t offset = 5 + ((fc & FC_MAC_INCLUDED) ? 6 : 0) + ((fc & FC_CAPABILITY) ? 1 : 0); + if (size <= offset + ENCRYPTED_TRAILER || size - offset - ENCRYPTED_TRAILER > MAX_PAYLOAD_SIZE) { + ESP_LOGW(TAG, "Unsupported encrypted frame layout (fc=0x%02X, %u bytes).", fc, (unsigned) size); + return false; + } + uint8_t plaintext[MAX_PAYLOAD_SIZE]; + if (!this->decrypt_(raw, size, offset, plaintext)) { + ESP_LOGW(TAG, "Decryption failed (%u-byte frame) -- check the bindkey.", (unsigned) size); + return false; + } + // Only an authenticated frame may advance the duplicate filter. There is no replay protection: the + // counter is not persisted and restarts on a battery change, so a strictly increasing check could lock + // the sensor out until the next reboot. + this->last_frame_count_ = frame_count; + return this->parse_objects_(plaintext, size - offset - ENCRYPTED_TRAILER, reading); +} + +bool XiaomiMCCGQ02HL::parse_objects_(const uint8_t *payload, size_t length, Reading &reading) { + bool found = false; + // Each object: id (uint16 LE), length (uint8), value. + while (length >= 3) { + const uint16_t id = encode_uint16(payload[1], payload[0]); + const uint8_t len = payload[2]; + if (len < 1 || length < 3u + len) + break; + const uint8_t *value = payload + 3; + + if ((id == OBJ_DOOR || id == OBJ_DOOR_ALT) && len == 1) { + // 0 = open, 1 = closed, 2 = left open past timeout, 3 = device reset + if (value[0] <= 2) { + reading.open = value[0] != 1; + found = true; + } + } else if ((id == OBJ_LIGHT || id == OBJ_LIGHT_ALT) && len == 1) { + reading.light = value[0] != 0; + found = true; + } else if ((id == OBJ_BATTERY || id == OBJ_BATTERY_ALT) && len == 1) { + reading.battery_level = value[0]; + found = true; + } else { + ESP_LOGVV(TAG, "Ignoring object 0x%04X (%u bytes).", id, len); + } + + payload += 3 + len; + length -= 3 + len; + } + return found; +} + +} // namespace esphome::xiaomi_mccgq02hl diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h new file mode 100644 index 00000000000..8897752b449 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/optional.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/ble_device_base/ble_device.h" + +#include + +namespace esphome::xiaomi_mccgq02hl { + +// Xiaomi Mijia Door/Window Sensor 2 (MCCGQ02HL), MiBeacon product id 0x098b. +// +// Self-contained on purpose: xiaomi_ble's parse_xiaomi_header() does not know +// this product id and parse_xiaomi_value() does not know the door object, so +// this class does its own header and object parsing and decryption. +class XiaomiMCCGQ02HL final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public ble_device_base::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { this->address_ = address; } + void set_bindkey(const char *bindkey); + void set_light(binary_sensor::BinarySensor *light) { this->light_ = light; } + void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; } + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + protected: + struct Reading { + optional open; + optional light; + optional battery_level; + }; + + bool decrypt_(const uint8_t *frame, size_t size, size_t offset, uint8_t *plaintext) const; + bool parse_service_data_(const std::vector &data, Reading &reading); + bool parse_objects_(const uint8_t *payload, size_t length, Reading &reading); + + uint64_t address_{0}; + uint8_t bindkey_[16]{}; + // Per instance, unlike xiaomi_ble's function-static counter which is shared + // by every Xiaomi device on the node. + optional last_frame_count_; + binary_sensor::BinarySensor *light_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; +}; + +} // namespace esphome::xiaomi_mccgq02hl diff --git a/tests/components/xiaomi_mccgq02hl/binary_sensor/xiaomi_mccgq02hl_test.cpp b/tests/components/xiaomi_mccgq02hl/binary_sensor/xiaomi_mccgq02hl_test.cpp new file mode 100644 index 00000000000..c9a4a67a343 --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/binary_sensor/xiaomi_mccgq02hl_test.cpp @@ -0,0 +1,74 @@ +#include "../common.h" + +#include + +namespace esphome::xiaomi_mccgq02hl::testing { + +TEST(XiaomiMCCGQ02HL, DecodesOpenAndClosed) { + Harness h; + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN))); + EXPECT_TRUE(h.sensor.state); + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, CLOSED))); + EXPECT_FALSE(h.sensor.state); +} + +TEST(XiaomiMCCGQ02HL, LeftOpenIsReportedAsOpen) { + Harness h; + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, LEFT_OPEN))); + EXPECT_TRUE(h.sensor.state); +} + +TEST(XiaomiMCCGQ02HL, DeviceResetIsIgnored) { + Harness h; + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN))); + EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, DEVICE_RESET))); + EXPECT_TRUE(h.sensor.state); +} + +TEST(XiaomiMCCGQ02HL, DecodesLight) { + Harness h; + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, BRIGHT))); + EXPECT_TRUE(h.light.state); + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, DARK))); + EXPECT_FALSE(h.light.state); + EXPECT_EQ(h.door_changes, 0); // light frames carry no door state +} + +TEST(XiaomiMCCGQ02HL, DecodesBatteryLevel) { + Harness h; + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, BATTERY))); + EXPECT_FLOAT_EQ(h.battery_level.state, 87.0f); +} + +TEST(XiaomiMCCGQ02HL, RepeatedFrameIsDropped) { + Harness h; + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN))); + EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, OPEN))); +} + +TEST(XiaomiMCCGQ02HL, RejectsBadMic) { + Harness h; + EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, BAD_MIC))); + EXPECT_EQ(h.door_changes, 0); +} + +TEST(XiaomiMCCGQ02HL, BadMicDoesNotBlockTheNextFrame) { + Harness h; + EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, BAD_MIC))); + ASSERT_TRUE(h.sensor.parse_device(advert(ADDRESS, OPEN))); + EXPECT_TRUE(h.sensor.state); +} + +TEST(XiaomiMCCGQ02HL, RejectsPlaintextFrame) { + Harness h; + EXPECT_FALSE(h.sensor.parse_device(advert(ADDRESS, PLAINTEXT_OPEN))); + EXPECT_EQ(h.door_changes, 0); +} + +TEST(XiaomiMCCGQ02HL, IgnoresOtherAddress) { + Harness h; + EXPECT_FALSE(h.sensor.parse_device(advert(OTHER_ADDRESS, OPEN))); + EXPECT_EQ(h.door_changes, 0); +} + +} // namespace esphome::xiaomi_mccgq02hl::testing diff --git a/tests/components/xiaomi_mccgq02hl/common-ln.yaml b/tests/components/xiaomi_mccgq02hl/common-ln.yaml new file mode 100644 index 00000000000..9d7a7278a9e --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/common-ln.yaml @@ -0,0 +1,9 @@ +binary_sensor: + - platform: xiaomi_mccgq02hl + name: Mi Door Sensor 2 + mac_address: E4:AA:EC:01:02:03 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + light: + name: Mi Door Sensor 2 Light + battery_level: + name: Mi Door Sensor 2 Battery Level diff --git a/tests/components/xiaomi_mccgq02hl/common.h b/tests/components/xiaomi_mccgq02hl/common.h new file mode 100644 index 00000000000..3f6dcd39bba --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/common.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h" + +#include +#include + +namespace esphome::xiaomi_mccgq02hl::testing { + +using Frame = std::array; +using PlainFrame = std::array; + +static constexpr uint64_t ADDRESS = 0xE4AAEC010203ULL; +static constexpr uint64_t OTHER_ADDRESS = 0xE4AAEC010204ULL; +static constexpr const char *BINDKEY = "48403ebe2d385db8d0c187f81e62cb64"; + +// MiBeacon v5 frames (MAC included, AES-CCM encrypted) built with Python's cryptography AESCCM using ADDRESS and +// BINDKEY. Each frame has a different frame count. +// Door open (object 0x0019) +static constexpr Frame OPEN = {0x58, 0x59, 0x8b, 0x09, 0x01, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0xe4, 0xd9, 0x75, 0x9f, 0x01, 0x00, 0x00, 0x7e, 0x61, 0x36, 0x49}; +// Door closed +static constexpr Frame CLOSED = {0x58, 0x59, 0x8b, 0x09, 0x02, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0x52, 0x9c, 0x58, 0x6e, 0x01, 0x00, 0x00, 0x69, 0x45, 0x8f, 0xec}; +// Door left open past the device timeout (object 0x1019) +static constexpr Frame LEFT_OPEN = {0x58, 0x59, 0x8b, 0x09, 0x03, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0x38, 0xe3, 0xbd, 0x82, 0x01, 0x00, 0x00, 0x74, 0x97, 0x75, 0x9d}; +// Device reset, must be ignored +static constexpr Frame DEVICE_RESET = {0x58, 0x59, 0x8b, 0x09, 0x04, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0x4b, 0x18, 0xdb, 0x8e, 0x01, 0x00, 0x00, 0x48, 0xa6, 0x52, 0x99}; +// Light above threshold (object 0x1018) +static constexpr Frame BRIGHT = {0x58, 0x59, 0x8b, 0x09, 0x05, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0xe3, 0x96, 0x1c, 0xa4, 0x01, 0x00, 0x00, 0x4b, 0x43, 0xca, 0x14}; +// Light below threshold (object 0x0018) +static constexpr Frame DARK = {0x58, 0x59, 0x8b, 0x09, 0x06, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0x78, 0x07, 0x43, 0xb5, 0x01, 0x00, 0x00, 0x69, 0x62, 0x88, 0x89}; +// Battery 87 % (object 0x100A) +static constexpr Frame BATTERY = {0x58, 0x59, 0x8b, 0x09, 0x07, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0xdc, 0x88, 0xf2, 0xd0, 0x01, 0x00, 0x00, 0x39, 0xe8, 0x96, 0x08}; +// OPEN with a corrupted MIC (same frame count as OPEN) +static constexpr Frame BAD_MIC = {0x58, 0x59, 0x8b, 0x09, 0x01, 0x03, 0x02, 0x01, 0xec, 0xaa, 0xe4, + 0xe4, 0xd9, 0x75, 0x9f, 0x01, 0x00, 0x00, 0x7e, 0x61, 0x36, 0xb6}; + +// OPEN sent without encryption (MAC included), which must be rejected +static constexpr PlainFrame PLAINTEXT_OPEN = {0x50, 0x59, 0x8b, 0x09, 0x09, 0x03, 0x02, 0x01, + 0xec, 0xaa, 0xe4, 0x19, 0x00, 0x01, 0x00}; + +template ble_device_base::ESPBTDevice advert(uint64_t address, const std::array &frame) { + // Service data AD structure for UUID 0xFE95 + std::vector adv = {static_cast(frame.size() + 3), 0x16, 0x95, 0xFE}; + adv.insert(adv.end(), frame.begin(), frame.end()); + uint8_t mac[6]; + for (size_t i = 0; i < 6; i++) + mac[i] = static_cast(address >> (i * 8)); + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, -60, 0, adv.data(), static_cast(adv.size())); + return device; +} + +struct Harness { + Harness() { + this->sensor.set_address(ADDRESS); + this->sensor.set_bindkey(BINDKEY); + this->sensor.set_light(&this->light); + this->sensor.set_battery_level(&this->battery_level); + // The door sensor starts as "closed", so count changes to see whether anything was published + this->sensor.add_full_state_callback([this](optional, optional) { this->door_changes++; }); + } + + XiaomiMCCGQ02HL sensor; + binary_sensor::BinarySensor light; + sensor::Sensor battery_level; + int door_changes{0}; +}; + +} // namespace esphome::xiaomi_mccgq02hl::testing diff --git a/tests/components/xiaomi_mccgq02hl/common.yaml b/tests/components/xiaomi_mccgq02hl/common.yaml new file mode 100644 index 00000000000..9cf9be8e2d4 --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/common.yaml @@ -0,0 +1,14 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mccgq02hl + ble_hub_id: ble_tracker_hub + name: Mi Door Sensor 2 + mac_address: E4:AA:EC:01:02:03 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + light: + name: Mi Door Sensor 2 Light + battery_level: + name: Mi Door Sensor 2 Battery Level diff --git a/tests/components/xiaomi_mccgq02hl/test.esp32-idf.yaml b/tests/components/xiaomi_mccgq02hl/test.esp32-idf.yaml new file mode 100644 index 00000000000..8e5a674bc2f --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_mccgq02hl: !include common.yaml diff --git a/tests/components/xiaomi_mccgq02hl/test.ln882x-ard.yaml b/tests/components/xiaomi_mccgq02hl/test.ln882x-ard.yaml new file mode 100644 index 00000000000..8bbe6097dbb --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mccgq02hl: !include common-ln.yaml diff --git a/tests/components/xiaomi_mccgq02hl/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mccgq02hl/validate.bk72xx-ard.yaml new file mode 100644 index 00000000000..921263442cc --- /dev/null +++ b/tests/components/xiaomi_mccgq02hl/validate.bk72xx-ard.yaml @@ -0,0 +1,23 @@ +# 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_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mccgq02hl + ble_hub_id: ble_tracker_hub + name: Mi Door Sensor 2 + mac_address: E4:AA:EC:01:02:03 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + light: + name: Mi Door Sensor 2 Light + battery_level: + name: Mi Door Sensor 2 Battery Level + + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mccgq02hl + name: Mi Door Sensor 2 Implicit + mac_address: E4:AA:EC:01:02:04 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" From 678975b6b233ef29689c78ca767c177bc53467d5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:25:28 -0400 Subject: [PATCH 011/435] [usb_host] `iManufacturer` and `iProduct` descriptor filters (#19095) Co-authored-by: J. Nick Koston Co-authored-by: Keith Burzinski --- esphome/components/usb_host/__init__.py | 59 +++++++++- esphome/components/usb_host/usb_host.h | 4 + .../components/usb_host/usb_host_client.cpp | 68 ++++++++--- esphome/components/usb_uart/usb_uart.cpp | 12 +- esphome/helpers.py | 15 +++ .../usb_host/test.esp32-s3-idf.yaml | 5 + tests/components/usb_uart/common.yaml | 19 +++ tests/unit_tests/components/test_usb_host.py | 111 ++++++++++++++++++ tests/unit_tests/test_helpers.py | 15 +++ 9 files changed, 288 insertions(+), 20 deletions(-) create mode 100644 tests/unit_tests/components/test_usb_host.py diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 4abcc3a4499..94532958ed1 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,4 +1,7 @@ +from itertools import combinations + import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( VARIANT_ESP32H4, VARIANT_ESP32P4, @@ -15,6 +18,8 @@ from esphome.const import CONF_DEVICES, CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component +import esphome.final_validate as fv +from esphome.helpers import cpp_u16string_escape from esphome.types import ConfigType AUTO_LOAD = ["bytebuffer"] @@ -26,11 +31,21 @@ USBClient = usb_host_ns.class_("USBClient", Component) DOMAIN = "usb_host" CONF_VID = "vid" CONF_PID = "pid" +CONF_PRODUCT = "product" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" CONF_MAX_PACKET_SIZE = "max_packet_size" +# VID/PID set to 0 or `None` product/manufacturer are wildcards +_FILTER_WILDCARDS = { + CONF_VID: 0, + CONF_PID: 0, + CONF_MANUFACTURER: None, + CONF_PRODUCT: None, +} + + def usb_device_schema( cls=USBClient, vid: int | None = None, pid: int | None = None ) -> cv.Schema: @@ -47,7 +62,40 @@ def usb_device_schema( schema = schema.extend({cv.Optional(CONF_PID, default=pid): cv.hex_uint16_t}) else: schema = schema.extend({cv.Required(CONF_PID): cv.hex_uint16_t}) - return schema + + return schema.extend( + { + cv.Optional(CONF_MANUFACTURER): cv.string_strict, + cv.Optional(CONF_PRODUCT): cv.string_strict, + } + ) + + +def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: + # Two entries overlap when no field they both constrain tells them apart + for first, second in combinations(configs, 2): + for key, wildcard in _FILTER_WILDCARDS.items(): + a = first.get(key) + b = second.get(key) + if wildcard not in (a, b) and a != b: + break + else: + raise cv.Invalid( + f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}" + ) + return configs + + +def _final_validate(config: ConfigType) -> ConfigType: + # Every USB client on the bus, whichever component configured it: any two could + # otherwise open the same device + clients = list(config.get(CONF_DEVICES) or ()) + clients.extend(fv.full_config.get().get("usb_uart") or ()) + validate_usb_clients(clients) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate def _set_max_packet_size(config: dict) -> dict: @@ -91,6 +139,15 @@ CONFIG_SCHEMA = cv.All( async def register_usb_client(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], config[CONF_VID], config[CONF_PID]) await cg.register_component(var, config) + # UTF-16 literals, the encoding the descriptors use, so the device compares code units + if (manufacturer := config.get(CONF_MANUFACTURER)) is not None: + cg.add( + var.set_manufacturer_filter( + cg.RawExpression(cpp_u16string_escape(manufacturer)) + ) + ) + if (product := config.get(CONF_PRODUCT)) is not None: + cg.add(var.set_product_filter(cg.RawExpression(cpp_u16string_escape(product)))) return var diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 42869fb2a6d..6f92fc85386 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -143,6 +143,8 @@ class USBClient : public Component { trq_bitmask_t get_trq_in_use() const { return trq_in_use_; } bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data = {}); + void set_manufacturer_filter(const char16_t *manufacturer) { this->manufacturer_filter_ = manufacturer; } + void set_product_filter(const char16_t *product) { this->product_filter_ = product; } // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks @@ -181,6 +183,8 @@ class USBClient : public Component { // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available // Supports multiple concurrent consumers and producers (both threads can allocate/deallocate) std::atomic trq_in_use_; + const char16_t *manufacturer_filter_{nullptr}; + const char16_t *product_filter_{nullptr}; uint16_t vid_{}; uint16_t pid_{}; }; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 7bc2b0a16b7..3ad5fc59dcf 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -11,6 +11,8 @@ #include #include #include +#include + namespace esphome::usb_host { #pragma GCC diagnostic ignored "-Wparentheses" @@ -147,21 +149,40 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc // Character count = (bLength - 2) / 2, max 126 chars + null terminator. static constexpr size_t DESC_STRING_BUF_SIZE = 128; -static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { - if (desc == nullptr || desc->bLength < 2) - return "(unspecified)"; - int char_count = (desc->bLength - 2) / 2; +// Folds UTF-16 to Latin-1 for logging, dropping anything that does not fit +template +static const char *utf16_to_latin1(const T *data, size_t count, std::span buffer) { char *p = buffer.data(); char *end = p + buffer.size() - 1; - for (int i = 0; i != char_count && p < end; i++) { - auto c = desc->wData[i]; - if (c < 0x100) - *p++ = static_cast(c); + for (size_t i = 0; i != count && p < end; i++) { + if (data[i] < 0x100) + *p++ = static_cast(data[i]); } *p = '\0'; return buffer.data(); } +static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { + if (desc == nullptr || desc->bLength < 2) + return "(unspecified)"; + return utf16_to_latin1(desc->wData, (desc->bLength - 2) / 2, buffer); +} + +static const char *filter_string(const char16_t *filter, std::span buffer) { + return utf16_to_latin1(filter, std::char_traits::length(filter), buffer); +} + +// Both sides are UTF-16: the descriptor by specification, the filter because code +// generation emits it as a u"" literal +static bool descriptor_string_equals(const usb_str_desc_t *desc, const char16_t *expected) { + const int char_count = (desc == nullptr || desc->bLength < 2) ? 0 : (desc->bLength - 2) / 2; + for (int i = 0; i != char_count; i++) { + if (expected[i] == u'\0' || desc->wData[i] != expected[i]) + return false; + } + return expected[char_count] == u'\0'; +} + // CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { auto *client = static_cast(ptr); @@ -302,12 +323,10 @@ void USBClient::handle_open_state_() { return; } ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct); - if (desc->idVendor != this->vid_ || desc->idProduct != this->pid_) { - if (this->vid_ != 0 || this->pid_ != 0) { - ESP_LOGD(TAG, "Not our device, closing"); - this->disconnect(); - return; - } + if ((this->vid_ != 0 && desc->idVendor != this->vid_) || (this->pid_ != 0 && desc->idProduct != this->pid_)) { + ESP_LOGD(TAG, "Not our device, closing"); + this->disconnect(); + return; } usb_device_info_t dev_info; err = usb_host_device_info(this->device_handle_, &dev_info); @@ -316,9 +335,21 @@ void USBClient::handle_open_state_() { this->disconnect(); return; } - this->state_ = USB_CLIENT_CONNECTED; char buf_manuf[DESC_STRING_BUF_SIZE]; char buf_product[DESC_STRING_BUF_SIZE]; + const bool manufacturer_matches = + this->manufacturer_filter_ == nullptr || + descriptor_string_equals(dev_info.str_desc_manufacturer, this->manufacturer_filter_); + const bool product_matches = + this->product_filter_ == nullptr || descriptor_string_equals(dev_info.str_desc_product, this->product_filter_); + if (!manufacturer_matches || !product_matches) { + ESP_LOGD(TAG, "Device does not match filter, closing. Manuf: %s; Prod: %s", + get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf), + get_descriptor_string(dev_info.str_desc_product, buf_product)); + this->disconnect(); + return; + } + this->state_ = USB_CLIENT_CONNECTED; char buf_serial[DESC_STRING_BUF_SIZE]; ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s", get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf), @@ -557,6 +588,13 @@ void USBClient::dump_config() { " Vendor id %04X\n" " Product id %04X", this->vid_, this->pid_); + char buf[DESC_STRING_BUF_SIZE]; + if (this->manufacturer_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Manufacturer %s", filter_string(this->manufacturer_filter_, buf)); + } + if (this->product_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Product %s", filter_string(this->product_filter_, buf)); + } } // THREAD CONTEXT: Called from both USB task and main loop threads // - USB task: Immediately after transfer callback completes diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3113f695f60..37a5587fca1 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -463,10 +463,12 @@ void USBUartTypeCdcAcm::on_connected() { void USBUartTypeCdcAcm::on_disconnected() { for (auto *channel : this->channels_) { - if (channel->cdc_dev_.in_ep != nullptr) { - usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); - usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); - } + // Not set up for this device: it was rejected before on_connected() ran, or it has + // fewer ports than there are channels. Nothing was claimed for it. + if (channel->cdc_dev_.in_ep == nullptr) + continue; + usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); + usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); if (channel->cdc_dev_.out_ep != nullptr) { usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); @@ -494,6 +496,8 @@ void USBUartTypeCdcAcm::on_disconnected() { } } channel->initialised_.store(false); + // The descriptors these point into are freed with the device + channel->cdc_dev_ = {}; } USBClient::on_disconnected(); } diff --git a/esphome/helpers.py b/esphome/helpers.py index 3ccf0fe65ae..5ec46be67e4 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -201,6 +201,21 @@ def cpp_string_escape(string, encoding="utf-8"): return f'"{result}"' +def cpp_u16string_escape(string: str) -> str: + """Escape a string as a C++ u"..." literal, which the compiler encodes as UTF-16.""" + result = "" + for character in string: + code = ord(character) + if code >= 127: + # Surrogate escapes are ill-formed in C++; the compiler splits astral code points + result += f"\\U{code:08X}" + elif code < 32 or character in ("\\", '"'): + result += f"\\{code:03o}" + else: + result += character + return f'u"{result}"' + + def run_system_command(*args): import subprocess diff --git a/tests/components/usb_host/test.esp32-s3-idf.yaml b/tests/components/usb_host/test.esp32-s3-idf.yaml index 5360d1f6ff0..a71140feca3 100644 --- a/tests/components/usb_host/test.esp32-s3-idf.yaml +++ b/tests/components/usb_host/test.esp32-s3-idf.yaml @@ -4,3 +4,8 @@ usb_host: - id: device_1 vid: 0x1234 pid: 0x1234 + - id: device_2 + vid: 0x1234 + pid: 0xABCD + manufacturer: Example Corp + product: Example Widget diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 2e41fad1a1b..02500035cfd 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -36,8 +36,10 @@ usb_uart: debug: true dummy_receiver: true debug_prefix: "[ESP_JTAG] " + # A CP2105, so it does not share 10C4:EA60 with uart_1 above - id: uart_5 type: cp210x + pid: 0xEA70 channels: - id: channel_5_1 baud_rate: 9600 @@ -66,3 +68,20 @@ usb_uart: stop_bits: 2 data_bits: 7 parity: even + # A ZBT-2 and a ZWA-2 both enumerate as 303A:4001, so only iProduct separates them + - id: uart_9 + type: cdc_acm + vid: 0x303A + pid: 0x4001 + manufacturer: Nabu Casa + product: ZBT-2 + channels: + - id: channel_9_1 + - id: uart_10 + type: cdc_acm + vid: 0x303A + pid: 0x4001 + manufacturer: Nabu Casa + product: ZWA-2 + channels: + - id: channel_10_1 diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py new file mode 100644 index 00000000000..1a1df68f4de --- /dev/null +++ b/tests/unit_tests/components/test_usb_host.py @@ -0,0 +1,111 @@ +"""Tests for usb_host device matching validation.""" + +import pytest + +from esphome.components.usb_host import validate_usb_clients +import esphome.config_validation as cv +from esphome.types import ConfigType + + +@pytest.mark.parametrize( + ("first", "second"), + [ + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4002}, + id="different_pid", + ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x1A86, "pid": 0x4001}, + id="different_vid", + ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZWA-2"}, + id="different_product", + ), + pytest.param( + { + "id": "a", + "vid": 0x303A, + "pid": 0x4001, + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + }, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4001, + "manufacturer": "Espressif", + "product": "ZBT-2", + }, + id="different_manufacturer", + ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0, "product": "ZBT-2"}, + {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZWA-2"}, + id="wildcard_pid_separated_by_product", + ), + pytest.param( + {"id": "a", "vid": 0, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4002}, + id="wildcard_vid_different_pid", + ), + ], +) +def test_disjoint_clients_are_accepted(first: ConfigType, second: ConfigType) -> None: + configs = [first, second] + assert validate_usb_clients(configs) is configs + + +@pytest.mark.parametrize( + ("first", "second"), + [ + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4001}, + id="exact_duplicate", + ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + id="unfiltered_shadows_filtered", + ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + id="identical_filters", + ), + pytest.param( + {"id": "a", "vid": 0, "pid": 0}, + {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + id="zero_ids_match_every_device", + ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0}, + {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + id="wildcard_pid_shadows_filtered", + ), + pytest.param( + {"id": "a", "vid": 0, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0}, + id="wildcards_on_different_fields", + ), + ], +) +def test_overlapping_clients_are_rejected( + first: ConfigType, second: ConfigType +) -> None: + with pytest.raises(cv.Invalid, match="overlap"): + validate_usb_clients([first, second]) + + +def test_every_pair_is_compared_not_just_neighbours() -> None: + configs = [ + {"id": "a", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + {"id": "b", "vid": 0x303A, "pid": 0x4002}, + {"id": "c", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, + ] + with pytest.raises(cv.Invalid, match="a, c"): + validate_usb_clients(configs) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 5bdecf2fd3a..8dae1e87b20 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -94,6 +94,21 @@ def test_cpp_string_escape(string, expected): assert actual == expected +@pytest.mark.parametrize( + "string, expected", + ( + ("foo", 'u"foo"'), + ("foo\nbar", 'u"foo\\012bar"'), + ("foo\\bar", 'u"foo\\134bar"'), + ('foo "bar"', 'u"foo \\042bar\\042"'), + ("caf\u00e9", 'u"caf\\U000000E9"'), + ("foo 🐍", 'u"foo \\U0001F40D"'), + ), +) +def test_cpp_u16string_escape(string: str, expected: str) -> None: + assert helpers.cpp_u16string_escape(string) == expected + + @pytest.mark.parametrize( "value, expected", ( From 13a8efd694e51643f906948ac5d6d5e1d8458090 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 08:25:44 -1000 Subject: [PATCH 012/435] [api] Reply when an infrared/RF raw timings transmit has completed (#19086) --- CODEOWNERS | 1 + esphome/components/api/api.proto | 26 +- esphome/components/api/api_connection.cpp | 46 ++- esphome/components/api/api_connection.h | 5 +- esphome/components/api/api_pb2.cpp | 25 +- esphome/components/api/api_pb2.h | 26 +- esphome/components/api/api_pb2_dump.cpp | 11 +- esphome/components/api/api_pb2_service.cpp | 2 +- esphome/components/api/api_pb2_service.h | 2 +- esphome/components/api/api_server.cpp | 3 +- esphome/components/api/api_server.h | 2 +- esphome/components/infrared/__init__.py | 14 +- esphome/components/infrared/infrared.cpp | 147 +--------- esphome/components/infrared/infrared.h | 124 ++------ esphome/components/ir_rf_base/__init__.py | 38 +++ esphome/components/ir_rf_base/ir_rf_base.cpp | 237 +++++++++++++++ esphome/components/ir_rf_base/ir_rf_base.h | 273 ++++++++++++++++++ esphome/components/ir_rf_proxy/infrared.py | 12 +- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 78 +---- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 19 +- .../components/ir_rf_proxy/radio_frequency.py | 12 +- .../components/radio_frequency/__init__.py | 16 +- .../radio_frequency/radio_frequency.cpp | 84 +----- .../radio_frequency/radio_frequency.h | 110 ++----- esphome/components/remote_base/__init__.py | 4 +- .../components/remote_base/dish_protocol.cpp | 6 +- .../components/remote_base/remote_base.cpp | 6 +- esphome/components/remote_base/remote_base.h | 37 ++- .../remote_transmitter/remote_transmitter.cpp | 2 +- .../remote_transmitter/remote_transmitter.h | 10 + .../remote_transmitter_libretiny_isr.cpp | 11 +- .../remote_transmitter_rmt.cpp | 54 +++- .../remote_transmitter_rtl87xx.cpp | 4 +- esphome/components/web_server/web_server.cpp | 6 +- esphome/core/defines.h | 1 + .../components/api/bench_proto_proxy.cpp | 4 +- .../esphome/components/infrared/infrared.h | 4 +- .../radio_frequency/radio_frequency.h | 4 +- .../remote_transmitter_mock/__init__.py | 25 ++ .../remote_transmitter_mock.cpp | 40 +++ .../remote_transmitter_mock.h | 31 ++ .../fixtures/ir_rf_transmit_complete.yaml | 30 ++ .../test_ir_rf_transmit_complete.py | 131 +++++++++ 43 files changed, 1130 insertions(+), 593 deletions(-) create mode 100644 esphome/components/ir_rf_base/__init__.py create mode 100644 esphome/components/ir_rf_base/ir_rf_base.cpp create mode 100644 esphome/components/ir_rf_base/ir_rf_base.h create mode 100644 tests/integration/fixtures/external_components/remote_transmitter_mock/__init__.py create mode 100644 tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.cpp create mode 100644 tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.h create mode 100644 tests/integration/fixtures/ir_rf_transmit_complete.yaml create mode 100644 tests/integration/test_ir_rf_transmit_complete.py diff --git a/CODEOWNERS b/CODEOWNERS index 944c5f2809d..b5138c06120 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ esphome/components/inkplate/* @jesserockz @JosipKuci esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core +esphome/components/ir_rf_base/* @bdraco @kbx81 esphome/components/ir_rf_proxy/* @kbx81 esphome/components/it8951/* @koosoli @limengdu @Passific esphome/components/jsn_sr04t/* @Mafus1 diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index ed7b8e515cf..5f4246b5742 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2692,7 +2692,7 @@ message ListEntitiesInfraredResponse { message InfraredRFTransmitRawTimingsRequest { option (id) = 136; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; + option (ifdef) = "USE_IR_RF"; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance @@ -2706,7 +2706,7 @@ message InfraredRFTransmitRawTimingsRequest { message InfraredRFReceiveEvent { option (id) = 137; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; + option (ifdef) = "USE_IR_RF"; option (no_delay) = true; option (speed_optimized) = true; @@ -2715,6 +2715,28 @@ message InfraredRFReceiveEvent { repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } +// Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the +// transmitter reports that the transmission (all repeats) has finished, or immediately with +// success=false if it was refused before reaching the transmitter (unknown key, no transmitter, +// no or invalid timings) or the transmitter could not send it (not set up, hardware error). +// success=false also answers a request superseded by a newer request on the same entity, from +// any client, and a transmit that reported nothing within 30 s past its expected air time, so a +// client should not retry on it blindly. The device serializes transmits per transmitter and +// several entities may share one, so a client should keep at most one transmit outstanding per +// device, not per entity. A reply for an unknown key or a refused request is sent once and can be lost +// when the device's send buffer is full, so a client should also stop waiting on its own after the +// expected air time plus a margin. Lets clients pace requests instead of estimating durations (since API 1.18) +message InfraredRFTransmitCompleteResponse { + option (id) = 153; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_IR_RF"; + option (no_delay) = true; + + uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; + fixed32 key = 2 [(force) = true]; // Key of the transmitter entity from the request + bool success = 3; // false if the request was refused, not sent, superseded, or never reported +} + // ==================== RADIO FREQUENCY ==================== // Lists available radio frequency entity instances diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0a2fe13e6a2..06e5e7c3425 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -198,6 +198,17 @@ APIConnection::~APIConnection() { proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE); } } +#endif + // entities holding a transmit reply for this client must not answer into a freed connection +#ifdef USE_INFRARED + for (auto *infrared : App.get_infrareds()) { + infrared->on_api_connection_closed(this); + } +#endif +#ifdef USE_RADIO_FREQUENCY + for (auto *radio_frequency : App.get_radio_frequencies()) { + radio_frequency->on_api_connection_closed(this); + } #endif } @@ -1517,8 +1528,10 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { + // Clients on API 1.18+ are told when the frame has left the transmitter; the entity owns that reply + const bool want_reply = this->client_supports_api_version(1, 18); // Dispatch by key: infrared entities are checked first, then radio frequency entities. // The key is unique across all entity instances on a device, so at most one lookup will succeed. #ifdef USE_INFRARED @@ -1528,6 +1541,7 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF call.set_carrier_frequency(msg.carrier_frequency); call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); call.set_repeat_count(msg.repeat_count); + call.set_api_connection(want_reply ? this : nullptr); call.perform(); return; } @@ -1540,13 +1554,38 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF call.set_modulation(static_cast(msg.modulation)); call.set_repeat_count(msg.repeat_count); call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); + call.set_api_connection(want_reply ? this : nullptr); call.perform(); + return; } #endif + ESP_LOGW(TAG, "IR/RF transmit for unknown key %" PRIu32, msg.key); + if (want_reply) { + // nothing will ever report for an unknown key, so answer as not started right away +#ifdef USE_DEVICES + const uint32_t device_id = msg.device_id; +#else + const uint32_t device_id = 0; +#endif + if (!this->send_infrared_rf_transmit_complete(device_id, msg.key, false)) { + API_LOG_MSG_DROPPED(TAG, "IR/RF reply"); + } + } +} + +bool APIConnection::send_infrared_rf_transmit_complete([[maybe_unused]] uint32_t device_id, uint32_t key, + bool success) { + InfraredRFTransmitCompleteResponse resp{}; +#ifdef USE_DEVICES + resp.device_id = device_id; +#endif + resp.key = key; + resp.success = success; + return this->send_message(resp); } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { if (!this->send_message(msg)) { // V: fires per decoded frame with no subscription gate, so a warning @@ -1554,6 +1593,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); } } + #endif #ifdef USE_SERIAL_PROXY @@ -1816,7 +1856,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 17; + resp.api_version_minor = 18; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index dacca573695..db364d1830c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -233,9 +233,12 @@ class APIConnection final : public APIServerConnectionBase { void on_water_heater_command_request(const WaterHeaterCommandRequest &msg); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg); void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); + // Reply to an InfraredRFTransmitRawTimingsRequest (API 1.18+); false when the TCP buffer is + // full, the entity that owns the reply retries it then + [[nodiscard]] bool send_infrared_rf_transmit_complete(uint32_t device_id, uint32_t key, bool success); #endif #ifdef USE_SERIAL_PROXY diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index fe8992337d6..2f0d8e7cc73 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3934,7 +3934,7 @@ uint32_t ListEntitiesInfraredResponse::calc_size_msg(const void *self) { return size; } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void InfraredRFTransmitRawTimingsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { auto &msg = *static_cast(self); @@ -3994,6 +3994,27 @@ InfraredRFReceiveEvent::calc_size_msg(const void *self) { } return size; } +uint8_t *InfraredRFTransmitCompleteResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_DEVICES + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); +#endif + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.success); + return pos; +} +uint32_t InfraredRFTransmitCompleteResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; +#ifdef USE_DEVICES + size += ProtoSize::calc_uint32(1, msg.device_id); +#endif + size += 5; + size += ProtoSize::calc_bool(1, msg.success); + return size; +} #endif #ifdef USE_RADIO_FREQUENCY uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, @@ -4329,7 +4350,7 @@ static_assert(!std::is_polymorphic_v, "decodable messages static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); #endif #ifdef USE_SERIAL_PROXY diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d94cd709d8e..0429bc78dd0 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3681,7 +3681,7 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { protected: }; #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { public: static constexpr uint16_t MESSAGE_TYPE = 136; @@ -3733,6 +3733,30 @@ class InfraredRFReceiveEvent final : public ProtoMessage { protected: }; +class InfraredRFTransmitCompleteResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 153; + static constexpr uint8_t ESTIMATED_SIZE = 11; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_complete_response"); } +#endif +#ifdef USE_DEVICES + uint32_t device_id{0}; +#endif + uint32_t key{0}; + bool success{false}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; #endif #ifdef USE_RADIO_FREQUENCY class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b8845076183..6c7a1ac9ac9 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2720,7 +2720,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest")); #ifdef USE_DEVICES @@ -2749,6 +2749,15 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { } return out.c_str(); } +const char *InfraredRFTransmitCompleteResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitCompleteResponse")); +#ifdef USE_DEVICES + dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); +#endif + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("success"), this->success); + return out.c_str(); +} #endif #ifdef USE_RADIO_FREQUENCY const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 172062be636..b8ac99f14f4 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -628,7 +628,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: { InfraredRFTransmitRawTimingsRequest msg; msg.decode(msg_data, msg_size); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index a4dfd6a3663..b77fc82da7a 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -213,7 +213,7 @@ class APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 600ff2a2fe1..904bb33e535 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -504,7 +504,7 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { InfraredRFReceiveEvent resp{}; @@ -517,6 +517,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); } + #endif #ifdef USE_ALARM_CONTROL_PANEL diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fc1ca7df133..df05ca3a434 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -203,7 +203,7 @@ class APIServer final : public Component #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index d04c82ea965..47ca5bf07fb 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -9,20 +9,21 @@ Once the API is considered stable, this warning will be removed. """ import esphome.codegen as cg +from esphome.components import ir_rf_base import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import queue_entity_register, setup_entity +from esphome.core import coroutine_with_priority +from esphome.core.entity_helpers import setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["remote_base"] +AUTO_LOAD = ["ir_rf_base"] IS_PLATFORM_COMPONENT = True infrared_ns = cg.esphome_ns.namespace("infrared") -Infrared = infrared_ns.class_("Infrared", cg.EntityBase, cg.Component) +Infrared = infrared_ns.class_("Infrared", ir_rf_base.IrRfEntity) InfraredCall = infrared_ns.class_("InfraredCall") InfraredTraits = infrared_ns.class_("InfraredTraits") @@ -52,11 +53,8 @@ async def setup_infrared_core_(var: cg.MockObj, config: ConfigType) -> None: async def register_infrared(var: cg.MockObj, config: ConfigType) -> None: """Register an infrared device with the core.""" - cg.add_define("USE_IR_RF") - await cg.register_component(var, config) - queue_entity_register("infrared", config) + await ir_rf_base.register_ir_rf_entity(var, config, "infrared") await setup_infrared_core_(var, config) - CORE.register_platform_component("infrared", var) async def new_infrared(config: ConfigType, *args: SafeExpType) -> cg.MockObj: diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 83039a5a9bf..67a40fa3490 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -1,161 +1,18 @@ #include "infrared.h" -#include - #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - namespace esphome::infrared { static const char *const TAG = "infrared"; -// ========== InfraredCall ========== - -InfraredCall &InfraredCall::set_carrier_frequency(uint32_t frequency) { - this->carrier_frequency_ = frequency; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings(const std::vector &timings) { - this->raw_timings_ = &timings; - this->packed_data_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_base64url(const std::string &base64url) { - this->base64url_ptr_ = &base64url; - this->raw_timings_ = nullptr; - this->packed_data_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { - this->packed_data_ = data; - this->packed_length_ = length; - this->packed_count_ = count; - this->raw_timings_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_repeat_count(uint32_t count) { - this->repeat_count_ = count; - return *this; -} - -void InfraredCall::perform() { - if (this->parent_ != nullptr) { - this->parent_->control(*this); - } -} - -// ========== Infrared ========== - -void Infrared::setup() { - // Set up traits based on configuration - this->traits_.set_supports_transmitter(this->has_transmitter()); - this->traits_.set_supports_receiver(this->has_receiver()); -} - void Infrared::dump_config() { ESP_LOGCONFIG(TAG, "Infrared '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); -} - -void Infrared::control(const InfraredCall &call) { - if (this->transmitter_ == nullptr) { - ESP_LOGW(TAG, "No transmitter configured"); - return; - } - - if (!call.has_raw_timings()) { - ESP_LOGE(TAG, "No raw timings provided"); - return; - } - - // Create transmit data object - auto transmit_call = this->transmitter_->transmit(); - auto *transmit_data = transmit_call.get_data(); - - // Set carrier frequency - auto freq = call.get_carrier_frequency(); - if (freq.has_value()) { - transmit_data->set_carrier_frequency(*freq); - } - - // Set timings based on format - if (call.is_packed()) { - // Zero-copy from packed protobuf data - transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), - call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), - call.get_repeat_count()); - } else if (call.is_base64url()) { - // Decode base64url (URL-safe) into transmit buffer - if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { - ESP_LOGE(TAG, "Invalid base64url data"); - return; - } - // Sanity check: validate timing values are within reasonable bounds - constexpr int32_t max_timing_us = 500000; // 500ms absolute max - for (int32_t timing : transmit_data->get_data()) { - int32_t abs_timing = timing < 0 ? -timing : timing; - if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; - } - } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), - call.get_repeat_count()); - } else { - // From vector (lambdas/automations) - transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), - call.get_repeat_count()); - } - - // Set repeat count - if (call.get_repeat_count() > 0) { - transmit_call.set_send_times(call.get_repeat_count()); - } - - // Perform transmission - transmit_call.perform(); -} - -uint32_t Infrared::get_capability_flags() const { - uint32_t flags = 0; - - // Add transmit/receive capability based on traits - if (this->traits_.get_supports_transmitter()) - flags |= InfraredCapability::CAPABILITY_TRANSMITTER; - if (this->traits_.get_supports_receiver()) - flags |= InfraredCapability::CAPABILITY_RECEIVER; - - return flags; -} - -bool Infrared::on_receive(remote_base::RemoteReceiveData data) { - // Forward received IR data to API server -#if defined(USE_API) && defined(USE_IR_RF) - if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); - } -#endif - return false; // Don't consume the event, allow other listeners to process it + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); } } // namespace esphome::infrared diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index afbde57be2f..d81e3e2e38d 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -4,131 +4,48 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. -#include "esphome/core/component.h" -#include "esphome/core/entity_base.h" -#include "esphome/components/remote_base/remote_base.h" - -#include +#include "esphome/components/ir_rf_base/ir_rf_base.h" namespace esphome::infrared { -/// Capability flags for individual infrared instances -enum InfraredCapability : uint32_t { - CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals - CAPABILITY_RECEIVER = 1 << 1, // Can receive signals -}; +using ir_rf_base::CAPABILITY_RECEIVER; +using ir_rf_base::CAPABILITY_TRANSMITTER; -/// Forward declarations class Infrared; /// InfraredCall - Builder pattern for transmitting infrared signals -class InfraredCall { +class InfraredCall : public ir_rf_base::IrRfCall { public: - explicit InfraredCall(Infrared *parent) : parent_(parent) {} + explicit InfraredCall(Infrared *parent) : IrRfCall(parent) {} /// Set the carrier frequency in Hz - InfraredCall &set_carrier_frequency(uint32_t frequency); - - // ===== Raw Timings Methods ===== - // All set_raw_timings_* methods store pointers/references to external data. - // The referenced data must remain valid until perform() completes. - // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous - // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! - - /// Set the raw timings from a vector (positive = mark, negative = space) - /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). - /// @note Usage: Primarily for lambdas/automations where the vector is in scope. - InfraredCall &set_raw_timings(const std::vector &timings); - - /// Set the raw timings from base64url-encoded little-endian int32 data - /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). - /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). - /// @note Decoding happens at perform() time, directly into the transmit buffer. - InfraredCall &set_raw_timings_base64url(const std::string &base64url); - - /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) - /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). - /// @note Usage: For API component where data comes directly from the protobuf message. - InfraredCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); - - /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) - InfraredCall &set_repeat_count(uint32_t count); - - /// Perform the transmission - void perform(); - + InfraredCall &set_carrier_frequency(uint32_t frequency) { + this->carrier_frequency_ = frequency; + return *this; + } /// Get the carrier frequency const optional &get_carrier_frequency() const { return this->carrier_frequency_; } - /// Get the raw timings (only valid if set via set_raw_timings) - const std::vector &get_raw_timings() const { return *this->raw_timings_; } - /// Check if raw timings have been set (any format) - bool has_raw_timings() const { - return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; - } - /// Check if using packed data format - bool is_packed() const { return this->packed_data_ != nullptr; } - /// Check if using base64url data format - bool is_base64url() const { return this->base64url_ptr_ != nullptr; } - /// Get the base64url data string - const std::string &get_base64url_data() const { return *this->base64url_ptr_; } - /// Get packed data (only valid if set via set_raw_timings_packed) - const uint8_t *get_packed_data() const { return this->packed_data_; } - uint16_t get_packed_length() const { return this->packed_length_; } - uint16_t get_packed_count() const { return this->packed_count_; } - /// Get the repeat count - uint32_t get_repeat_count() const { return this->repeat_count_; } protected: - uint32_t repeat_count_{1}; - Infrared *parent_; optional carrier_frequency_; - // Pointer to vector-based timings (caller-owned, must outlive perform()) - const std::vector *raw_timings_{nullptr}; - // Pointer to base64url-encoded string (caller-owned, must outlive perform()) - const std::string *base64url_ptr_{nullptr}; - // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) - const uint8_t *packed_data_{nullptr}; - uint16_t packed_length_{0}; - uint16_t packed_count_{0}; }; /// InfraredTraits - Describes the capabilities of an infrared implementation class InfraredTraits { public: - bool get_supports_transmitter() const { return this->supports_transmitter_; } - void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } - - bool get_supports_receiver() const { return this->supports_receiver_; } - void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } - uint32_t get_receiver_frequency_hz() const { return this->receiver_frequency_hz_; } void set_receiver_frequency_hz(uint32_t freq) { this->receiver_frequency_hz_ = freq; } protected: - bool supports_transmitter_{false}; - bool supports_receiver_{false}; uint32_t receiver_frequency_hz_{0}; // Demodulation frequency of the IR receiver in Hz (0 = unspecified) }; /// Infrared - Base class for infrared remote control implementations -class Infrared : public Component, public EntityBase, public remote_base::RemoteReceiverListener { +class Infrared : public ir_rf_base::IrRfEntity { public: Infrared() = default; - void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - - /// Set the remote receiver component; the listener registration happens from codegen, see - /// remote_base.attach_receiver - void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } - /// Set the remote transmitter component - void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - - /// Check if this infrared has a transmitter configured - bool has_transmitter() const { return this->transmitter_ != nullptr; } - /// Check if this infrared has a receiver configured - bool has_receiver() const { return this->receiver_ != nullptr; } /// Get the traits for this infrared implementation InfraredTraits &get_traits() { return this->traits_; } @@ -137,21 +54,16 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote /// Create a call object for transmitting InfraredCall make_call() { return InfraredCall(this); } - /// Get capability flags for this infrared instance - uint32_t get_capability_flags() const; - - /// Called when IR data is received (from RemoteReceiverListener) - bool on_receive(remote_base::RemoteReceiveData data) override; - protected: - friend class InfraredCall; + friend class ir_rf_base::IrRfCall; - /// Perform the actual transmission (called by InfraredCall) - virtual void control(const InfraredCall &call); - - // Underlying hardware components - remote_base::RemoteReceiverBase *receiver_{nullptr}; - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + void on_call_(const InfraredCall &) {} + /// Perform the actual transmission (called by InfraredCall); false only when no frame was handed + /// to the transmitter, in which case no completion follows + /// Without a remote_base transmitter, call api_transmit_done_() once the frame is out + virtual bool control(const InfraredCall &call) { + return this->transmit_raw_(call, call.get_carrier_frequency().value_or(0)); + } // Traits describing capabilities InfraredTraits traits_; diff --git a/esphome/components/ir_rf_base/__init__.py b/esphome/components/ir_rf_base/__init__.py new file mode 100644 index 00000000000..55536c131b1 --- /dev/null +++ b/esphome/components/ir_rf_base/__init__.py @@ -0,0 +1,38 @@ +"""Shared base for the infrared and radio_frequency entity components.""" + +import esphome.codegen as cg +from esphome.components import remote_base +from esphome.const import CONF_API +from esphome.core import CORE +from esphome.core.entity_helpers import queue_entity_register +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@bdraco"] +AUTO_LOAD = ["remote_base"] + +ir_rf_base_ns = cg.esphome_ns.namespace("ir_rf_base") +IrRfEntity = ir_rf_base_ns.class_("IrRfEntity", cg.EntityBase, cg.Component) + + +async def attach_transmitter(var: cg.MockObj, config: ConfigType, key: str) -> None: + """Link the configured transmitter to an entity. + + With the API configured this also compiles in the transmit completion + tracking that answers API transmit requests; the transmitter platform has + to report completion through notify_complete_(), as remote_transmitter + does. Without this call anywhere in the build a request is answered as + soon as the frame is handed over. + """ + await remote_base.register_transmittable(var, config, key) + if CONF_API in CORE.config: + cg.add_define("USE_IR_RF_TRANSMIT_COMPLETE") + + +async def register_ir_rf_entity( + var: cg.MockObj, config: ConfigType, domain: str +) -> None: + """Register an infrared or radio_frequency entity; USE_IR_RF covers both kinds.""" + cg.add_define("USE_IR_RF") + await cg.register_component(var, config) + queue_entity_register(domain, config) + CORE.register_platform_component(domain, var) diff --git a/esphome/components/ir_rf_base/ir_rf_base.cpp b/esphome/components/ir_rf_base/ir_rf_base.cpp new file mode 100644 index 00000000000..9aa845f269e --- /dev/null +++ b/esphome/components/ir_rf_base/ir_rf_base.cpp @@ -0,0 +1,237 @@ +#include "ir_rf_base.h" + +#include +#include + +#include "esphome/core/log.h" + +#ifdef USE_API +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/application.h" +#endif + +namespace esphome::ir_rf_base { + +static const char *const TAG = "ir_rf"; + +#if defined(USE_API) && defined(USE_IR_RF) +// A missing completion is answered as failed this long after the frame should have left the +// wire, and an owed reply the client never reads is dropped this long after the first refusal +static constexpr uint32_t API_REPLY_TIMEOUT_MS = 30000; +#endif +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +// Longest air time added to the deadline, in 16 ms ticks (8 min); with the 30 s above the +// deadline stays within the signed 16 bit tick window loop() compares against +static constexpr uint16_t API_REPLY_MAX_AIR_TICKS = 30000; +#endif + +#if defined(USE_API) && defined(USE_IR_RF) +static uint16_t api_reply_ticks_from_now() { + return static_cast((App.get_loop_component_start_time() >> 4) + (API_REPLY_TIMEOUT_MS >> 4)); +} +#endif + +void IrRfEntity::setup() { + // merged, not assigned: a platform may have set a flag for its own hardware before setup() + if (this->has_transmitter()) + this->supports_transmitter_ = true; + if (this->has_receiver()) + this->supports_receiver_ = true; +} + +bool IrRfEntity::transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz) { + if (this->transmitter_ == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return false; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return false; + } + + auto transmit_call = this->transmitter_->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency_hz); + + if (call.is_packed()) { + // Zero-copy from packed protobuf data + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + // Decode base64url (URL-safe) into transmit buffer + if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { + ESP_LOGE(TAG, "Invalid base64url data"); + return false; + } + constexpr int32_t max_timing_us = 500000; // 500ms absolute max + for (int32_t timing : transmit_data->get_data()) { + int32_t abs_timing = timing < 0 ? -timing : timing; + if (abs_timing > max_timing_us) { + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); + return false; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + // From vector (lambdas/automations) + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + // one answer for every backend: a frame that decoded to nothing is refused here + if (transmit_data->get_data().empty()) { + ESP_LOGE(TAG, "No raw timings provided"); + return false; + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + // only the API frame expect_api_reply_() armed claims the seq and extends its deadline + if (call.wants_api_reply() && this->api_reply_ == ApiReply::API_REPLY_WAITING) { + this->inflight_seq_ = transmit_call.get_seq(); + // a long frame must not be answered as failed while still on the wire: the 30 s safety net + // starts after this frame's own air time (capped so the tick comparison cannot wrap) + uint64_t frame_us = 0; + for (const int64_t timing : transmit_data->get_data()) + frame_us += timing < 0 ? -timing : timing; + const uint64_t air_ticks = (frame_us * std::max(call.get_repeat_count(), 1) / 1000) >> 4; + this->api_reply_deadline_ += static_cast(std::min(air_ticks, API_REPLY_MAX_AIR_TICKS)); + } +#endif + transmit_call.perform(); + return true; +} + +bool IrRfEntity::on_receive(remote_base::RemoteReceiveData data) { +#if defined(USE_API) && defined(USE_IR_RF) + if (api::global_api_server != nullptr) { +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + } +#endif + return false; // Don't consume the event, allow other listeners to process it +} + +#if defined(USE_API) && defined(USE_IR_RF) + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +void IrRfEntity::on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) { + // only the frame this entity submitted; YAML automations and other entities share the transmitter + if (transmitter == this->transmitter_ && seq == this->inflight_seq_ && + this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(sent); +} +#endif + +bool IrRfEntity::expect_api_reply_(api::APIConnection *conn) { + // only an unpaced client gets here: its earlier request is answered as not started, and a reply + // the buffer still owes gets one last try; if that fails too the slot stays with it and the + // new request is refused rather than answered to nobody + if (this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(false); + if (this->api_reply_ != ApiReply::API_REPLY_NONE && !this->send_api_reply_()) { + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("refused, reply still owed")); + this->refuse_api_call_(conn); + return false; + } + this->api_reply_connection_ = conn; + this->api_reply_deadline_ = api_reply_ticks_from_now(); + this->api_reply_ = ApiReply::API_REPLY_WAITING; + return true; +} + +void IrRfEntity::refuse_api_call_(api::APIConnection *conn) { + uint32_t device_id = 0; +#ifdef USE_DEVICES + device_id = this->get_device_id(); +#endif + // no slot to retry from; a drop here is covered by the client's own timeout + if (!conn->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(), false)) { + API_LOG_MSG_DROPPED(TAG, "IR/RF reply"); + } +} + +void IrRfEntity::finish_api_reply_(bool success) { + this->api_reply_ = success ? ApiReply::API_REPLY_OWED_OK : ApiReply::API_REPLY_OWED_FAILED; + if (!this->send_api_reply_()) { + // the retry gets its own window + this->api_reply_deadline_ = api_reply_ticks_from_now(); + } +} + +bool IrRfEntity::send_api_reply_() { + uint32_t device_id = 0; +#ifdef USE_DEVICES + device_id = this->get_device_id(); +#endif + // Refused by a full TCP buffer: the reply stays owed and loop() retries it, since a lost + // reply would stall the client's pacing for good (same shape as bluetooth_proxy) + if (!this->api_reply_connection_->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(), + this->api_reply_ == ApiReply::API_REPLY_OWED_OK)) + return false; + this->clear_api_reply_(); + return true; +} + +// Only runs while an API reply is pending: retries an owed one until it goes out or the client is +// gone, and expires a transmit that never reported (a platform overriding control() without wiring +// its transmitter's completion) +void IrRfEntity::loop() { + if (this->api_reply_ == ApiReply::API_REPLY_NONE) { + this->disable_loop(); + return; + } + const auto remaining = static_cast(this->api_reply_deadline_ - (App.get_loop_component_start_time() >> 4)); + if (this->api_reply_ != ApiReply::API_REPLY_WAITING) { + if (this->send_api_reply_() || remaining > 0) + return; + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("reply dropped, client not reading")); + this->clear_api_reply_(); + return; + } + if (remaining > 0) + return; + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("never reported completion")); + this->finish_api_reply_(false); +} + +void IrRfEntity::on_api_connection_closed(api::APIConnection *conn) { + if (this->api_reply_connection_ == conn) { + this->clear_api_reply_(); + } +} +#endif + +} // namespace esphome::ir_rf_base + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +namespace esphome::remote_base { + +void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) { +#ifdef USE_INFRARED + for (auto *entity : App.get_infrareds()) { + entity->on_transmit_complete(transmitter, seq, sent); + } +#endif +#ifdef USE_RADIO_FREQUENCY + for (auto *entity : App.get_radio_frequencies()) { + entity->on_transmit_complete(transmitter, seq, sent); + } +#endif +} + +} // namespace esphome::remote_base +#endif diff --git a/esphome/components/ir_rf_base/ir_rf_base.h b/esphome/components/ir_rf_base/ir_rf_base.h new file mode 100644 index 00000000000..c6dc8776968 --- /dev/null +++ b/esphome/components/ir_rf_base/ir_rf_base.h @@ -0,0 +1,273 @@ +#pragma once + +// WARNING: This component is EXPERIMENTAL. The API may change at any time +// without following the normal breaking changes policy. Use at your own risk. +// Once the API is considered stable, this warning will be removed. + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" +#include "esphome/components/remote_base/remote_base.h" + +#include +#include + +#if defined(USE_API) && defined(USE_IR_RF) +namespace esphome::api { +class APIConnection; +} // namespace esphome::api +#endif + +namespace esphome::ir_rf_base { + +/// Capability flags reported by infrared and radio frequency entities +enum IrRfCapability : uint32_t { + CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals + CAPABILITY_RECEIVER = 1 << 1, // Can receive signals +}; + +/// Raw timings of a transmit call, in one of three caller-owned forms +class IrRfCallData { + public: + /// Get the raw timings (only valid if set via set_raw_timings) + const std::vector &get_raw_timings() const { return *this->raw_timings_; } + /// Check if raw timings have been set (any format) + bool has_raw_timings() const { + return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; + } + /// Check if using packed data format + bool is_packed() const { return this->packed_data_ != nullptr; } + /// Check if using base64url data format + bool is_base64url() const { return this->base64url_ptr_ != nullptr; } + /// Get the base64url data string + const std::string &get_base64url_data() const { return *this->base64url_ptr_; } + /// Get packed data (only valid if set via set_raw_timings_packed) + const uint8_t *get_packed_data() const { return this->packed_data_; } + uint16_t get_packed_length() const { return this->packed_length_; } + uint16_t get_packed_count() const { return this->packed_count_; } + /// Get the repeat count + uint32_t get_repeat_count() const { return this->repeat_count_; } + +#if defined(USE_API) && defined(USE_IR_RF) + /// True for a frame an API client is waiting on + bool wants_api_reply() const { return this->api_connection_ != nullptr; } +#endif + + protected: + uint32_t repeat_count_{1}; +#if defined(USE_API) && defined(USE_IR_RF) + api::APIConnection *api_connection_{nullptr}; +#endif + // Pointer to vector-based timings (caller-owned, must outlive perform()) + const std::vector *raw_timings_{nullptr}; + // Pointer to base64url-encoded string (caller-owned, must outlive perform()) + const std::string *base64url_ptr_{nullptr}; + // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) + const uint8_t *packed_data_{nullptr}; + uint16_t packed_length_{0}; + uint16_t packed_count_{0}; +}; + +template class IrRfCall; + +/// Everything an infrared or radio frequency entity does that does not depend on the medium: +/// the remote_base transport, forwarding received frames to the API, and answering the API +/// once a transmit it started has left the transmitter. +class IrRfEntity : public Component, public EntityBase, public remote_base::RemoteReceiverListener { + public: + /// Reports the configured transports, listens on the receiver and hooks the transmitter's + /// completion; a platform with its own setup() calls it first + void setup() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver + void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } + /// Set the remote transmitter component + void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } + bool has_transmitter() const { return this->transmitter_ != nullptr; } + bool has_receiver() const { return this->receiver_ != nullptr; } + + /// What the entity can do; platforms with their own hardware set these from their own + /// setup(), remote_base ones get them from IrRfEntity::setup() + bool get_supports_transmitter() const { return this->supports_transmitter_; } + void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } + bool get_supports_receiver() const { return this->supports_receiver_; } + void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } + /// Capability flags as reported to the API and web server + uint32_t get_capability_flags() const { + uint32_t flags = 0; + if (this->supports_transmitter_) + flags |= CAPABILITY_TRANSMITTER; + if (this->supports_receiver_) + flags |= CAPABILITY_RECEIVER; + return flags; + } + + /// Forwards a received frame to the API; never consumes it, so other listeners still run + bool on_receive(remote_base::RemoteReceiveData data) override; +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + /// Called for every finished frame on every transmitter; answers the API request when the + /// frame is the one this entity submitted for it, with sent as the outcome + void on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent); +#endif + +#if defined(USE_API) && defined(USE_IR_RF) + void loop() override; + /// The API server calls this when a client disconnects, so no reply goes to a stale pointer + void on_api_connection_closed(api::APIConnection *conn); +#endif + + protected: + template friend class IrRfCall; + + /// Hands the call's timings to the transmitter; the default transmit path of both entity types + bool transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz); +#if defined(USE_API) && defined(USE_IR_RF) + // One reply slot: a pacing client has at most one transmit outstanding, and a second request + // from an unpaced client displaces the first. Retried and expired from loop(), which only + // runs while a reply is pending; polling the expiry there costs nothing while idle, where a + // scheduler timeout would allocate per frame. + enum class ApiReply : uint8_t { + API_REPLY_NONE, + API_REPLY_WAITING, // frame handed to the transmitter, completion not reported yet + API_REPLY_OWED_OK, // reply refused by a full TCP buffer; loop() retries it + API_REPLY_OWED_FAILED // same, for a transmit that did not start + }; + /// Claims the reply slot for conn; false when it still owes a reply that cannot be sent + bool expect_api_reply_(api::APIConnection *conn); + void refuse_api_call_(api::APIConnection *conn); + /// After control(): a false start is answered now, and the loop only runs once something is + /// pending, since a blocking transmitter has already answered inside control() + void settle_api_reply_(bool started) { +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + if (!started) + this->finish_api_reply_(false); +#else + // no transmitter in this build reports completion, so the hand-over is the answer + this->finish_api_reply_(started); +#endif + if (this->api_reply_ != ApiReply::API_REPLY_NONE) + this->enable_loop(); + } + void finish_api_reply_(bool success); + /// Answers the API request for a control() override that transmits without a remote_base transmitter + void api_transmit_done_(bool sent) { + if (this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(sent); + } + bool send_api_reply_(); + void clear_api_reply_() { + this->api_reply_ = ApiReply::API_REPLY_NONE; + this->api_reply_connection_ = nullptr; + this->disable_loop(); + } + api::APIConnection *api_reply_connection_{nullptr}; +#endif + + remote_base::RemoteReceiverBase *receiver_{nullptr}; + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; +#if defined(USE_API) && defined(USE_IR_RF) + // 16 ms ticks: 30 s for completion (plus capped air time) or for delivering an owed reply + uint16_t api_reply_deadline_{0}; +#endif +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + uint16_t inflight_seq_{0}; // seq of the API frame this entity submitted last +#endif + // short members last, so the derived traits start on the next word without a gap + bool supports_transmitter_{false}; + bool supports_receiver_{false}; +#if defined(USE_API) && defined(USE_IR_RF) + ApiReply api_reply_{ApiReply::API_REPLY_NONE}; +#endif +}; + +/// Builder for a transmit; Call is the concrete call type and Entity its entity, so the fluent +/// setters return the concrete type and control() sees the medium specific fields +template class IrRfCall : public IrRfCallData { + // only the concrete call may construct the CRTP base + friend Call; + explicit IrRfCall(Entity *parent) : parent_(parent) {} + + public: + // ===== Raw Timings Methods ===== + // All set_raw_timings_* methods store pointers/references to external data. + // The referenced data must remain valid until perform() completes. + // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous + // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! + + /// Set the raw timings from a vector (positive = mark, negative = space) + /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). + /// @note Usage: Primarily for lambdas/automations where the vector is in scope. + Call &set_raw_timings(const std::vector &timings) { + this->raw_timings_ = &timings; + this->packed_data_ = nullptr; + this->base64url_ptr_ = nullptr; + return this->self_(); + } + + /// Set the raw timings from base64url-encoded little-endian int32 data + /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). + /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). + /// @note Decoding happens at perform() time, directly into the transmit buffer. + Call &set_raw_timings_base64url(const std::string &base64url) { + this->base64url_ptr_ = &base64url; + this->raw_timings_ = nullptr; + this->packed_data_ = nullptr; + return this->self_(); + } + + /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) + /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). + /// @note Usage: For API component where data comes directly from the protobuf message. + Call &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { + this->packed_data_ = data; + this->packed_length_ = length; + this->packed_count_ = count; + this->raw_timings_ = nullptr; + this->base64url_ptr_ = nullptr; + return this->self_(); + } + + /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) + Call &set_repeat_count(uint32_t count) { + this->repeat_count_ = count; + return this->self_(); + } + +#if defined(USE_API) && defined(USE_IR_RF) + /// Reply to this API client once the frame has left the transmitter (API 1.18+) + Call &set_api_connection(api::APIConnection *conn) { + this->api_connection_ = conn; + return this->self_(); + } +#endif + + /// Perform the transmission; returns true if a frame was handed to the transmitter + bool perform() { + // make_call() always sets the parent + Entity *parent = this->parent_; + if (parent == nullptr) + return false; + parent->on_call_(this->self_()); +#if defined(USE_API) && defined(USE_IR_RF) + // Before control(): blocking transmitters report completion from inside it, and the + // non-blocking RMT path flushes the previous frame's completion there + if (this->api_connection_ != nullptr && !parent->expect_api_reply_(this->api_connection_)) + return false; +#endif + const bool started = parent->control(this->self_()); +#if defined(USE_API) && defined(USE_IR_RF) + if (this->api_connection_ != nullptr) + parent->settle_api_reply_(started); +#endif + return started; + } + + protected: + Call &self_() { return static_cast(*this); } + + Entity *parent_; +}; + +} // namespace esphome::ir_rf_base diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 288bd916738..548ba2b1326 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,12 +3,7 @@ from typing import Any import esphome.codegen as cg -from esphome.components import ( - infrared, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import infrared, ir_rf_base, remote_base, remote_receiver from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -30,7 +25,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -82,8 +77,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link transmitter if specified if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) # Link receiver if specified if CONF_REMOTE_RECEIVER_ID in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index ceb4c9a67c3..88b15606080 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -8,70 +8,17 @@ namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; -// ========== Shared transmit helper ========== -// Static template: all instantiations occur in this translation unit. - -template -static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, - const CallT &call) { - if (transmitter == nullptr) { - ESP_LOGW(TAG, "No transmitter configured"); - return; - } - - if (!call.has_raw_timings()) { - ESP_LOGE(TAG, "No raw timings provided"); - return; - } - - auto transmit_call = transmitter->transmit(); - auto *transmit_data = transmit_call.get_data(); - transmit_data->set_carrier_frequency(carrier_frequency); - - if (call.is_packed()) { - transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), - call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), - call.get_repeat_count()); - } else if (call.is_base64url()) { - if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { - ESP_LOGE(TAG, "Invalid base64url data"); - return; - } - constexpr int32_t max_timing_us = 500000; - for (int32_t timing : transmit_data->get_data()) { - int32_t abs_timing = timing < 0 ? -timing : timing; - if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; - } - } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), - call.get_repeat_count()); - } else { - transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), - call.get_repeat_count()); - } - - if (call.get_repeat_count() > 0) { - transmit_call.set_send_times(call.get_repeat_count()); - } - - transmit_call.perform(); -} - // ========== IrRfProxy (Infrared platform) ========== -#ifdef USE_IR_RF +#ifdef USE_INFRARED void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); if (this->is_rf()) { ESP_LOGCONFIG(TAG, " Hardware Type: RF (%.3f MHz)", this->frequency_khz_ / 1e3f); @@ -80,21 +27,14 @@ void IrRfProxy::dump_config() { } } -void IrRfProxy::control(const infrared::InfraredCall &call) { - uint32_t carrier = call.get_carrier_frequency().value_or(0); - transmit_raw_timings(this->transmitter_, carrier, call); -} - -#endif // USE_IR_RF +#endif // USE_INFRARED // ========== RfProxy (Radio Frequency platform) ========== #ifdef USE_RADIO_FREQUENCY void RfProxy::setup() { - this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); - this->traits_.set_supports_receiver(this->receiver_ != nullptr); - + ir_rf_base::IrRfEntity::setup(); // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); } @@ -104,8 +44,8 @@ void RfProxy::dump_config() { "RF Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); const auto &traits = this->traits_; if (traits.get_frequency_min_hz() > 0) { @@ -118,11 +58,11 @@ void RfProxy::dump_config() { } } -void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { +bool RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { // RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning) // happens via the radio_frequency entity's on_control trigger and remote_transmitter's // on_transmit/on_complete triggers — wired up in user YAML. - transmit_raw_timings(this->transmitter_, 0, call); + return this->transmit_raw_(call, 0); } #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 1aa4394fe84..a45b0ac4722 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -6,7 +6,7 @@ #include "esphome/components/remote_base/remote_base.h" -#ifdef USE_IR_RF +#ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif @@ -16,7 +16,7 @@ namespace esphome::ir_rf_proxy { -#ifdef USE_IR_RF +#ifdef USE_INFRARED /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy final : public infrared::Infrared { public: @@ -35,12 +35,10 @@ class IrRfProxy final : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: - void control(const infrared::InfraredCall &call) override; - // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; -#endif // USE_IR_RF +#endif // USE_INFRARED #ifdef USE_RADIO_FREQUENCY /// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend. @@ -54,20 +52,11 @@ class RfProxy final : public radio_frequency::RadioFrequency { void setup() override; void dump_config() override; - /// Set the remote transmitter component - void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// Set the remote receiver component; the listener registration happens from codegen, see - /// remote_base.attach_receiver - void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } - /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } protected: - void control(const radio_frequency::RadioFrequencyCall &call) override; - - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; - remote_base::RemoteReceiverBase *receiver_{nullptr}; + bool control(const radio_frequency::RadioFrequencyCall &call) override; }; #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index 28b8fd5953a..1519fe9908a 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,12 +1,7 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import ( - radio_frequency, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import ir_rf_base, radio_frequency, remote_base, remote_receiver import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -27,7 +22,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -67,8 +62,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) if CONF_REMOTE_RECEIVER_ID in config: await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index f303a142975..ebba97d1a3e 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -10,22 +10,21 @@ Once the API is considered stable, this warning will be removed. from esphome import automation import esphome.codegen as cg +from esphome.components import ir_rf_base import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_CONTROL -from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import queue_entity_register, setup_entity +from esphome.core import coroutine_with_priority +from esphome.core.entity_helpers import setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["remote_base"] +AUTO_LOAD = ["ir_rf_base"] IS_PLATFORM_COMPONENT = True radio_frequency_ns = cg.esphome_ns.namespace("radio_frequency") -RadioFrequency = radio_frequency_ns.class_( - "RadioFrequency", cg.EntityBase, cg.Component -) +RadioFrequency = radio_frequency_ns.class_("RadioFrequency", ir_rf_base.IrRfEntity) RadioFrequencyCall = radio_frequency_ns.class_("RadioFrequencyCall") RadioFrequencyTraits = radio_frequency_ns.class_("RadioFrequencyTraits") RadioFrequencyModulation = radio_frequency_ns.enum("RadioFrequencyModulation") @@ -55,11 +54,8 @@ async def setup_radio_frequency_core_(var: cg.MockObj, config: ConfigType) -> No async def register_radio_frequency(var: cg.MockObj, config: ConfigType) -> None: """Register a radio frequency device with the core.""" - cg.add_define("USE_RADIO_FREQUENCY") - await cg.register_component(var, config) - queue_entity_register("radio_frequency", config) + await ir_rf_base.register_ir_rf_entity(var, config, "radio_frequency") await setup_radio_frequency_core_(var, config) - CORE.register_platform_component("radio_frequency", var) for conf in config.get(CONF_ON_CONTROL, []): await automation.build_callback_automation( diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 61e7feb9af4..a2386032177 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -4,73 +4,17 @@ #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - namespace esphome::radio_frequency { static const char *const TAG = "radio_frequency"; -// ========== RadioFrequencyCall ========== - -RadioFrequencyCall &RadioFrequencyCall::set_frequency(uint32_t frequency_hz) { - this->frequency_hz_ = frequency_hz; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_modulation(RadioFrequencyModulation modulation) { - this->modulation_ = modulation; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings(const std::vector &timings) { - this->raw_timings_ = &timings; - this->packed_data_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_base64url(const std::string &base64url) { - this->base64url_ptr_ = &base64url; - this->raw_timings_ = nullptr; - this->packed_data_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { - this->packed_data_ = data; - this->packed_length_ = length; - this->packed_count_ = count; - this->raw_timings_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) { - this->repeat_count_ = count; - return *this; -} - -void RadioFrequencyCall::perform() { - if (this->parent_ != nullptr) { - // Fire any on_control hooks (user-wired automations) before handing off to - // the platform-specific control() — gives users a chance to react to call - // parameters (e.g. retune an external RF front-end based on call.get_frequency()). - this->parent_->control_callback_.call(*this); - this->parent_->control(*this); - } -} - -// ========== RadioFrequency ========== - void RadioFrequency::dump_config() { ESP_LOGCONFIG(TAG, "Radio Frequency '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); if (this->traits_.get_frequency_min_hz() > 0) { if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) { ESP_LOGCONFIG(TAG, " Frequency: %" PRIu32 " Hz (fixed)", this->traits_.get_frequency_min_hz()); @@ -81,31 +25,9 @@ void RadioFrequency::dump_config() { } } -uint32_t RadioFrequency::get_capability_flags() const { - uint32_t flags = 0; - if (this->traits_.get_supports_transmitter()) - flags |= RadioFrequencyCapability::CAPABILITY_TRANSMITTER; - if (this->traits_.get_supports_receiver()) - flags |= RadioFrequencyCapability::CAPABILITY_RECEIVER; - return flags; -} - bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { - // Invoke local callbacks this->receive_callback_.call(data); - - // Forward received RF data to API server -#if defined(USE_API) && defined(USE_RADIO_FREQUENCY) - if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); - } -#endif - return false; // Don't consume the event, allow other listeners to process it + return IrRfEntity::on_receive(data); } } // namespace esphome::radio_frequency diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index 8782c255f00..d084a6c0164 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -4,20 +4,12 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. -#include "esphome/core/component.h" -#include "esphome/core/entity_base.h" -#include "esphome/core/helpers.h" -#include "esphome/components/remote_base/remote_base.h" - -#include +#include "esphome/components/ir_rf_base/ir_rf_base.h" namespace esphome::radio_frequency { -/// Capability flags for individual radio frequency instances -enum RadioFrequencyCapability : uint32_t { - CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals - CAPABILITY_RECEIVER = 1 << 1, // Can receive signals -}; +using ir_rf_base::CAPABILITY_RECEIVER; +using ir_rf_base::CAPABILITY_TRANSMITTER; /// Modulation types supported by radio frequency implementations enum RadioFrequencyModulation : uint8_t { @@ -25,95 +17,36 @@ enum RadioFrequencyModulation : uint8_t { // Future: RADIO_FREQUENCY_MODULATION_FSK, RADIO_FREQUENCY_MODULATION_GFSK, etc. }; -/// Forward declarations class RadioFrequency; /// RadioFrequencyCall - Builder pattern for transmitting radio frequency signals -class RadioFrequencyCall { +class RadioFrequencyCall : public ir_rf_base::IrRfCall { public: - explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + explicit RadioFrequencyCall(RadioFrequency *parent) : IrRfCall(parent) {} /// Set the carrier frequency in Hz (e.g. 433920000 for 433.92 MHz) - RadioFrequencyCall &set_frequency(uint32_t frequency_hz); - + RadioFrequencyCall &set_frequency(uint32_t frequency_hz) { + this->frequency_hz_ = frequency_hz; + return *this; + } /// Set the modulation type (defaults to OOK) - RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation); - - // ===== Raw Timings Methods ===== - // All set_raw_timings_* methods store pointers/references to external data. - // The referenced data must remain valid until perform() completes. - // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous - // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! - - /// Set the raw timings from a vector (positive = mark, negative = space) - /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). - /// @note Usage: Primarily for lambdas/automations where the vector is in scope. - RadioFrequencyCall &set_raw_timings(const std::vector &timings); - - /// Set the raw timings from base64url-encoded little-endian int32 data - /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). - /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). - /// @note Decoding happens at perform() time, directly into the transmit buffer. - RadioFrequencyCall &set_raw_timings_base64url(const std::string &base64url); - - /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) - /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). - /// @note Usage: For API component where data comes directly from the protobuf message. - RadioFrequencyCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); - - /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) - RadioFrequencyCall &set_repeat_count(uint32_t count); - - /// Perform the transmission - void perform(); - + RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation) { + this->modulation_ = modulation; + return *this; + } /// Get the frequency in Hz const optional &get_frequency() const { return this->frequency_hz_; } /// Get the modulation type RadioFrequencyModulation get_modulation() const { return this->modulation_; } - /// Get the raw timings (only valid if set via set_raw_timings) - const std::vector &get_raw_timings() const { return *this->raw_timings_; } - /// Check if raw timings have been set (any format) - bool has_raw_timings() const { - return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; - } - /// Check if using packed data format - bool is_packed() const { return this->packed_data_ != nullptr; } - /// Check if using base64url data format - bool is_base64url() const { return this->base64url_ptr_ != nullptr; } - /// Get the base64url data string - const std::string &get_base64url_data() const { return *this->base64url_ptr_; } - /// Get packed data (only valid if set via set_raw_timings_packed) - const uint8_t *get_packed_data() const { return this->packed_data_; } - uint16_t get_packed_length() const { return this->packed_length_; } - uint16_t get_packed_count() const { return this->packed_count_; } - /// Get the repeat count - uint32_t get_repeat_count() const { return this->repeat_count_; } protected: optional frequency_hz_{}; - uint32_t repeat_count_{1}; - RadioFrequency *parent_; - // Pointer to vector-based timings (caller-owned, must outlive perform()) - const std::vector *raw_timings_{nullptr}; - // Pointer to base64url-encoded string (caller-owned, must outlive perform()) - const std::string *base64url_ptr_{nullptr}; - // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) - const uint8_t *packed_data_{nullptr}; - uint16_t packed_length_{0}; - uint16_t packed_count_{0}; RadioFrequencyModulation modulation_{RADIO_FREQUENCY_MODULATION_OOK}; }; /// RadioFrequencyTraits - Describes the capabilities of a radio frequency implementation class RadioFrequencyTraits { public: - bool get_supports_transmitter() const { return this->supports_transmitter_; } - void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } - - bool get_supports_receiver() const { return this->supports_receiver_; } - void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } - /// Hardware-supported tunable frequency range in Hz. /// If min == max (and both non-zero): fixed-frequency hardware. /// If both 0: range unspecified. @@ -140,17 +73,14 @@ class RadioFrequencyTraits { uint32_t frequency_min_hz_{0}; // Minimum tunable frequency in Hz (0 = unspecified) uint32_t frequency_max_hz_{0}; // Maximum tunable frequency in Hz (0 = unspecified) uint32_t supported_modulations_{0}; // Bitmask of supported RadioFrequencyModulation values - bool supports_transmitter_{false}; - bool supports_receiver_{false}; }; /// RadioFrequency - Base class for radio frequency implementations -class RadioFrequency : public Component, public EntityBase, public remote_base::RemoteReceiverListener { +class RadioFrequency : public ir_rf_base::IrRfEntity { public: RadioFrequency() = default; void dump_config() override; - float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } /// Get the traits for this radio frequency implementation RadioFrequencyTraits &get_traits() { return this->traits_; } @@ -159,9 +89,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: /// Create a call object for transmitting RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } - /// Get capability flags for this radio frequency instance - uint32_t get_capability_flags() const; - /// Called when RF data is received (from RemoteReceiverListener) bool on_receive(remote_base::RemoteReceiveData data) override; @@ -180,11 +107,16 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: } protected: - friend class RadioFrequencyCall; + friend class ir_rf_base::IrRfCall; + /// Fires the on_control hooks before the platform-specific control() runs + void on_call_(const RadioFrequencyCall &call) { this->control_callback_.call(call); } /// Perform the actual transmission (called by RadioFrequencyCall::perform()) /// Platforms must override this to implement hardware-specific transmission. - virtual void control(const RadioFrequencyCall &call) = 0; + /// Returns false only when no frame was handed to the transmitter, in which case no + /// completion follows. + /// Without a remote_base transmitter, call api_transmit_done_() once the frame is out + virtual bool control(const RadioFrequencyCall &call) = 0; // Traits describing capabilities RadioFrequencyTraits traits_; diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 5d615f0edf8..e9708d0a9f9 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -136,8 +136,8 @@ async def attach_receiver( add_listener(receiver, var) -async def register_transmittable(var, config): - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) +async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): + transmitter_ = await cg.get_variable(config[key]) cg.add(var.set_transmitter(transmitter_)) diff --git a/esphome/components/remote_base/dish_protocol.cpp b/esphome/components/remote_base/dish_protocol.cpp index 9a6420afd56..5a89c91a50e 100644 --- a/esphome/components/remote_base/dish_protocol.cpp +++ b/esphome/components/remote_base/dish_protocol.cpp @@ -20,7 +20,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) { // Typically a DISH device needs to get a command a total of // at least 4 times to accept it. - for (uint i = 0; i < 4; i++) { + for (uint32_t i = 0; i < 4; i++) { // COMMAND (function, in MSB) for (uint8_t mask = 1UL << 5; mask; mask >>= 1) { if (data.command & mask) { @@ -39,7 +39,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) { } } // PADDING - for (uint j = 0; j < 6; j++) + for (uint32_t j = 0; j < 6; j++) dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); // FOOTER @@ -73,7 +73,7 @@ optional DishProtocol::decode(RemoteReceiveData src) { return {}; } } - for (uint j = 0; j < 6; j++) { + for (uint32_t j = 0; j < 6; j++) { if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { return {}; } diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 5d1bba16b61..a685b733b46 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,7 +182,7 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ -void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { +void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, [[maybe_unused]] uint16_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); char buffer[256]; @@ -213,6 +213,10 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { if (pos != 0) { ESP_LOGVV(TAG, "%s", buffer); } +#endif + this->flush_pending_completion(); +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + this->current_seq_ = seq; #endif this->send_internal(send_times, send_wait); } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 67e5799bcaf..36d2a44c509 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -143,6 +143,14 @@ class RemoteRMTChannel { #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +class RemoteTransmitterBase; + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +/// Defined by ir_rf_base: answers the API request waiting on the entity that submitted seq; +/// sent is false when the platform never put the frame on the wire. +/// One function for the whole build instead of a callback list on every transmitter. +void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent); +#endif // Protocol shapes, checked where a protocol is used so a missing method fails at the use site // instead of deep inside a template body. Receive-only protocols such as RCSwitchBase decode // without encoding. @@ -164,21 +172,24 @@ class RemoteTransmitterBase : public RemoteComponentBase { RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} class TransmitCall { public: - explicit TransmitCall(RemoteTransmitterBase *parent) : parent_(parent) {} + TransmitCall(RemoteTransmitterBase *parent, uint16_t seq) : parent_(parent), seq_(seq) {} RemoteTransmitData *get_data() { return &this->parent_->temp_; } void set_send_times(uint32_t send_times) { send_times_ = send_times; } void set_send_wait(uint32_t send_wait) { send_wait_ = send_wait; } - void perform() { this->parent_->send_(this->send_times_, this->send_wait_); } + /// Identifies this transmission in the completion hook + uint16_t get_seq() const { return this->seq_; } + void perform() { this->parent_->send_(this->send_times_, this->send_wait_, this->seq_); } protected: RemoteTransmitterBase *parent_; uint32_t send_times_{1}; uint32_t send_wait_{0}; + uint16_t seq_; }; TransmitCall transmit() { this->temp_.reset(); - return TransmitCall(this); + return TransmitCall(this, this->take_seq_()); } template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { @@ -190,9 +201,25 @@ class RemoteTransmitterBase : public RemoteComponentBase { } protected: - void send_(uint32_t send_times, uint32_t send_wait); + void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; - void send_single_() { this->send_(1, 0); } + /// Platforms that report completion later wait out the previous frame here, before send_() + /// assigns the next seq, so that completion carries the seq it belongs to + virtual void flush_pending_completion() {} + void send_single_() { this->send_(1, 0, this->take_seq_()); } +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + /// Reports the frame handed to the platform last, after its final repeat and before the + /// on_complete trigger; a seq only has to be unique within the 30 s reply window + void notify_complete_(bool sent) { ir_rf_transmit_complete(this, this->current_seq_, sent); } + uint16_t take_seq_() { return ++this->next_seq_; } + + uint16_t next_seq_{0}; + uint16_t current_seq_{0}; +#else + // seq tracking only exists for the API completion reply + void notify_complete_(bool /*sent*/) {} + static uint16_t take_seq_() { return 0; } +#endif /// Use same vector for all transmits, avoids many allocations RemoteTransmitData temp_; diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 5e82213a48d..29ef9bc2cc2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -114,7 +114,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } } - this->complete_trigger_.trigger(); + this->fire_complete_(send_times != 0); // same answer as the ISR backend for a frame sent zero times } } // namespace esphome::remote_transmitter diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 99e1ce9504d..fad273a1185 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -82,6 +82,16 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; + // the API reply is answered before the user's on_complete automation; sent is false on a + // bail-out that never put the frame on the wire, on_complete fires either way + void fire_complete_(bool sent = true) { + this->notify_complete_(sent); + this->complete_trigger_.trigger(); + } +#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \ + defined(REMOTE_TRANSMITTER_BK_PWM) + void flush_pending_completion() override; +#endif #if defined(USE_ESP8266) || \ (defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \ defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index fad91f593ff..26e9a1baa30 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -110,7 +110,7 @@ void RemoteTransmitterComponent::deliver_completion_() { if (!this->stall_aborted_) this->status_clear_warning(); this->complete_pending_ = false; - this->complete_trigger_.trigger(); + this->fire_complete_(!this->stall_aborted_); } // Waits until no chain is in flight, delivering any deferred completions; a completion @@ -152,20 +152,21 @@ void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_w this->start_isr_item_(0); } +void RemoteTransmitterComponent::flush_pending_completion() { this->wait_until_idle_(); } + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (!this->envelope_ready_()) { // both triggers still fire, so an on_complete-sequenced automation does not stall ESP_LOGW(TAG, "Cannot send: PWM not initialized"); this->transmit_trigger_.trigger(); - this->deliver_completion_(); + this->fire_complete_(false); return; } - this->wait_until_idle_(); if (send_times == 0) { // parity with the loop-based implementations: transmit nothing, but both triggers // still fire so an on_complete-sequenced automation does not stall this->transmit_trigger_.trigger(); - this->deliver_completion_(); + this->fire_complete_(false); return; } ESP_LOGD(TAG, "Sending remote code"); @@ -175,7 +176,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (this->isr_data_.empty()) { ESP_LOGW(TAG, "Empty data"); this->transmit_trigger_.trigger(); - this->deliver_completion_(); + this->fire_complete_(false); return; } // trigger first: the deadline computed in arm_chain_ must not be charged for user code diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 6d27be8d472..91bd339f210 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -200,25 +200,35 @@ void RemoteTransmitterComponent::wait_for_rmt_() { this->status_set_warning(); } - this->complete_trigger_.trigger(); + this->fire_complete_(error == ESP_OK); } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +void RemoteTransmitterComponent::flush_pending_completion() { + // a frame still on the wire is waited out, and its completion reported, before the next one + if (this->non_blocking_ && this->cancel_timeout("complete")) { + this->wait_for_rmt_(); + } +} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { uint64_t total_duration = 0; if (this->is_failed()) { + // both triggers still fire, so a paced API client or on_complete automation is not left waiting + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } - // if the timeout was cancelled, block until the tx is complete - if (this->non_blocking_ && this->cancel_timeout("complete")) { - this->wait_for_rmt_(); - } - if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -258,6 +268,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.size() <= offset) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } @@ -273,9 +285,11 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); this->status_set_warning(); - } else { - this->status_clear_warning(); + // nothing was queued, so there is no frame to wait for + this->fire_complete_(false); + return; } + this->status_clear_warning(); if (this->non_blocking_) { this->set_timeout("complete", total_duration / 1000, [this]() { this->wait_for_rmt_(); }); @@ -284,13 +298,23 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } #else +void RemoteTransmitterComponent::flush_pending_completion() {} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - if (this->is_failed()) + if (this->is_failed()) { + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; + } if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -328,9 +352,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.empty()) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } this->transmit_trigger_.trigger(); + bool sent = send_times != 0; // same answer as the ISR backend for a frame sent zero times for (uint32_t i = 0; i < send_times; i++) { rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); @@ -340,18 +367,21 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); this->status_set_warning(); - } else { - this->status_clear_warning(); + sent = false; } error = rmt_tx_wait_all_done(this->channel_, -1); if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_tx_wait_all_done failed: %s", esp_err_to_name(error)); this->status_set_warning(); + sent = false; } if (i + 1 < send_times) delayMicroseconds(send_wait); } - this->complete_trigger_.trigger(); + // a later repeat must not clear the warning a failed one raised + if (sent) + this->status_clear_warning(); + this->fire_complete_(sent); } #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index 6db9faac368..799f46401c6 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -146,6 +146,8 @@ void RemoteTransmitterComponent::await_target_time_() { void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (this->pwm_ == nullptr) { ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } ESP_LOGD(TAG, "Sending remote code"); @@ -194,7 +196,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } } - this->complete_trigger_.trigger(); + this->fire_complete_(); } #endif // USE_LIBRETINY_VARIANT_RTL8720C diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 90c6b167f6a..fde86502d4a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2080,10 +2080,8 @@ void WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config, set_json_icon_state_value(root, obj, "infrared", "", 0, start_config); - auto traits = obj->get_traits(); - - root[ESPHOME_F("supports_transmitter")] = traits.get_supports_transmitter(); - root[ESPHOME_F("supports_receiver")] = traits.get_supports_receiver(); + root[ESPHOME_F("supports_transmitter")] = obj->get_supports_transmitter(); + root[ESPHOME_F("supports_receiver")] = obj->get_supports_receiver(); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8b3319190d6..7c35ae2714d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -86,6 +86,7 @@ #define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF +#define USE_IR_RF_TRANSMIT_COMPLETE #define USE_JSON #define USE_JSON_ARENA #define USE_RADIO_FREQUENCY diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index e45966999bd..c8b56044de5 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -149,7 +149,7 @@ BENCHMARK(Decode_SerialProxyWriteRequest); // --- InfraredRFReceiveEvent encode (100 sint32 timings) + // InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) --- -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF // Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings). static std::vector make_ir_timings_100() { @@ -275,6 +275,6 @@ static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) } BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest); -#endif // USE_IR_RF || USE_RADIO_FREQUENCY +#endif // USE_IR_RF } // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h index 874e7a270bd..c6ed7fe2036 100644 --- a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h +++ b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h @@ -19,7 +19,8 @@ class InfraredCall { return *this; } InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; } - void perform() {} + template InfraredCall &set_api_connection(T * /*conn*/) { return *this; } + bool perform() { return false; } protected: Infrared *parent_; @@ -37,6 +38,7 @@ class Infrared : public Component, public EntityBase { const InfraredTraits &get_traits() const { return this->traits_; } InfraredCall make_call() { return InfraredCall(this); } uint32_t get_capability_flags() const { return 0; } + template void on_api_connection_closed(T * /*conn*/) {} protected: InfraredTraits traits_; diff --git a/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h index 72fc08034bd..b1cdd53c5b7 100644 --- a/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h +++ b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h @@ -23,7 +23,8 @@ class RadioFrequencyCall { RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { return *this; } - void perform() {} + template RadioFrequencyCall &set_api_connection(T * /*conn*/) { return *this; } + bool perform() { return false; } protected: RadioFrequency *parent_; @@ -43,6 +44,7 @@ class RadioFrequency : public Component, public EntityBase { const RadioFrequencyTraits &get_traits() const { return this->traits_; } RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } uint32_t get_capability_flags() const { return 0; } + template void on_api_connection_closed(T * /*conn*/) {} protected: RadioFrequencyTraits traits_; diff --git a/tests/integration/fixtures/external_components/remote_transmitter_mock/__init__.py b/tests/integration/fixtures/external_components/remote_transmitter_mock/__init__.py new file mode 100644 index 00000000000..b58eb41c59d --- /dev/null +++ b/tests/integration/fixtures/external_components/remote_transmitter_mock/__init__.py @@ -0,0 +1,25 @@ +"""Host-only stand-in for remote_transmitter used by the IR/RF integration tests.""" + +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/tests"] +MULTI_CONF = True +AUTO_LOAD = ["remote_base"] + +remote_transmitter_mock_ns = cg.esphome_ns.namespace("remote_transmitter_mock") +MockRemoteTransmitter = remote_transmitter_mock_ns.class_( + "MockRemoteTransmitter", remote_base.RemoteTransmitterBase, cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + {cv.GenerateID(): cv.declare_id(MockRemoteTransmitter)} +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.cpp b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.cpp new file mode 100644 index 00000000000..cfc03f38db7 --- /dev/null +++ b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.cpp @@ -0,0 +1,40 @@ +#include "remote_transmitter_mock.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::remote_transmitter_mock { + +static const char *const TAG = "remote_transmitter_mock"; + +void MockRemoteTransmitter::dump_config() { ESP_LOGCONFIG(TAG, "Mock Remote Transmitter"); } + +void MockRemoteTransmitter::flush_pending_completion() { + if (!this->busy_) + return; + // The RMT backend blocks here until the hardware is idle; the mock only reports the overlap + ESP_LOGW(TAG, "Overlap: a frame was requested while seq=%" PRIu16 " was in flight", this->current_seq_); + this->cancel_timeout("complete"); + this->finish_(); +} + +void MockRemoteTransmitter::send_internal(uint32_t send_times, uint32_t send_wait) { + uint64_t total_us = static_cast(send_wait) * (send_times - 1); + for (int32_t value : this->temp_.get_data()) { + total_us += static_cast(value < 0 ? -value : value) * send_times; + } + const auto duration_ms = static_cast(total_us / 1000); + + this->busy_ = true; + ESP_LOGI(TAG, "TX seq=%" PRIu16 " timings=%zu repeat=%" PRIu32 " duration=%" PRIu32 "ms", this->current_seq_, + this->temp_.get_data().size(), send_times, duration_ms); + this->set_timeout("complete", duration_ms, [this]() { this->finish_(); }); +} + +void MockRemoteTransmitter::finish_() { + this->busy_ = false; + ESP_LOGI(TAG, "Complete seq=%" PRIu16, this->current_seq_); + this->notify_complete_(true); +} + +} // namespace esphome::remote_transmitter_mock diff --git a/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.h b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.h new file mode 100644 index 00000000000..5b753d96382 --- /dev/null +++ b/tests/integration/fixtures/external_components/remote_transmitter_mock/remote_transmitter_mock.h @@ -0,0 +1,31 @@ +#pragma once + +// ============================================================================ +// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE +// +// Emulates a non-blocking remote transmitter: a frame "occupies" the +// transmitter for its real duration and the completion callback fires from a +// scheduler timeout, the same way the ESP32 RMT backend reports completion. +// A frame submitted while another is in flight is logged as an overlap; the +// real backends block the main loop in that case. +// ============================================================================ + +#include "esphome/core/component.h" +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::remote_transmitter_mock { + +class MockRemoteTransmitter : public remote_base::RemoteTransmitterBase, public Component { + public: + MockRemoteTransmitter() : remote_base::RemoteTransmitterBase(nullptr) {} + void dump_config() override; + + protected: + void flush_pending_completion() override; + void send_internal(uint32_t send_times, uint32_t send_wait) override; + void finish_(); + + bool busy_{false}; +}; + +} // namespace esphome::remote_transmitter_mock diff --git a/tests/integration/fixtures/ir_rf_transmit_complete.yaml b/tests/integration/fixtures/ir_rf_transmit_complete.yaml new file mode 100644 index 00000000000..16bc09b1ad1 --- /dev/null +++ b/tests/integration/fixtures/ir_rf_transmit_complete.yaml @@ -0,0 +1,30 @@ +esphome: + name: ir-rf-transmit-complete-test + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +remote_transmitter_mock: + - id: rf_tx + - id: ir_tx + +# two entities on one transmitter: each must get only its own completion +radio_frequency: + - platform: ir_rf_proxy + name: RF Transmitter A + remote_transmitter_id: rf_tx + - platform: ir_rf_proxy + name: RF Transmitter B + remote_transmitter_id: rf_tx + +infrared: + - platform: ir_rf_proxy + name: IR Transmitter + remote_transmitter_id: ir_tx diff --git a/tests/integration/test_ir_rf_transmit_complete.py b/tests/integration/test_ir_rf_transmit_complete.py new file mode 100644 index 00000000000..d74712271f7 --- /dev/null +++ b/tests/integration/test_ir_rf_transmit_complete.py @@ -0,0 +1,131 @@ +"""IR/RF transmit completion replies (API 1.18) and the client pacing built on them. + +The transmitter is a host-only mock that takes a frame's real duration to +"send" it and reports completion from a scheduler timeout, like the ESP32 RMT +backend. The client is expected to hold the next frame until the device +replies, so the mock never sees an overlapping frame. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import InfraredInfo, RadioFrequencyInfo +from aioesphomeapi.api_pb2 import InfraredRFTransmitCompleteResponse +import pytest + +from .state_utils import find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +FRAME_COUNT = 5 +# 10 marks and 10 spaces of 500 us, sent twice: 20 ms per frame +TIMINGS = [500, -500] * 10 +REPEAT = 2 +MOCK_EVENT = re.compile( + r"remote_transmitter_mock[^\]]*\]: (TX|Complete|Overlap)\b.*?seq=(\d+)" +) + + +@pytest.mark.shared_yaml("ir_rf_transmit_complete") +@pytest.mark.asyncio +async def test_ir_rf_transmit_complete_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The host build with the mock transmitters boots and lists all entities on any client.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + assert find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo) is not None + assert find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo) is not None + assert find_entity(entities, "ir_transmitter", InfraredInfo) is not None + + +@pytest.mark.shared_yaml("ir_rf_transmit_complete") +@pytest.mark.asyncio +async def test_ir_rf_transmit_complete( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Frames are answered once they leave the transmitter, never overlap, and a refused + request is answered at once. Two RF entities share a transmitter and each gets its own + reply; the infrared entity on its own transmitter is answered the same way.""" + loop = asyncio.get_running_loop() + events: list[tuple[str, int]] = [] + all_sent = loop.create_future() + all_logged = loop.create_future() + + def line_callback(line: str) -> None: + if (match := MOCK_EVENT.search(line)) is None: + return + events.append((match.group(1), int(match.group(2)))) + if ( + match.group(1) == "Complete" + and not all_sent.done() + and sum(kind == "Complete" for kind, _ in events) == FRAME_COUNT + ): + all_sent.set_result(None) + # the log reader stops with the device, so wait for the last line before asserting on it + if len(events) == 2 * (FRAME_COUNT + 1) and not all_logged.done(): + all_logged.set_result(None) + + completions: list[InfraredRFTransmitCompleteResponse] = [] + all_replied = loop.create_future() + ir_replied = loop.create_future() + refused = loop.create_future() + + def on_complete(msg: InfraredRFTransmitCompleteResponse) -> None: + if not msg.success: + if not refused.done(): + refused.set_result(msg) + return + completions.append(msg) + if len(completions) == FRAME_COUNT and not all_replied.done(): + all_replied.set_result(None) + if len(completions) == FRAME_COUNT + 1 and not ir_replied.done(): + ir_replied.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + rf = find_entity(entities, "rf_transmitter_a", RadioFrequencyInfo) + rf_b = find_entity(entities, "rf_transmitter_b", RadioFrequencyInfo) + ir = find_entity(entities, "ir_transmitter", InfraredInfo) + assert rf is not None and rf_b is not None, "RF transmitter entities not found" + assert ir is not None, "IR transmitter entity not found" + + client._connection.add_message_callback( + on_complete, (InfraredRFTransmitCompleteResponse,) + ) + # alternate between the two entities sharing the transmitter + keys = [rf.key if i % 2 == 0 else rf_b.key for i in range(FRAME_COUNT)] + for key in keys: + client.radio_frequency_transmit_raw_timings( + key, 433920000, TIMINGS, repeat_count=REPEAT + ) + + await asyncio.wait_for(all_replied, timeout=10) + await asyncio.wait_for(all_sent, timeout=10) + + # the infrared entity goes through the same path on its own transmitter + client.infrared_rf_transmit_raw_timings(ir.key, 38000, TIMINGS) + await asyncio.wait_for(ir_replied, timeout=10) + await asyncio.wait_for(all_logged, timeout=10) + + # A request the entity refuses is answered right away with success false; + # no timings, so the proxy rejects it before it reaches the transmitter + client.radio_frequency_transmit_raw_timings(rf.key, 433920000, []) + refused_msg = await asyncio.wait_for(refused, timeout=10) + + assert [msg.key for msg in completions] == [*keys, ir.key] + assert refused_msg.key == rf.key + + # The mocks saw one frame at a time: every transmit follows the previous completion + kinds = [kind for kind, _ in events] + assert kinds == ["TX", "Complete"] * (FRAME_COUNT + 1), events + seqs = [seq for _, seq in events] + assert seqs[::2] == seqs[1::2], events From ae6ebd2d9d0abd115dbc3105897ba6a3f5503cbd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:52:25 +1300 Subject: [PATCH 013/435] [epaper_spi] Add is_updating condition (#20261) --- esphome/components/epaper_spi/display.py | 6 ++++++ esphome/components/epaper_spi/epaper_spi.h | 2 ++ .../epaper_spi/config/full_update_next_test.yaml | 5 +++++ tests/component_tests/epaper_spi/test_init.py | 10 ++++++++++ .../epaper_spi/display/test_ssd1677_transfer.cpp | 15 +++++++++++++++ .../components/epaper_spi/test.esp32-s3-idf.yaml | 5 +++++ 6 files changed, 43 insertions(+) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 3fa504539be..487e8e5ef40 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -62,6 +62,12 @@ automation.register_apply_action( automation.ApplyCall("request_full_update()"), ) +automation.register_apply_condition( + "epaper_spi.is_updating", + automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}), + "is_updating()", +) + # Import all models dynamically from the models package for module_info in pkgutil.iter_modules(models.__path__): importlib.import_module(f".models.{module_info.name}", package=__package__) diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 0040c867b20..96755a404ec 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -109,6 +109,8 @@ class EPaperBase : public Display, void draw_pixel_at(int x, int y, Color color) 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. + bool is_updating() const { return this->state_ != EPaperState::IDLE; } protected: int get_height_internal() override { return this->height_; }; diff --git a/tests/component_tests/epaper_spi/config/full_update_next_test.yaml b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml index de5e678a700..ab8389798b7 100644 --- a/tests/component_tests/epaper_spi/config/full_update_next_test.yaml +++ b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml @@ -27,3 +27,8 @@ binary_sensor: name: Trigger on_press: - epaper_spi.full_update_next: epaper_display + - if: + condition: + epaper_spi.is_updating: epaper_display + then: + - component.update: epaper_display diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 9d6ebe831a0..4b8f5788925 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -551,6 +551,16 @@ def test_full_update_next_action_code_generation( assert "epaper_display->request_full_update();" in main_cpp +def test_is_updating_condition_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The epaper_spi.is_updating condition checks the configured display.""" + main_cpp = generate_main(component_config_path("full_update_next_test.yaml")) + + assert "epaper_display->is_updating()" in main_cpp + + def test_model_with_no_default_init_sequence_generates( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp index cad853b1973..01eea3c83aa 100644 --- a/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp +++ b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp @@ -196,6 +196,21 @@ TEST(EPaperSSD1677, RequestedFullUpdateAppliesWhenTheNextUpdateStarts) { EXPECT_FALSE(display.run_update_state()) << "request was applied more than once"; } +/// The display reports updating from the start of an update until it is back to idle. +TEST(EPaperSSD1677, IsUpdatingUntilBackToIdle) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + EXPECT_FALSE(display.is_updating()); + display.request_full_update(); + EXPECT_TRUE(display.run_update_state()); + EXPECT_TRUE(display.is_updating()); + + EXPECT_FALSE(display.run_update_state()) << "an update with nothing drawn should not push"; + EXPECT_FALSE(display.is_updating()); +} + /// Nothing a partial needs lives in controller RAM any more, so a partial push skips the reset /// altogether; a full one still gets the hardware pulse and the software reset. TEST(EPaperSSD1677, PartialPushSkipsTheResetAndAFullPushKeepsIt) { diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 7a4ce3a5ff1..8cb2a995212 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -8,6 +8,11 @@ esphome: on_boot: then: - epaper_spi.full_update_next: epaper_partial + - if: + condition: + epaper_spi.is_updating: epaper_partial + then: + - component.update: epaper_partial display: - platform: epaper_spi From 1c0d406cbb8fa3ccc795b34cfae8431d8d18cba0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:05:07 -0400 Subject: [PATCH 014/435] [serial_proxy] Add USB_SERIAL port type and device identity (#19308) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Keith Burzinski --- esphome/components/api/api.proto | 52 +++++++++++++ esphome/components/api/api_connection.cpp | 16 ++++ esphome/components/api/api_connection.h | 10 ++- esphome/components/api/api_pb2.cpp | 42 +++++++++++ esphome/components/api/api_pb2.h | 55 ++++++++++++++ esphome/components/api/api_pb2_dump.cpp | 47 ++++++++++++ esphome/components/api/api_pb2_service.cpp | 9 +++ esphome/components/api/api_pb2_service.h | 4 + esphome/components/api/api_server.cpp | 9 +++ esphome/components/api/api_server.h | 4 + esphome/components/serial_proxy/__init__.py | 23 +++++- .../components/serial_proxy/serial_proxy.cpp | 71 +++++++++++++++++- .../components/serial_proxy/serial_proxy.h | 41 +++++++++++ esphome/components/usb_host/usb_host.h | 49 +++++++++++++ .../components/usb_host/usb_host_client.cpp | 73 ++++++++++++++++++- esphome/components/usb_uart/__init__.py | 11 ++- esphome/components/usb_uart/usb_uart.cpp | 4 +- esphome/components/usb_uart/usb_uart.h | 16 +++- esphome/core/defines.h | 5 ++ .../components/serial_proxy/serial_proxy.h | 1 + .../serial_proxy/test-usb.esp32-s3-idf.yaml | 30 ++++++++ 21 files changed, 558 insertions(+), 14 deletions(-) create mode 100644 tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 5f4246b5742..4fce8d7b7f3 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -76,6 +76,7 @@ service APIConnection { rpc serial_proxy_write(SerialProxyWriteRequest) returns (void) {} rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} + rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} } @@ -233,6 +234,7 @@ enum SerialProxyPortType { SERIAL_PROXY_PORT_TYPE_TTL = 0; SERIAL_PROXY_PORT_TYPE_RS232 = 1; SERIAL_PROXY_PORT_TYPE_RS485 = 2; + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3; // since API 1.18 } message SerialProxyInfo { @@ -2907,6 +2909,56 @@ message SerialProxySetModeRequest { SerialProxyMode mode = 2; } +// Subscribe to the identity of every serial proxy port. The device answers with one +// SerialProxyIdentity per port, then sends another whenever a port's identity changes, +// for the life of the connection (since API 1.18). +message SubscribeSerialProxyIdentityRequest { + option (id) = 154; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; +} + +// Where a port's identity comes from +enum SerialProxyIdentitySource { + SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0; // The port carries no identity + SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1; // Reserved for identities stated in the device configuration; not sent yet + SERIAL_PROXY_IDENTITY_SOURCE_USB = 2; // Read from the descriptors of the USB device behind the port; + // changes when a device is attached or removed +} + +enum SerialProxyIdentityFlag { + SERIAL_PROXY_IDENTITY_FLAG_NONE = 0; + SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1; // The backend believes the device is present and usable on this port + SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2; // The USB host stack refused the descriptor query; the strings and + // IDs below are empty +} + +// The descriptor fields of a USB device as seen by the host stack +message UsbDeviceDescriptor { + uint32 vendor_id = 1; + uint32 product_id = 2; + uint32 bcd_device = 3; + uint32 interface_number = 4; // bInterfaceNumber the host driver binds to +} + +// The identity of the device behind a port. Identifies one physical device among others of +// the same kind, so a client matches on manufacturer and product regardless of source. The +// strings are empty for source NONE, and for source USB while nothing is connected +// (since API 1.18). +message SerialProxyIdentity { + option (id) = 155; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyIdentitySource source = 2; + uint32 flags = 3; // Bitmask of SerialProxyIdentityFlag + string manufacturer = 4; + string product = 5; + string serial_number = 6; + UsbDeviceDescriptor usb = 7; // Only sent for source USB +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 06e5e7c3425..a5d91de5513 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1685,6 +1685,22 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM } } +void APIConnection::on_subscribe_serial_proxy_identity_request() { +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + // Only USB ports change identity after this snapshot + this->flags_.serial_proxy_identity_subscription = true; +#endif + for (auto *proxy : App.get_serial_proxies()) { + proxy->send_identity(this); + } +} + +void APIConnection::send_serial_proxy_identity(const SerialProxyIdentity &msg) { + if (!this->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy identity"); + } +} + void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index db364d1830c..df9089f6237 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -246,6 +246,9 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg); void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); + void on_subscribe_serial_proxy_identity_request(); + /// Send a port identity to this client + void send_serial_proxy_identity(const SerialProxyIdentity &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); @@ -760,9 +763,12 @@ class APIConnection final : public APIServerConnectionBase { #ifdef HAS_PROTO_MESSAGE_DUMP uint8_t log_only_mode : 1; #endif - } flags_{}; // 2 bytes total +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + uint8_t serial_proxy_identity_subscription : 1; +#endif + } flags_{}; // 2 bytes; 3 with HAS_PROTO_MESSAGE_DUMP + USE_API_OUTGOING_CONNECTION + USE_SERIAL_PROXY_USB_IDENTITY - // 2-byte type immediately after flags_ (no padding between them) + // 2-byte type immediately after flags_ (one padding byte when flags_ is 3 bytes) uint16_t batch_message_type_{0}; // Current message type during batch encoding // 1-byte types to fill remaining space before next 4-byte boundary // Client API versions are clamped to 255 on receive (see send_hello_response_) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2f0d8e7cc73..b64b3492463 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4199,6 +4199,48 @@ void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uin break; } } +uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.vendor_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + return pos; +} +uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.vendor_id); + size += ProtoSize::calc_uint32(1, msg.product_id); + size += ProtoSize::calc_uint32(1, msg.bcd_device); + size += ProtoSize::calc_uint32(1, msg.interface_number); + return size; +} +uint8_t *SerialProxyIdentity::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.source)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.manufacturer); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.product); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.serial_number); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 7, msg.usb); + return pos; +} +uint32_t SerialProxyIdentity::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.instance); + size += msg.source ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_length(1, msg.manufacturer.size()); + size += ProtoSize::calc_length(1, msg.product.size()); + size += ProtoSize::calc_length(1, msg.serial_number.size()); + size += ProtoSize::calc_message(1, msg.usb.calculate_size()); + return size; +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothSetConnectionParamsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 0429bc78dd0..91c218b16c4 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -23,6 +23,7 @@ enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, SERIAL_PROXY_PORT_TYPE_RS485 = 2, + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3, }; enum EntityCategory : uint32_t { ENTITY_CATEGORY_NONE = 0, @@ -371,7 +372,17 @@ enum SerialProxyMode : uint32_t { SERIAL_PROXY_MODE_RAW = 0, SERIAL_PROXY_MODE_PROTOCOL = 1, }; +enum SerialProxyIdentitySource : uint32_t { + SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0, + SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1, + SERIAL_PROXY_IDENTITY_SOURCE_USB = 2, +}; #endif +enum SerialProxyIdentityFlag : uint32_t { + SERIAL_PROXY_IDENTITY_FLAG_NONE = 0, + SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1, + SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2, +}; } // namespace enums @@ -3973,6 +3984,50 @@ class SerialProxySetModeRequest final : public ProtoDecodableMessage { protected: static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; +class UsbDeviceDescriptor final : public ProtoMessage { + public: + uint32_t vendor_id{0}; + uint32_t product_id{0}; + uint32_t bcd_device{0}; + uint32_t interface_number{0}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +class SerialProxyIdentity final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 155; + static constexpr uint8_t ESTIMATED_SIZE = 54; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_identity"); } +#endif + uint32_t instance{0}; + enums::SerialProxyIdentitySource source{}; + uint32_t flags{0}; + StringRef manufacturer{nullptr, 0}; // null until set, encode only + StringRef product{nullptr, 0}; // null until set, encode only + StringRef serial_number{nullptr, 0}; // null until set, encode only + UsbDeviceDescriptor usb{}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 6c7a1ac9ac9..79f9f360bd0 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -143,6 +143,8 @@ template<> const char *proto_enum_to_string(enums::S return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232"); case enums::SERIAL_PROXY_PORT_TYPE_RS485: return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485"); + case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL: + return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -890,7 +892,31 @@ template<> const char *proto_enum_to_string(enums::Seria return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyIdentitySource value) { + switch (value) { + case enums::SERIAL_PROXY_IDENTITY_SOURCE_NONE: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_NONE"); + case enums::SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED"); + case enums::SERIAL_PROXY_IDENTITY_SOURCE_USB: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_USB"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif +template<> const char *proto_enum_to_string(enums::SerialProxyIdentityFlag value) { + switch (value) { + case enums::SERIAL_PROXY_IDENTITY_FLAG_NONE: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_NONE"); + case enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_CONNECTED"); + case enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_ERROR"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest")); @@ -2841,6 +2867,27 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); return out.c_str(); } +const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("UsbDeviceDescriptor")); + dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id); + dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); + dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); + dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + return out.c_str(); +} +const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyIdentity")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("source"), static_cast(this->source)); + dump_field(out, ESPHOME_PSTR("flags"), this->flags); + dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); + dump_field(out, ESPHOME_PSTR("product"), this->product); + dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number); + out.append(2, ' ').append_p(ESPHOME_PSTR("usb")).append(": "); + this->usb.dump_to(out); + out.append("\n"); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index b8ac99f14f4..afc99f93c83 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -722,6 +722,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_serial_proxy_set_mode_request(msg); break; } +#endif +#ifdef USE_SERIAL_PROXY + case 154 /* SubscribeSerialProxyIdentityRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_subscribe_serial_proxy_identity_request")); +#endif + this->on_subscribe_serial_proxy_identity_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index b77fc82da7a..ea89984ac57 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -238,6 +238,10 @@ class APIServerConnectionBase { #ifdef USE_SERIAL_PROXY void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_subscribe_serial_proxy_identity_request(){}; +#endif + #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 904bb33e535..33869dd976e 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -504,6 +504,15 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { } #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) { + for (auto &c : this->active_clients()) { + if (c->flags_.serial_proxy_identity_subscription) + c->send_serial_proxy_identity(msg); + } +} +#endif + #ifdef USE_IR_RF void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index df05ca3a434..09b08de4e3f 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -203,6 +203,10 @@ class APIServer final : public Component #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// Tell every subscribed client that a serial proxy port's identity changed + void send_serial_proxy_identity(const SerialProxyIdentity &msg); +#endif #ifdef USE_IR_RF void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index b6e780fabdc..a8767280504 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -17,10 +17,12 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.usb_uart import is_usb_uart_channel import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME +from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -38,6 +40,7 @@ SERIAL_PROXY_PORT_TYPES = { "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, + "USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL, } CONF_DTR_PIN = "dtr_pin" @@ -73,6 +76,18 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + is_usb = is_usb_uart_channel(config[CONF_UART_ID], fv.full_config.get()) + if config[CONF_PORT_TYPE] == "USB_SERIAL" and not is_usb: + raise cv.Invalid( + f"{CONF_PORT_TYPE} USB_SERIAL requires {CONF_UART_ID} to be a usb_uart channel" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_serial_proxy_count_define() -> None: """Emit the SERIAL_PROXY_COUNT define once with the final instance count.""" @@ -88,6 +103,12 @@ async def to_code(config: ConfigType) -> None: cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) + # port_type names the electrical interface (a USB RS485 adapter is RS485), so every + # usb_uart channel reports USB identity whatever port type it declares + if is_usb_uart_channel(config[CONF_UART_ID], CORE.config): + channel = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_usb_channel(channel)) + cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 729ec40256a..16e0f0b984c 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -14,6 +14,10 @@ #include "esphome/components/api/api_server.h" #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +#include "esphome/components/usb_uart/usb_uart.h" +#endif + namespace esphome::serial_proxy { static const char *const TAG = "serial_proxy"; @@ -35,6 +39,12 @@ void SerialProxy::setup() { // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + if (this->usb_channel_ != nullptr) { + this->usb_channel_->get_parent()->add_on_connection_callback( + [this](bool connected) { this->on_usb_connection_changed_(connected); }); + } +#endif #ifdef USE_SERIAL_PROXY_TAP // A tap sets itself up before this runs (its setup priority is higher), so it may // already be waiting on the port -- a boot-time handshake with the device, say. Leaving @@ -161,9 +171,10 @@ void SerialProxy::dump_config() { " RTS Pin: %s\n" " DTR Pin: %s", this->instance_index_, this->name_ != nullptr ? this->name_ : "", - this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") - : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") - : LOG_STR_LITERAL("TTL"), + this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL ? LOG_STR_LITERAL("USB_SERIAL") + : LOG_STR_LITERAL("TTL"), this->rts_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"), this->dtr_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured")); } @@ -373,6 +384,60 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } +#ifdef USE_API +void SerialProxy::send_identity(api::APIConnection *api_connection) { + IdentityScratch scratch; + api::SerialProxyIdentity msg{}; + this->fill_identity_(scratch, msg); + api_connection->send_serial_proxy_identity(msg); +} + +void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api::SerialProxyIdentity &msg) const { + msg.instance = this->instance_index_; +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + // The define is global, so a hardware UART port in the same config also gets here + if (this->usb_channel_ != nullptr) { + msg.source = api::enums::SERIAL_PROXY_IDENTITY_SOURCE_USB; + // Covers a removed device, a channel the device has no CDC function for and a failed channel setup + if (!this->usb_channel_->is_connected()) { + return; + } + auto *client = this->usb_channel_->get_parent(); + msg.flags = api::enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED; + if (!client->get_device_info(scratch)) { + msg.flags |= api::enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR; + return; + } + msg.usb.vendor_id = scratch.vendor_id; + msg.usb.product_id = scratch.product_id; + msg.usb.bcd_device = scratch.bcd_device; + msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.manufacturer = StringRef(scratch.manufacturer); + msg.product = StringRef(scratch.product); + msg.serial_number = StringRef(scratch.serial_number); + return; + } +#endif + // Zero-initialized message: source NONE, no flags +} +#endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +void SerialProxy::on_usb_connection_changed_(bool connected) { + ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]", + connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_); +#ifdef USE_API + if (api::global_api_server == nullptr) { + return; + } + IdentityScratch scratch; + api::SerialProxyIdentity msg{}; + this->fill_identity_(scratch, msg); + api::global_api_server->send_serial_proxy_identity(msg); +#endif +} +#endif + uint32_t SerialProxy::get_modem_pins() const { return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index df775d4f630..1a406bc0891 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -20,6 +20,15 @@ #include "esphome/components/api/api_pb2.h" #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +namespace esphome::usb_uart { +class USBUartChannel; +} // namespace esphome::usb_uart +namespace esphome::usb_host { +struct UsbDeviceInfo; +} // namespace esphome::usb_host +#endif + // Forward-declare types needed outside the USE_API guard. namespace esphome::api { class APIConnection; @@ -160,6 +169,16 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Set the DTR GPIO pin (from YAML configuration) void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// Attach the USB UART channel behind this port (from code generation) + void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; } +#endif + +#ifdef USE_API + /// Send this port's identity to one client + void send_identity(api::APIConnection *api_connection); +#endif + #ifdef USE_SERIAL_PROXY_TAP /// Attach a traffic observer. At most one, set once at setup time. void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } @@ -226,6 +245,23 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool tap_observing_() const; #endif +#ifdef USE_API +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + using IdentityScratch = usb_host::UsbDeviceInfo; +#else + struct IdentityScratch {}; +#endif + /// Fill an identity message for this port. The message's strings are views into scratch, + /// so it must outlive the send. + void fill_identity_(IdentityScratch &scratch, api::SerialProxyIdentity &msg) const; +#endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// The USB device behind this port was attached or removed; report the port's new + /// identity to every subscribed API client + void on_usb_connection_changed_(bool connected); +#endif + /// Instance index for identifying this proxy in API messages uint32_t instance_index_{0}; @@ -268,6 +304,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { #ifdef USE_SERIAL_PROXY_TAP SerialProxyTap *tap_{nullptr}; #endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// The USB UART channel behind this port; nullptr on non-USB ports + usb_uart::USBUartChannel *usb_channel_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 6f92fc85386..aaab8e22a18 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -5,6 +5,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/defines.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include #include "usb/usb_host.h" #include @@ -12,6 +13,7 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" #include +#include namespace esphome::usb_host { @@ -117,6 +119,25 @@ struct UsbEvent { // callback function type. +// USB string descriptors hold at most 126 characters; one more for the terminator +static constexpr size_t DESC_STRING_BUF_SIZE = 128; + +/// Identity of a connected USB device, copied out of the descriptors the USB host +/// stack caches for the lifetime of the connection +struct UsbDeviceInfo { + uint16_t vendor_id; + uint16_t product_id; + uint16_t bcd_device; + char manufacturer[DESC_STRING_BUF_SIZE]; + char product[DESC_STRING_BUF_SIZE]; + char serial_number[DESC_STRING_BUF_SIZE]; +}; + +/// Copy a USB string descriptor into a NUL-terminated buffer. A missing descriptor copies as +/// an empty string. Returns false when a descriptor contains non-ASCII characters, +/// UTF-16 to UTF-8 conversion is not currently implemented. +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer); + enum ClientState { USB_CLIENT_INIT = 0, USB_CLIENT_OPEN, @@ -146,6 +167,23 @@ class USBClient : public Component { void set_manufacturer_filter(const char16_t *manufacturer) { this->manufacturer_filter_ = manufacturer; } void set_product_filter(const char16_t *product) { this->product_filter_ = product; } + /// Whether a device has been opened and its setup by the subclass has finished + bool is_connected() const { return this->connection_reported_; } + + /// Copy the connected device's identity out of the cached USB descriptors. + /// Returns false when no device is connected or the host stack refused the query. + bool get_device_info(UsbDeviceInfo &info) const; + + /// Register a callback for the device this client claims being connected (true) or + /// removed (false). Fires only for a device that was fully opened, so a device another + /// client claims is never reported. Called from the main loop: connected once the device + /// has been enumerated and the subclass has finished its setup of it (whether or not that + /// setup succeeded), removed after on_disconnected() has run. This tracks the device's + /// presence, not whether a given channel is usable. + template void add_on_connection_callback(F &&callback) { + this->connection_callback_.add(std::forward(callback)); + } + // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks LockFreeQueue event_queue; @@ -163,6 +201,13 @@ class USBClient : public Component { TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); virtual void on_connected() {} + + /// Whether the subclass reports the device as connected itself, once its own setup of + /// the device has finished, rather than as soon as the device has been opened + virtual bool reports_connection_itself() const { return false; } + /// Report the claimed device to the connection callbacks. Idempotent; a subclass that + /// reports itself calls this once the device is ready to use. + void report_connected_(); virtual void on_disconnected() { // Reset all requests to available (all bits to 0) this->trq_in_use_.store(0); @@ -179,6 +224,7 @@ class USBClient : public Component { usb_device_handle_t device_handle_{}; int device_addr_{-1}; int state_{USB_CLIENT_INIT}; + LazyCallbackManager connection_callback_; // Lock-free pool management using atomic bitmask (no dynamic allocation) // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available // Supports multiple concurrent consumers and producers (both threads can allocate/deallocate) @@ -187,6 +233,9 @@ class USBClient : public Component { const char16_t *product_filter_{nullptr}; uint16_t vid_{}; uint16_t pid_{}; + // Whether the connection callbacks were told about the current device, so a removal is + // only ever reported for a device that was reported connected + bool connection_reported_{false}; }; class USBHost final : public Component { public: diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 3ad5fc59dcf..1b50aa75baf 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -145,9 +145,27 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc } while (next_desc != NULL); } #endif -// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType). -// Character count = (bLength - 2) / 2, max 126 chars + null terminator. -static constexpr size_t DESC_STRING_BUF_SIZE = 128; +// bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType), +// so character count = (bLength - 2) / 2. +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { + buffer[0] = '\0'; + if (desc == nullptr || desc->bLength < 2) + return true; + int char_count = (desc->bLength - 2) / 2; + char *p = buffer.data(); + char *end = p + buffer.size() - 1; + for (int i = 0; i != char_count && p < end; i++) { + auto c = desc->wData[i]; + // TODO: encode non-ASCII code units as UTF-8 if a device with such descriptors turns up + if (c >= 0x80) { + buffer[0] = '\0'; + return false; + } + *p++ = static_cast(c); + } + *p = '\0'; + return true; +} // Folds UTF-16 to Latin-1 for logging, dropping anything that does not fit template @@ -183,6 +201,36 @@ static bool descriptor_string_equals(const usb_str_desc_t *desc, const char16_t return expected[char_count] == u'\0'; } +bool USBClient::get_device_info(UsbDeviceInfo &info) const { + if (!this->is_connected()) + return false; + const usb_device_desc_t *desc; + esp_err_t err = usb_host_get_device_descriptor(this->device_handle_, &desc); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device descriptor query failed: %s", esp_err_to_name(err)); + return false; + } + info.vendor_id = desc->idVendor; + info.product_id = desc->idProduct; + info.bcd_device = desc->bcdDevice; + usb_device_info_t dev_info; + err = usb_host_device_info(this->device_handle_, &dev_info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device info query failed: %s", esp_err_to_name(err)); + return false; + } + if (!copy_descriptor_string(dev_info.str_desc_manufacturer, info.manufacturer)) { + ESP_LOGW(TAG, "Manufacturer string descriptor is not ASCII"); + } + if (!copy_descriptor_string(dev_info.str_desc_product, info.product)) { + ESP_LOGW(TAG, "Product string descriptor is not ASCII"); + } + if (!copy_descriptor_string(dev_info.str_desc_serial_num, info.serial_number)) { + ESP_LOGW(TAG, "Serial number string descriptor is not ASCII"); + } + return true; +} + // CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { auto *client = static_cast(ptr); @@ -367,6 +415,18 @@ void USBClient::handle_open_state_() { usb_client_print_config_descriptor(config_desc, nullptr); #endif this->on_connected(); + // on_connected() may have rejected the device (no usable interface, say) and closed it + if (this->state_ == USB_CLIENT_CONNECTED && !this->reports_connection_itself()) { + this->report_connected_(); + } +} + +void USBClient::report_connected_() { + if (this->state_ != USB_CLIENT_CONNECTED || this->connection_reported_) { + return; + } + this->connection_reported_ = true; + this->connection_callback_.call(true); } void USBClient::on_opened(uint8_t addr) { @@ -437,6 +497,10 @@ TransferRequest *USBClient::get_trq_() { } void USBClient::disconnect() { + // Also reached for a device this client opened and then declined, or lost before it was + // ready; neither was reported as connected, so neither is reported as removed + const bool was_reported = this->connection_reported_; + this->connection_reported_ = false; this->on_disconnected(); auto err = usb_host_device_close(this->handle_, this->device_handle_); if (err != ESP_OK) { @@ -445,6 +509,9 @@ void USBClient::disconnect() { this->state_ = USB_CLIENT_INIT; this->device_handle_ = nullptr; this->device_addr_ = -1; + if (was_reported) { + this->connection_callback_.call(false); + } } // THREAD CONTEXT: Called from main loop thread only diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index af58c24d432..a632b4e38d0 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -18,7 +18,7 @@ from esphome.const import ( CONF_ID, CONF_TYPE, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.cpp_types import Component from esphome.types import ConfigType @@ -29,6 +29,15 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) + +def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: + return any( + channel[CONF_ID] == uart_id + for device in full_config.get("usb_uart") or [] + for channel in device[CONF_CHANNELS] + ) + + UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 37a5587fca1..86dfa6d729f 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -21,8 +21,6 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in int conf_offset, ep_offset; // look for an interface with an interrupt endpoint (notify), and one with two bulk endpoints (data in/out) CdcEps eps{}; - eps.bulk_interface_number = 0xFF; - eps.interrupt_interface_number = 0xFF; for (;;) { const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx++, 0, &conf_offset); if (!intf_desc) { @@ -664,6 +662,8 @@ bool USBUartComponent::run_config_machine_() { this->cfg_single_ = nullptr; } else if (++this->cfg_channel_idx_ >= this->channels_.size()) { this->cfg_active_ = false; + // Init is done and the line settings are on the wire: now the device is ready to use + this->report_connected_(); } // If the machine just went idle and a reload was requested while it was busy, start it now. diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c99992d8785..c6ae0b4eef5 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -33,10 +33,11 @@ struct CdcEps { const usb_ep_desc_t *notify_ep; const usb_ep_desc_t *in_ep; const usb_ep_desc_t *out_ep; - uint8_t bulk_interface_number; + // 0xFF marks a channel that was never matched to a CDC function on the device + uint8_t bulk_interface_number{0xFF}; // Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it // must remain valid even when the interface itself is not claimed. - uint8_t interrupt_interface_number; + uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; }; @@ -182,6 +183,13 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } + /// USB interface number a host driver binds to for this channel: the communication + /// interface of a CDC ACM function, otherwise the data interface. + uint8_t get_interface_number() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_number + : this->cdc_dev_.bulk_interface_number; + } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -267,6 +275,10 @@ class USBUartComponent : public usb_host::USBClient { // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + // The device is only usable once the config machine has applied every channel's line + // settings, so the connected report waits for run_config_machine_() to finish the init + bool reports_connection_itself() const override { return true; } + std::vector channels_{}; // Config state machine diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7c35ae2714d..c8a10f3d2d4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -467,6 +467,11 @@ #define USE_USB_UART_CP210X #define USE_USB_UART_FT23XX #define USE_USB_UART_PL2303 +// USB identity on serial proxy ports needs the usb_host stack +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) +#define USE_SERIAL_PROXY_USB_IDENTITY +#endif #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index 7da6fff017b..e2c2efaa6be 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -49,6 +49,7 @@ class SerialProxy { uint32_t get_modem_pins() const { return 0; } uint32_t get_configured_modem_pins() const { return 0; } SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } + void send_identity(api::APIConnection *api_connection) {} protected: uint32_t instance_index_{0}; diff --git a/tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml b/tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml new file mode 100644 index 00000000000..4bcd3a771f2 --- /dev/null +++ b/tests/components/serial_proxy/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,30 @@ +# A hardware UART port alongside the USB one, so the USB identity define is exercised on a +# port that has no USB channel +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +wifi: + ssid: MySSID + password: password1 + +api: + +usb_host: + +usb_uart: + - type: CDC_ACM + vid: 0x303A + pid: 0x831A + channels: + - id: usb_serial_channel + baud_rate: 460800 + +serial_proxy: + - id: serial_proxy_usb + uart_id: usb_serial_channel + name: USB Serial Port + port_type: USB_SERIAL + - id: serial_proxy_hw + uart_id: uart_bus + name: Hardware Serial Port + port_type: TTL From 6dec759261d5db98800ca1c015367a7109caad76 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:18:40 +1100 Subject: [PATCH 015/435] [core] Select I/O expander pin providers by I2C address (#18939) Co-authored-by: Claude Sonnet 5 --- esphome/components/max6956/__init__.py | 2 +- esphome/components/mcp23016/__init__.py | 2 +- esphome/components/mcp23xxx_base/__init__.py | 2 +- esphome/components/mipi_dsi/models/m5stack.py | 6 + esphome/components/pca6416a/__init__.py | 2 +- esphome/components/pca9554/__init__.py | 2 +- esphome/components/pcf8574/__init__.py | 2 +- esphome/components/pi4ioe5v6408/__init__.py | 2 +- esphome/components/sx1509/__init__.py | 2 +- esphome/components/tca9555/__init__.py | 2 +- esphome/components/xl9535/__init__.py | 2 +- esphome/config.py | 36 ++++++ esphome/core/__init__.py | 9 +- esphome/pins.py | 67 +++++++++-- .../mipi_dsi/test-tab5.esp32-p4-idf.yaml | 23 ++++ tests/components/xl9535/common.yaml | 12 ++ tests/unit_tests/core/test_config.py | 79 +++++++++++++ .../expander_pin_ambiguous_no_address.yaml | 24 ++++ .../core/config/expander_pin_by_address.yaml | 25 ++++ .../expander_pin_by_address_ambiguous.yaml | 31 +++++ .../expander_pin_by_address_no_match.yaml | 23 ++++ .../expander_pin_reuse_across_syntax.yaml | 30 +++++ tests/unit_tests/test_pins.py | 110 ++++++++++++++++++ 23 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml create mode 100644 tests/unit_tests/test_pins.py diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 557f497e1ab..05abaf886f1 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -79,7 +79,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_MAX6956): cv.use_id(MAX6956), + cv.Required(CONF_MAX6956): pins.use_id_or_address(MAX6956), } ) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 5f4b7276d83..aa606507f0b 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -60,7 +60,7 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_MCP23016): cv.use_id(MCP23016), + cv.Required(CONF_MCP23016): pins.use_id_or_address(MCP23016), } ) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 755d86e4ea7..a626835518b 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -74,7 +74,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_MCP23XXX): cv.use_id(MCP23XXXBase), + cv.Required(CONF_MCP23XXX): pins.use_id_or_address(MCP23XXXBase), cv.Optional(CONF_INTERRUPT, default="NO_INTERRUPT"): cv.enum( MCP23XXX_INTERRUPT_MODES, upper=True ), diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 5b07229ec7c..4e847ef6fd9 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -14,6 +14,8 @@ DsiDriverChip( pclk_frequency="60MHz", lane_bit_rate="730Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 (0xB7, 0x03), # Pad control - 2 lane @@ -67,6 +69,8 @@ TAB5_ST7123 = DsiDriverChip( pclk_frequency="80MHz", lane_bit_rate="960Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0x01,), (0x60, 0x71, 0x23, 0xa2), @@ -118,6 +122,8 @@ DsiDriverChip( pclk_frequency="70MHz", lane_bit_rate="965Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0x01,), (0x60, 0x71, 0x21, 0xA2), diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 47b9118d9ae..9756e9431ab 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -60,7 +60,7 @@ def validate_mode(value: ConfigType) -> ConfigType: PCA6416A_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), - cv.Required(CONF_PCA6416A): cv.use_id(PCA6416AComponent), + cv.Required(CONF_PCA6416A): pins.use_id_or_address(PCA6416AComponent), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 5272df2b555..68e6d163e8d 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -66,7 +66,7 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_PCA9554): cv.use_id(PCA9554Component), + cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component), } ) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 9f8c7fabd25..4ee1c2af567 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -63,7 +63,7 @@ PCF8574_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_PCF8574): cv.use_id(PCF8574Component), + cv.Required(CONF_PCF8574): pins.use_id_or_address(PCF8574Component), } ) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 11a5dd62bb6..45d84b88efd 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -74,7 +74,7 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_PI4IOE5V6408): cv.use_id(PI4IOE5V6408Component), + cv.Required(CONF_PI4IOE5V6408): pins.use_id_or_address(PI4IOE5V6408Component), } ) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index e4df7b443c2..942911c7fe8 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -133,7 +133,7 @@ CONF_SX1509 = "sx1509" SX1509_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(SX1509GPIOPin), - cv.Required(CONF_SX1509): cv.use_id(SX1509Component), + cv.Required(CONF_SX1509): pins.use_id_or_address(SX1509Component), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 3033cc65e08..6ae1afc883b 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -62,7 +62,7 @@ TCA9555_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_TCA9555): cv.use_id(TCA9555Component), + cv.Required(CONF_TCA9555): pins.use_id_or_address(TCA9555Component), } ) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 5686b741738..2656b60c103 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -54,7 +54,7 @@ def validate_pin(pin: int) -> int: XL9535_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(XL9535GPIOPin), - cv.Required(CONF_XL9535): cv.use_id(XL9535Component), + cv.Required(CONF_XL9535): pins.use_id_or_address(XL9535Component), cv.Required(CONF_NUMBER): cv.All(cv.int_range(min=0, max=17), validate_pin), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/config.py b/esphome/config.py index bc69661d8f8..28a688f15ab 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1129,6 +1129,42 @@ class IDPassValidationStep(ConfigValidationStep): if inherits: matches.append(v[0]) + if id.match_config: + # Disambiguate among same-type candidates by comparing their own + # declared config against the requested key/value pairs, e.g. an + # I2C address, instead of requiring a single unambiguous candidate. + criteria = ", ".join(f"{k}={v}" for k, v in id.match_config.items()) + filtered = [ + m + for m in matches + if isinstance( + candidate_conf := result.get_config_for_path( + result.get_path_for_id(m)[:-1] + ), + dict, + ) + and all( + candidate_conf.get(k) == v + for k, v in id.match_config.items() + ) + ] + if len(filtered) == 1: + id.id = filtered[0].id + elif len(filtered) == 0: + result.add_str_error( + f"Couldn't find a '{id.type}' matching {criteria}. " + "Are you missing a hub declaration, or is the address wrong?", + path, + ) + else: + ids = ", ".join(f"'{m.id}'" for m in filtered) + result.add_str_error( + f"Multiple '{id.type}' instances match {criteria}: {ids}. " + "You must assign an explicit ID to the one you want to use.", + path, + ) + continue + if len(matches) == 0: result.add_str_error( f"Couldn't find any component that can be used for '{id.type}'. Are you missing a hub declaration?", diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 30c03720665..1668263e337 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -385,7 +385,9 @@ class Lambda: class ID: - def __init__(self, id, is_declaration=False, type=None, is_manual=None): + def __init__( + self, id, is_declaration=False, type=None, is_manual=None, match_config=None + ): self.id = id if is_manual is None: self.is_manual = id is not None @@ -393,6 +395,10 @@ class ID: self.is_manual = is_manual self.is_declaration = is_declaration self.type: MockObjClass | None = type + # When set, an unnamed (id=None) searching ID is disambiguated among same-type + # candidates by matching these key/value pairs against each candidate's own + # declared config, instead of requiring exactly one candidate to exist. + self.match_config: dict | None = match_config def resolve(self, registered_ids): from esphome.config_validation import RESERVED_IDS @@ -431,6 +437,7 @@ class ID: is_declaration=self.is_declaration, type=self.type, is_manual=self.is_manual, + match_config=self.match_config, ) diff --git a/esphome/pins.py b/esphome/pins.py index 34a406c2733..d42cc489a1b 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Hashable from functools import reduce from logging import Logger import operator @@ -6,6 +6,7 @@ from typing import Any import esphome.config_validation as cv from esphome.const import ( + CONF_ADDRESS, CONF_ALLOW_OTHER_USES, CONF_IGNORE_STRAPPING_WARNING, CONF_INPUT, @@ -17,8 +18,9 @@ from esphome.const import ( CONF_PULLDOWN, CONF_PULLUP, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.cpp_generator import MockObjClass +from esphome.schema_extractors import schema_extractor class PinRegistry(dict): @@ -69,11 +71,15 @@ class PinRegistry(dict): # evaluate here so a validation failure skips the rest result = self[key][1](conf) if CONF_NUMBER in result: - # key maps to the pin schema - if key != CORE.target_platform: - pin_key = (key, conf[key], result[CONF_NUMBER]) - else: - pin_key = (key, key, result[CONF_NUMBER]) + # key maps to the pin schema. The bucket used here doesn't need to + # identify the provider precisely -- final_validate() regroups pin + # usage by the *resolved* provider id once IDPassValidationStep has + # run, so this only has to be hashable. A hub selected by + # match_config (e.g. address) is given as a dict, which isn't. + ref = conf[key] if key != CORE.target_platform else key + if not isinstance(ref, Hashable): + ref = "match_config" + pin_key = (key, ref, result[CONF_NUMBER]) if pin_key not in self.pins_used: self.pins_used[pin_key] = [] # client_id identifies the instance of the providing component @@ -107,7 +113,18 @@ class PinRegistry(dict): Run the final validation for all pins, and check for reuse :param fconf: The full config """ - for (key, _, _), pin_list in self.pins_used.items(): + # Regroup by (schema key, resolved provider id, pin number) rather than + # the validate()-time bucket, so the same physical pin reached via an + # explicit id and via {address: ...} (or an omitted id) is recognized as + # one reuse group regardless of which syntax was used to reference the hub. + grouped: dict[tuple, list] = {} + for (key, _, number), pin_list in self.pins_used.items(): + for entry in pin_list: + client_id = entry[1] + provider_id = client_id.id if isinstance(client_id, ID) else client_id + grouped.setdefault((key, provider_id, number), []).append(entry) + + for (key, _, _), pin_list in grouped.items(): count = len(pin_list) # number of places same pin used. final_val_fun = self[key][2] # final validation function for pin_path, client_id, pin_config in pin_list: @@ -131,6 +148,40 @@ class PinRegistry(dict): PIN_SCHEMA_REGISTRY = PinRegistry() +def use_id_or_address(hub_type, address_key=CONF_ADDRESS): + """Build a validator for a pin-provider hub reference. + + Accepts everything `cv.use_id(hub_type)` accepts (an explicit id, or omitted to + auto-select the sole instance of `hub_type`), plus a mapping like + `{address: 0x21}` to instead select the instance of `hub_type` whose own + declared config has a matching `address_key`. Use this for pin schemas of + I2C-addressed hubs (I/O expanders) so a board with two instances of the + same hub type can pick one without assigning it an explicit id. + """ + id_validator = cv.use_id(hub_type) + + @schema_extractor("use_id") + def validator(value): + if isinstance(value, dict): + address = cv.Schema({cv.Required(address_key): cv.i2c_address})(value)[ + address_key + ] + return ID( + None, + is_declaration=False, + type=hub_type, + # The user explicitly gave selection criteria (unlike the plain + # omitted-id auto-pick), so treat the resolved id as manual: it + # must survive strip_default_ids() and appear in `esphome config` + # output, not be silently dropped as if it were a default. + is_manual=True, + match_config={address_key: address}, + ) + return id_validator(value) + + return validator + + def _set_mode(value, default_mode): if CONF_MODE not in value: return {**value, CONF_MODE: default_mode} diff --git a/tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml b/tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml new file mode 100644 index 00000000000..2f4b02eea8d --- /dev/null +++ b/tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml @@ -0,0 +1,23 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-p4-idf.yaml + +esp_ldo: + - id: ldo_id + channel: 3 + voltage: 2.5V + +psram: + +pi4ioe5v6408: + # A second, decoy hub at a different address, so this test only passes if + # the model's reset_pin default actually resolves pi4ioe1 by its address + # (0x43) -- with only one hub declared, the single-instance auto-pick + # fallback would resolve it too, even if address matching regressed. + - id: pi4ioe1 + address: 0x43 + - id: pi4ioe2 + address: 0x44 + +display: + - platform: mipi_dsi + model: M5STACK-TAB5 diff --git a/tests/components/xl9535/common.yaml b/tests/components/xl9535/common.yaml index 81e96131ab9..21ce8b5512d 100644 --- a/tests/components/xl9535/common.yaml +++ b/tests/components/xl9535/common.yaml @@ -2,6 +2,9 @@ xl9535: - id: xl9535_hub i2c_id: i2c_bus address: 0x20 + - id: xl9535_hub_2 + i2c_id: i2c_bus + address: 0x21 binary_sensor: - platform: gpio @@ -20,3 +23,12 @@ binary_sensor: mode: input: true inverted: false + - platform: gpio + name: XL9535 Pin 1 By Address + pin: + xl9535: + address: 0x21 + number: 1 + mode: + input: true + inverted: false diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 1a36fdc967c..883f9d8b3d5 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -447,6 +447,85 @@ def test_device_duplicate_id( assert "ID duplicate_device redefined!" in captured.out +def test_expander_pin_selected_by_address(yaml_file: Callable[[str], str]) -> None: + """A pin provider hub with an omitted id can be selected by its address.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_by_address.yaml", FIXTURES_DIR + ) + assert result is not None + + resolved = result["binary_sensor"][0]["pin"]["xl9535"] + assert resolved.id == "xl9535_b" + # Explicit selection criteria must survive strip_default_ids(), unlike a + # plain omitted-id auto-pick. + assert resolved.is_manual is True + + +def test_expander_pin_selected_by_address_no_match( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Selecting an address that matches no hub of that type fails clearly.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_by_address_no_match.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "Couldn't find a 'xl9535::XL9535Component' matching address=0x21. " + "Are you missing a hub declaration, or is the address wrong?" in captured.out + ) + + +def test_expander_pin_selected_by_address_ambiguous( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Two hubs sharing the same address (e.g. on different buses) still need an id.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_by_address_ambiguous.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "Multiple 'xl9535::XL9535Component' instances match address=0x20: " + "'xl9535_a', 'xl9535_b'. You must assign an explicit ID to the one you " + "want to use." in captured.out + ) + + +def test_expander_pin_ambiguous_without_match_config( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Omitting both id and address with multiple hubs of the same type still + falls back to the original "too many candidates" error.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_ambiguous_no_address.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "Too many candidates found for 'xl9535' type 'xl9535::XL9535Component' " + "Some are 'xl9535_a', 'xl9535_b'" in captured.out + ) + + +def test_expander_pin_reuse_detected_across_reference_syntax( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """The same physical pin, reached once by explicit id and once by address, + must still be flagged as reused -- reuse detection keys on the resolved + provider id, not on which syntax was used to reference it.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_reuse_across_syntax.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert "Pin 5 is used in multiple places" in captured.out + + def test_substitution_with_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml new file mode 100644 index 00000000000..7b22ad3bead --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + - id: xl9535_b + address: 0x21 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml new file mode 100644 index 00000000000..e4cdff9e29c --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + - id: xl9535_b + address: 0x21 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + address: 0x21 + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml new file mode 100644 index 00000000000..b951417c2be --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml @@ -0,0 +1,31 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + - id: bus_a + sda: GPIO21 + scl: GPIO22 + - id: bus_b + sda: GPIO18 + scl: GPIO19 + +xl9535: + - id: xl9535_a + i2c_id: bus_a + address: 0x20 + - id: xl9535_b + i2c_id: bus_b + address: 0x20 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + address: 0x20 + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml new file mode 100644 index 00000000000..126f88f31a3 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + address: 0x21 + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml new file mode 100644 index 00000000000..6a6a90fd345 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml @@ -0,0 +1,30 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + +binary_sensor: + - platform: gpio + name: "Expander Pin By Id" + pin: + xl9535: xl9535_a + number: 5 + mode: + input: true + - platform: gpio + name: "Expander Pin By Address" + pin: + xl9535: + address: 0x20 + number: 5 + mode: + input: true diff --git a/tests/unit_tests/test_pins.py b/tests/unit_tests/test_pins.py new file mode 100644 index 00000000000..18dc95a5c6c --- /dev/null +++ b/tests/unit_tests/test_pins.py @@ -0,0 +1,110 @@ +"""Unit tests for esphome/pins.py.""" + +import pytest + +from esphome import config_validation as cv, pins +import esphome.codegen as cg +from esphome.const import CONF_ADDRESS +from esphome.core import ID +from esphome.schema_extractors import SCHEMA_EXTRACT + +SomeHub = cg.esphome_ns.class_("SomeHub") + + +def test_use_id_or_address_with_explicit_id() -> None: + """An explicit id string still resolves exactly like cv.use_id.""" + validator = pins.use_id_or_address(SomeHub) + result = validator("my_hub") + + assert isinstance(result, ID) + assert result.id == "my_hub" + assert result.type is SomeHub + assert result.is_declaration is False + assert result.match_config is None + + +def test_use_id_or_address_with_omitted_id() -> None: + """Omitting the id (None) still falls back to the single-instance auto-pick.""" + validator = pins.use_id_or_address(SomeHub) + result = validator(None) + + assert isinstance(result, ID) + assert result.id is None + assert result.type is SomeHub + assert result.match_config is None + + +def test_use_id_or_address_with_address_mapping() -> None: + """A {address: ...} mapping produces an unnamed ID with match_config set.""" + validator = pins.use_id_or_address(SomeHub) + result = validator({CONF_ADDRESS: 0x21}) + + assert isinstance(result, ID) + assert result.id is None + assert result.type is SomeHub + assert result.is_declaration is False + assert result.match_config == {CONF_ADDRESS: 0x21} + + +def test_use_id_or_address_with_string_address() -> None: + """A hex string address is normalized the same way cv.i2c_address does.""" + validator = pins.use_id_or_address(SomeHub) + result = validator({CONF_ADDRESS: "0x21"}) + + assert result.match_config == {CONF_ADDRESS: 0x21} + + +def test_use_id_or_address_mapping_requires_address_key() -> None: + """A mapping without the address key is rejected, not silently ignored.""" + validator = pins.use_id_or_address(SomeHub) + + with pytest.raises(cv.Invalid): + validator({}) + + +def test_use_id_or_address_mapping_rejects_invalid_address() -> None: + """An out-of-range address is still validated by cv.i2c_address.""" + validator = pins.use_id_or_address(SomeHub) + + with pytest.raises(cv.Invalid): + validator({CONF_ADDRESS: 0x1FF}) + + +def test_use_id_or_address_custom_address_key() -> None: + """A custom address_key is honored both in the mapping and match_config.""" + validator = pins.use_id_or_address(SomeHub, address_key="i2c_address") + result = validator({"i2c_address": 0x10}) + + assert result.match_config == {"i2c_address": 0x10} + + +def test_use_id_or_address_marks_address_match_as_manual() -> None: + """The resolved id must survive strip_default_ids(), unlike a plain + omitted-id auto-pick, since the user explicitly gave selection criteria.""" + validator = pins.use_id_or_address(SomeHub) + result = validator({CONF_ADDRESS: 0x21}) + + assert result.is_manual is True + + +def test_use_id_or_address_schema_extract_passthrough() -> None: + """Called with the SCHEMA_EXTRACT sentinel (as build_language_schema.py + does), the wrapper must fall through to cv.use_id's own handling and + return the hub type, not try to treat it as an address mapping.""" + validator = pins.use_id_or_address(SomeHub) + + assert validator(SCHEMA_EXTRACT) is SomeHub + + +def test_use_id_or_address_registers_as_use_id_schema(monkeypatch) -> None: + """The wrapper must be discoverable by build_language_schema.py as a + `use_id` schema (like a bare cv.use_id), or the generated language schema + silently drops hub-id completion for every pin schema using it.""" + from esphome import schema_extractors + + monkeypatch.setattr(schema_extractors, "EnableSchemaExtraction", True) + monkeypatch.setattr(schema_extractors, "hidden_schemas", {}) + + validator = pins.use_id_or_address(SomeHub) + + assert schema_extractors.hidden_schemas.get(repr(validator)) == "use_id" From 2560a4436e574fadac0b159377ca6bae022991a1 Mon Sep 17 00:00:00 2001 From: TheSwert Date: Wed, 7 Oct 2026 15:28:10 -0400 Subject: [PATCH 016/435] [audio_http][core] Add ca_certificate_path to audio_http media source (#19392) --- .../audio_http/audio_http_media_source.cpp | 9 +++ .../audio_http/audio_http_media_source.h | 8 +++ esphome/components/audio_http/media_source.py | 53 ++++++++++++++ esphome/core/defines.h | 1 + .../audio_http/test-custom-ca.esp32-idf.yaml | 6 ++ .../audio_http/test-inherit-ca.esp32-idf.yaml | 13 ++++ tests/components/audio_http/test_ca.pem | 10 +++ .../unit_tests/components/test_audio_http.py | 71 +++++++++++++++++++ 8 files changed, 171 insertions(+) create mode 100644 tests/components/audio_http/test-custom-ca.esp32-idf.yaml create mode 100644 tests/components/audio_http/test-inherit-ca.esp32-idf.yaml create mode 100644 tests/components/audio_http/test_ca.pem create mode 100644 tests/unit_tests/components/test_audio_http.py diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index fb8620f7d9a..6cea4d73115 100644 --- a/esphome/components/audio_http/audio_http_media_source.cpp +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -33,6 +33,9 @@ void AudioHTTPMediaSource::dump_config() { " Persistent Ring Buffer: %s\n" " Decoder Task Stack in PSRAM: %s", this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_)); +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + ESP_LOGCONFIG(TAG, " Custom CA Certificate: %s", YESNO(this->http_ca_certificate_ != nullptr)); +#endif } void AudioHTTPMediaSource::setup() { @@ -52,6 +55,12 @@ void AudioHTTPMediaSource::setup() { config.reader_stack_size = READER_TASK_STACK_SIZE; config.decoder_stack_size = DECODER_TASK_STACK_SIZE; config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_; +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + // micro-decoder verifies HTTPS against this PEM only, skipping the built-in certificate bundle. + if (this->http_ca_certificate_ != nullptr) { + config.http_ca_certificate = this->http_ca_certificate_; + } +#endif this->decoder_ = std::make_unique(config); if (this->decoder_ == nullptr) { diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index a97025e53e8..e75dcf7a678 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -34,6 +34,11 @@ class AudioHTTPMediaSource final : public Component, void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; } +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + /// PEM-encoded CA certificate(s) used as the sole trust anchor for HTTPS playback URLs, + /// replacing the built-in certificate bundle. + void set_http_ca_certificate(const char *ca_certificate) { this->http_ca_certificate_ = ca_certificate; } +#endif // MediaSource interface implementation bool play_uri(const std::string &uri) override; @@ -56,6 +61,9 @@ class AudioHTTPMediaSource final : public Component, std::atomic pause_{false}; bool decoder_task_stack_in_psram_{false}; bool persistent_ring_buffer_{false}; +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + const char *http_ca_certificate_{nullptr}; +#endif }; } // namespace esphome::audio_http diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 14543957e95..3c23517b92f 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -1,13 +1,22 @@ +import logging +from pathlib import Path + import esphome.codegen as cg from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@kahrendt"] AUTO_LOAD = ["audio"] CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer" +CONF_CA_CERTIFICATE_PATH = "ca_certificate_path" +CONF_HTTP_REQUEST = "http_request" +CONF_VERIFY_SSL = "verify_ssl" audio_http_ns = cg.esphome_ns.namespace("audio_http") AudioHTTPMediaSource = audio_http_ns.class_( @@ -20,6 +29,38 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config +def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType: + # Default to the CA certificate configured on the http_request component so + # HTTPS playback verifies against the same trust anchor without repeating + # the option on every source. Needed because audio_http sources are often + # declared by device packages and cannot be extended from the device config. + # The PEM replaces the built-in certificate bundle as the sole trust anchor, + # so make the inheritance visible in the log. + if CONF_CA_CERTIFICATE_PATH in config: + return config + fconf = fv.full_config.get() + if CONF_HTTP_REQUEST not in fconf: + return config + http_request_config = fconf[CONF_HTTP_REQUEST] + # Mirror http_request's own semantics: it only applies its CA when SSL + # verification is enabled, so neither does the inheritance. + if not http_request_config.get(CONF_VERIFY_SSL, True): + return config + if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH): + _LOGGER.info( + "audio_http source '%s' is inheriting ca_certificate_path from the " + "http_request component; HTTPS playback will verify against that PEM " + "instead of the built-in certificate bundle", + config[CONF_ID].id, + ) + # Already validated by http_request's schema; the value is a resolved Path. + config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path + return config + + +FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path + + CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioHTTPMediaSource, @@ -31,6 +72,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean, + cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.file_, } ) .extend(cv.COMPONENT_SCHEMA), @@ -47,5 +89,16 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER])) + + # Embed the certificate content, like http_request does. Passed to + # micro_decoder's DecoderConfig::http_ca_certificate, which then uses it as + # the sole trust anchor for HTTPS playback URLs instead of the certificate + # bundle. + if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH): + cg.add_define("USE_AUDIO_HTTP_CA_CERTIFICATE") + with Path(ca_cert_path).open(encoding="utf-8") as f: + ca_cert_content = f.read() + cg.add(var.set_http_ca_certificate(ca_cert_content)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c8a10f3d2d4..0a1d16a2b97 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -256,6 +256,7 @@ #define AUDIO_FILE_MAX_FILES 4 #define USE_AUDIO_DAC #define USE_AUDIO_FLAC_SUPPORT +#define USE_AUDIO_HTTP_CA_CERTIFICATE #define USE_AUDIO_MP3_SUPPORT #define USE_AUDIO_OPUS_SUPPORT #define USE_AUDIO_WAV_SUPPORT diff --git a/tests/components/audio_http/test-custom-ca.esp32-idf.yaml b/tests/components/audio_http/test-custom-ca.esp32-idf.yaml new file mode 100644 index 00000000000..86eececf66b --- /dev/null +++ b/tests/components/audio_http/test-custom-ca.esp32-idf.yaml @@ -0,0 +1,6 @@ +media_source: + - platform: audio_http + id: audio_http_ca_source + ca_certificate_path: $component_dir/test_ca.pem + +<<: !include common.yaml diff --git a/tests/components/audio_http/test-inherit-ca.esp32-idf.yaml b/tests/components/audio_http/test-inherit-ca.esp32-idf.yaml new file mode 100644 index 00000000000..0bf22bc46d1 --- /dev/null +++ b/tests/components/audio_http/test-inherit-ca.esp32-idf.yaml @@ -0,0 +1,13 @@ +# http_request requires a network component; audio_http's common.yaml declares none. +wifi: + ssid: MySSID + password: password1 + +http_request: + ca_certificate_path: $component_dir/test_ca.pem + +media_source: + - platform: audio_http + id: audio_http_inherit_ca_source + +<<: !include common.yaml diff --git a/tests/components/audio_http/test_ca.pem b/tests/components/audio_http/test_ca.pem new file mode 100644 index 00000000000..30cbc1a3c40 --- /dev/null +++ b/tests/components/audio_http/test_ca.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu +dXNlZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5mMUB1hOgLmlnXtsvcGMP +XkhAqZaR0dDPW5OS8VEopWLJCX9Y0cvNCqiDI8cnP8pP8XJGU1hGLvA5PJzWnWZz +AgMBAAGjUzBRMB0GA1UdDgQWBBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAfBgNVHSME +GDAWgBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAPBgNVHRMBAf8EBTADAQH/MA0GCSqG +SIb3DQEBCwUAA0EAKqZFf6+f8FPDbKyPCpssquojgn7fEXqr/I/yz0R5CowGdMms +H3WH3aKP4lLSHdPTBtfIoJi3gEIZjFxp3S1TWw== +-----END CERTIFICATE----- diff --git a/tests/unit_tests/components/test_audio_http.py b/tests/unit_tests/components/test_audio_http.py new file mode 100644 index 00000000000..94355c09c98 --- /dev/null +++ b/tests/unit_tests/components/test_audio_http.py @@ -0,0 +1,71 @@ +"""Tests for the audio_http media source's ca_certificate_path inheritance.""" + +from pathlib import Path + +from esphome.components.audio_http.media_source import _inherit_ca_certificate_path +from esphome.core import ID +import esphome.final_validate as fv +from esphome.types import ConfigType + + +def _source_config() -> ConfigType: + return {"id": ID("audio_http_source")} + + +def _run(full_config: dict, config: ConfigType) -> ConfigType: + token = fv.full_config.set(full_config) + try: + return _inherit_ca_certificate_path(config) + finally: + fv.full_config.reset(token) + + +def test_explicit_option_wins() -> None: + """A source with its own ca_certificate_path is left untouched.""" + config = {"id": ID("s"), "ca_certificate_path": Path("own.pem")} + result = _run( + {"http_request": {"verify_ssl": True, "ca_certificate_path": Path("hr.pem")}}, + config, + ) + assert result["ca_certificate_path"] == Path("own.pem") + + +def test_inherited_from_http_request() -> None: + """Without its own option, the source picks up http_request's CA path.""" + result = _run( + { + "http_request": { + "verify_ssl": True, + "ca_certificate_path": Path("hr.pem"), + } + }, + _source_config(), + ) + assert result["ca_certificate_path"] == Path("hr.pem") + + +def test_no_http_request_component() -> None: + """No http_request block means no inheritance.""" + result = _run({}, _source_config()) + assert "ca_certificate_path" not in result + + +def test_http_request_without_ca() -> None: + """An http_request block without ca_certificate_path means no inheritance.""" + result = _run({"http_request": {"verify_ssl": True}}, _source_config()) + assert "ca_certificate_path" not in result + + +def test_not_inherited_when_verify_ssl_disabled() -> None: + """verify_ssl: false disables the CA for http_request itself, so the + inheritance must skip it too instead of silently pinning playback.""" + result = _run( + { + "http_request": { + "verify_ssl": False, + "ca_certificate_path": Path("hr.pem"), + } + }, + _source_config(), + ) + assert "ca_certificate_path" not in result From 887e3c302fbb1d40f0ecaa3f7fe09682f86490e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:30:26 +0000 Subject: [PATCH 017/435] Bump filelock from 4.0.9 to 4.0.10 (#20298) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 988bd620005..c0f8ebc546a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.2 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.9 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.0.10 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 82cffe81f35341095a3adbca6f260ee729993e7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:30:59 +0000 Subject: [PATCH 018/435] Bump platformdirs from 4.12.2 to 4.12.3 (#20297) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c0f8ebc546a..cd952b57526 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.3.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.2 # native esp-idf toolchain global cache dir +platformdirs==4.12.3 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.10 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 6f940a486b946eae3b0ffb30c5a95d1aa6a49ed9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:49:31 +1300 Subject: [PATCH 019/435] [api] Add a device wizard for Home Assistant onboarding (#19966) Co-authored-by: J. Nick Koston --- esphome/codegen.py | 1 + esphome/components/api/__init__.py | 13 + esphome/components/api/api.proto | 91 ++ esphome/components/api/api_connection.cpp | 18 +- esphome/components/api/api_connection.h | 16 + esphome/components/api/api_pb2.cpp | 51 + esphome/components/api/api_pb2.h | 66 +- esphome/components/api/api_pb2_dump.cpp | 27 + esphome/components/api/api_pb2_service.cpp | 20 + esphome/components/api/api_pb2_service.h | 7 + esphome/components/api/api_wizard.cpp | 104 ++ esphome/components/api/api_wizard.h | 62 ++ esphome/components/api/wizard.py | 461 +++++++++ esphome/components/homeassistant/__init__.py | 38 +- .../homeassistant/binary_sensor/__init__.py | 3 + .../homeassistant/button/__init__.py | 3 + .../button/homeassistant_button.cpp | 7 + .../homeassistant/number/__init__.py | 3 + .../number/homeassistant_number.cpp | 7 + .../homeassistant/select/__init__.py | 3 + .../select/homeassistant_select.cpp | 7 + .../homeassistant/sensor/__init__.py | 3 + .../homeassistant/switch/__init__.py | 3 + .../switch/homeassistant_switch.cpp | 7 + .../components/homeassistant/text/__init__.py | 3 + .../homeassistant/text/homeassistant_text.cpp | 7 + .../homeassistant/text_sensor/__init__.py | 3 + esphome/core/defines.h | 6 + esphome/cpp_generator.py | 17 + requirements.txt | 1 + tests/component_tests/api/test_wizard.py | 942 ++++++++++++++++++ tests/components/api/__init__.py | 12 + tests/components/api/common-wizard.yaml | 108 ++ .../components/api/test-wizard.esp32-idf.yaml | 6 + .../api/test-wizard.esp8266-ard.yaml | 6 + tests/components/api/test-wizard.host.yaml | 4 + tests/components/api/test_wizard.cpp | 150 +++ .../components/api/test_api_proto.py | 87 ++ tests/unit_tests/test_cpp_generator.py | 8 + 39 files changed, 2372 insertions(+), 9 deletions(-) create mode 100644 esphome/components/api/api_wizard.cpp create mode 100644 esphome/components/api/api_wizard.h create mode 100644 esphome/components/api/wizard.py create mode 100644 tests/component_tests/api/test_wizard.py create mode 100644 tests/components/api/common-wizard.yaml create mode 100644 tests/components/api/test-wizard.esp32-idf.yaml create mode 100644 tests/components/api/test-wizard.esp8266-ard.yaml create mode 100644 tests/components/api/test-wizard.host.yaml create mode 100644 tests/components/api/test_wizard.cpp diff --git a/esphome/codegen.py b/esphome/codegen.py index daf53db43fd..e7c08048b9f 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -31,6 +31,7 @@ from esphome.cpp_generator import ( # noqa: F401 add_global, add_library, add_platformio_option, + extern_progmem_array, get_variable, get_variable_with_full_id, is_template, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9b65f052cc3..ff3e215a404 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -57,6 +57,8 @@ from esphome.helpers import fnv1_hash from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType +from . import wizard + # Compat alias: downstream consumers (e.g. device-builder) referenced the # schema by its old private name before it moved to the noise component _encryption_schema = encryption_schema @@ -389,6 +391,7 @@ CONFIG_SCHEMA = cv.All( ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(wizard.CONF_WIZARD): wizard.WIZARD_SCHEMA, cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema, cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, @@ -520,9 +523,15 @@ def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: return config +def _validate_wizard(config: ConfigType) -> ConfigType: + wizard.final_validate(config) + return config + + FINAL_VALIDATE_SCHEMA = cv.All( _validate_esp8266_action_strings, _validate_outgoing_host_ipv6, + _validate_wizard, ) @@ -678,6 +687,9 @@ async def to_code(config: ConfigType) -> None: # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) + if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None: + await wizard.to_code(wizard_config) + if CONF_ON_CLIENT_CONNECTED in config: cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER") await automation.build_automation( @@ -1106,6 +1118,7 @@ _define_filter = filter_source_files_from_defines( "user_services.cpp": "USE_API_USER_DEFINED_ACTIONS", "api_frame_helper_noise.cpp": "USE_API_NOISE", "api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT", + "api_wizard.cpp": "USE_API_WIZARD", "api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION", } ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4fce8d7b7f3..2648b5d0d88 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -20,6 +20,8 @@ service APIConnection { option (needs_authentication) = false; } rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} + rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {} + rpc wizard_input_set (WizardInputSetRequest) returns (void) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -391,6 +393,11 @@ message ZWaveProxyCapabilities { uint32 home_id = 2; } +message WizardCapabilities { + // True when the device has a wizard configured, so DeviceWizardRequest will be answered + bool configured = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -400,6 +407,90 @@ message DeviceCapabilitiesResponse { ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; repeated SerialProxyInfo serial_proxies = 4 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; + WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; +} + +// ==================== DEVICE WIZARD ==================== + +// Asks the device for the onboarding wizard that Home Assistant presents when the +// device is added. +// +// The wizard is read once, when the device is added, whereas capabilities are read +// on every connect, so it is a separate message rather than part of +// DeviceCapabilitiesResponse. It is only served on an authenticated connection. +// +// A device without a wizard does not have this message at all and never answers it, +// as a device ignores messages it does not know. Clients therefore check +// DeviceCapabilitiesResponse.wizard.configured before sending it. +message DeviceWizardRequest { + option (id) = 156; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_WIZARD"; + // Empty +} + +// The wizard, as UTF-8 JSON compressed into a single zstd frame. The device builds it +// when it is compiled and sends it unchanged, so its size varies with the wizard and +// its format can grow without new fields here. Clients decompress it, then read: +// +// {"version": 1, +// "pages": [{"title": "...", "description": "...", +// "entities": [{"key": 123, "device_id": 456, "description": "..."}], +// "inputs": [{"key": 789, "description": "...", +// "entity_filters": [{"integration": "...", "domain": ["..."], +// "device_class": ["..."], "supported_features": ["..."]}]}]}]} +// +// Values that are empty or unset are left out, and so are empty lists. Strings may be +// Home Assistant translation placeholders such as "[%key:component::domain::name%]", +// passed through verbatim. The pages are shown in the order listed. A client that does +// not know the version must not use the wizard. +// +// An entity is an entity of this device that the page shows to the user (for example to +// turn it on or off). Its key is the key ListEntitiesResponse sends for it. Its +// device_id is the device_id ListEntitiesResponse sends, and is left out when that is 0. +// +// An input is filled in by the user with the id of a Home Assistant entity. It either +// stands for one entity of the device's homeassistant platform (sensor, binary_sensor, +// text_sensor, number, switch, text, select or button) that sets no entity_id of its own, +// as Home Assistant supplies it, or it is a standalone input that only stores the entity +// id for the device's own automations. Its key is the FNV-1 hash +// of the input's ESPHome id, not an entity key. The client sends the chosen entity id to +// the device with a WizardInputSetRequest. entity_filters says which Home Assistant +// entities the user may choose, and mirrors Home Assistant's EntityFilterSelectorConfig: +// an entity matches a filter when it satisfies every field that is set, and an entity +// that matches any one of the filters is accepted. +// +// The device keeps the entity id of an input in RAM only and does not store it across +// restarts. The client therefore stores the choice itself and, after every connection +// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest +// for each input. It sends one again whenever the user changes the choice. +message DeviceWizardResponse { + option (id) = 157; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_API_WIZARD"; + // Not logged: the data is in flash, which ESP8266 cannot read for a dump + option (log) = false; + + // The data is in flash, and the device sends it from there + bytes data = 1 [(pointer_to_buffer) = true]; +} + +// Sets the entity id of a wizard input (see WizardInputField.key). Clients send +// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no +// reply. The device ignores a request whose key matches no input or whose entity_id +// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// not across restarts, and when the input belongs to a homeassistant platform entity +// it subscribes to that entity's state. Clients send this for every input right +// after connecting, before SubscribeHomeAssistantStatesRequest. A request that +// arrives later still works: the device then sends the affected state subscriptions +// again. +message WizardInputSetRequest { + option (id) = 158; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_WIZARD_INPUTS"; + + fixed32 key = 1; + string entity_id = 2 [(max_data_length) = 255]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a5d91de5513..c5457d9482a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2085,6 +2085,9 @@ bool APIConnection::send_device_capabilities_response_() { info.port_type = proxy->get_port_type(); info.configured_line_states = proxy->get_configured_modem_pins(); } +#endif +#ifdef USE_API_WIZARD + resp.wizard.configured = true; #endif return this->send_message(resp); } @@ -2299,7 +2302,13 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; } +void APIConnection::on_subscribe_home_assistant_states_request() { +#ifdef USE_API_WIZARD_LINKED_INPUTS + // Remember it, as a client that subscribed also gets the subscriptions again when a wizard input is set + this->flags_.home_assistant_states = true; +#endif + state_subs_at_ = 0; +} #endif bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { delay(0); @@ -2742,6 +2751,13 @@ void APIConnection::process_state_subscriptions_() { } const auto &it = subs[this->state_subs_at_]; +#ifdef USE_API_WIZARD_LINKED_INPUTS + // An entity id that is not set yet (a wizard input) has nothing to subscribe to; it is sent once it is set + if (it.entity_id[0] == '\0') { + this->state_subs_at_++; + return; + } +#endif SubscribeHomeAssistantStateResponse resp; resp.entity_id = StringRef(it.entity_id); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index df9089f6237..84f8ec95970 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -279,6 +279,12 @@ class APIConnection final : public APIServerConnectionBase { void on_ping_request(); void on_device_info_request(); void on_device_capabilities_request(); +#ifdef USE_API_WIZARD + void on_device_wizard_request(); +#endif +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &msg); +#endif void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -308,6 +314,10 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(); +#ifdef USE_API_WIZARD_LINKED_INPUTS + /// Tell this client about the subscriptions whose entity id is stored in the given buffer, as the buffer changed + void resend_state_subscriptions(const char *entity_id); +#endif #endif #ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg); @@ -413,6 +423,9 @@ class APIConnection final : public APIServerConnectionBase { bool send_ping_response_(); bool send_device_info_response_(); bool send_device_capabilities_response_(); +#ifdef USE_API_WIZARD + bool send_device_wizard_response_(); +#endif #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif @@ -757,6 +770,9 @@ class APIConnection final : public APIServerConnectionBase { uint8_t batch_first_message : 1; // For batch buffer allocation uint8_t should_try_send_immediately : 1; // True after initial states are sent uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check +#ifdef USE_API_WIZARD_LINKED_INPUTS + uint8_t home_assistant_states : 1; // Client subscribed to Home Assistant states +#endif #ifdef USE_API_OUTGOING_CONNECTION uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b64b3492463..58ae9184e21 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -302,6 +302,20 @@ uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_API_WIZARD +uint8_t *WizardCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.configured); + return pos; +} +uint32_t WizardCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_bool(1, msg.configured); + return size; +} +#endif uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); @@ -318,6 +332,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, ProtoWriteBuff for (const auto &it : msg.serial_proxies) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } +#endif +#ifdef USE_API_WIZARD + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 5, msg.wizard); #endif return pos; } @@ -337,9 +354,40 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } +#endif +#ifdef USE_API_WIZARD + size += ProtoSize::calc_message(1, msg.wizard.calculate_size()); #endif return size; } +#ifdef USE_API_WIZARD +uint8_t *DeviceWizardResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + return pos; +} +uint32_t DeviceWizardResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_length(1, msg.data_len); + return size; +} +#endif +#ifdef USE_API_WIZARD_INPUTS +void WizardInputSetRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.entity_id = StringRef(value.data(), value.size()); + break; + } +} +#endif #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -4284,6 +4332,9 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { #ifndef HAS_PROTO_MESSAGE_DUMP static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_WIZARD_INPUTS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif #ifdef USE_COVER static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 91c218b16c4..faa9c979fcc 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -720,10 +720,27 @@ class ZWaveProxyCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_API_WIZARD +class WizardCapabilities final : public ProtoMessage { + public: + bool configured{false}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 102; + static constexpr uint8_t ESTIMATED_SIZE = 119; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -738,6 +755,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; +#endif +#ifdef USE_API_WIZARD + WizardCapabilities wizard{}; #endif static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -751,6 +771,50 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { protected: }; +#ifdef USE_API_WIZARD +class DeviceWizardResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 157; + static constexpr uint8_t ESTIMATED_SIZE = 19; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_wizard_response"); } +#endif + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_API_WIZARD_INPUTS +class WizardInputSetRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 158; + static constexpr uint8_t ESTIMATED_SIZE = 14; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("wizard_input_set_request"); } +#endif + uint32_t key{0}; + StringRef entity_id{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); +}; +#endif class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 19; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 79f9f360bd0..966ba8f2bf5 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1078,6 +1078,13 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_API_WIZARD +const char *WizardCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities")); + dump_field(out, ESPHOME_PSTR("configured"), this->configured); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1101,9 +1108,29 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); out.append("\n"); } +#endif +#ifdef USE_API_WIZARD + out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": "); + this->wizard.dump_to(out); + out.append("\n"); #endif return out.c_str(); } +#ifdef USE_API_WIZARD +const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse")); + dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); + return out.c_str(); +} +#endif +#ifdef USE_API_WIZARD_INPUTS +const char *WizardInputSetRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("WizardInputSetRequest")); + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("entity_id"), this->entity_id); + return out.c_str(); +} +#endif const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index afc99f93c83..1ef70f1fd4e 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -731,6 +731,26 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_subscribe_serial_proxy_identity_request(); break; } +#endif +#ifdef USE_API_WIZARD + case 156 /* DeviceWizardRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_wizard_request")); +#endif + this->on_device_wizard_request(); + break; + } +#endif +#ifdef USE_API_WIZARD_INPUTS + case WizardInputSetRequest::MESSAGE_TYPE: { + WizardInputSetRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_wizard_input_set_request"), msg); +#endif + this->on_wizard_input_set_request(msg); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index ea89984ac57..13fc03e03e7 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -29,6 +29,13 @@ class APIServerConnectionBase { void on_device_capabilities_request(){}; +#ifdef USE_API_WIZARD + void on_device_wizard_request(){}; +#endif + +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &value){}; +#endif void on_list_entities_request(){}; void on_subscribe_states_request(){}; diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp new file mode 100644 index 00000000000..7d4e8fdcea9 --- /dev/null +++ b/esphome/components/api/api_wizard.cpp @@ -0,0 +1,104 @@ +#include "api_wizard.h" + +#ifdef USE_API_WIZARD + +#include + +#include "api_connection.h" +#include "api_pb2.h" +#include "api_server.h" +#include "esphome/core/log.h" + +namespace esphome::api { + +static const char *const TAG = "api.wizard"; + +uint8_t *wizard_encode_response(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + if (msg.data_len == 0) + return pos; + pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len); + PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len); + progmem_memcpy(pos, msg.data, msg.data_len); + return pos + msg.data_len; +} + +#ifdef USE_API_WIZARD_INPUTS +static bool wizard_entity_id_valid(const char *entity_id, size_t length) { + return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; +} + +const char *wizard_set_input(const WizardInputSetRequest &msg) { + if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { + ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); + return nullptr; + } + for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { + // The table is in flash, which ESP8266 can only read through progmem_memcpy + WizardInputEntry entry; + progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); + if (entry.key != msg.key) + continue; + memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); + entry.entity_id[msg.entity_id.size()] = '\0'; + return entry.entity_id; + } + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + return nullptr; +} +#endif // USE_API_WIZARD_INPUTS + +bool APIConnection::send_device_wizard_response_() { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = API_WIZARD_DATA_SIZE; + // Not send_message: the data is in flash, so wizard_encode_response copies it out + return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE, + &wizard_encode_response, &resp); +} + +void APIConnection::on_device_wizard_request() { + if (!this->send_device_wizard_response_()) { + this->on_fatal_error(); + } +} + +#ifdef USE_API_WIZARD_INPUTS +void APIConnection::on_wizard_input_set_request(const WizardInputSetRequest &msg) { + const char *entity_id = wizard_set_input(msg); + if (entity_id == nullptr) + return; +#ifdef USE_API_WIZARD_LINKED_INPUTS + // Entities subscribed to the buffer before it held an entity id, so every client needs to learn of it now + for (auto &client : this->parent_->active_clients()) { + client->resend_state_subscriptions(entity_id); + } +#endif +} +#endif + +#ifdef USE_API_WIZARD_LINKED_INPUTS +void APIConnection::resend_state_subscriptions(const char *entity_id) { + if (!this->flags_.home_assistant_states) + return; + for (const auto &it : this->parent_->get_state_subs()) { + if (it.entity_id != entity_id) + continue; + SubscribeHomeAssistantStateResponse resp; + resp.entity_id = StringRef(it.entity_id); + resp.attribute = it.attribute != nullptr ? StringRef(it.attribute) : StringRef(""); + resp.once = it.once; + if (!this->send_message(resp)) { + // Could not send now: send every subscription again from the loop + this->state_subs_at_ = 0; + return; + } + } +} +#endif + +} // namespace esphome::api + +#endif // USE_API_WIZARD diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h new file mode 100644 index 00000000000..0ba204d3b72 --- /dev/null +++ b/esphome/components/api/api_wizard.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_API_WIZARD + +#include +#include + +#include "esphome/core/hal.h" +#include "proto.h" +#include "esphome/core/string_ref.h" + +namespace esphome::api { + +class WizardInputSetRequest; + +/// Size of the buffer holding the entity id of a wizard input. Home Assistant entity ids are at most 255 bytes. +static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256; + +/// The wizard as zstd compressed JSON (see DeviceWizardResponse), built by the generated code +/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long. +extern const uint8_t API_WIZARD_DATA[] PROGMEM; + +/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read +/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere. +uint8_t *wizard_encode_response(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + +#ifdef USE_API_WIZARD_INPUTS +/// Where the entity id of an input is kept, found by the key the client uses for it. +struct WizardInputEntry { + uint32_t key; // FNV-1 hash of the ESPHome id of the input + char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity +}; + +/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. +extern const WizardInputEntry API_WIZARD_INPUTS[] PROGMEM; + +/// Apply a WizardInputSetRequest: validate it and copy the entity id into the input's buffer. Nothing is stored +/// across restarts, so the client sends the choices again after every connection. +/// Returns the buffer, or nullptr when the request was ignored. +const char *wizard_set_input(const WizardInputSetRequest &msg); +#endif + +#ifdef USE_API_WIZARD_STANDALONE_INPUTS +/// An input of the wizard that is not tied to an entity of the device. The entity ID the user picks is only ever read +/// by lambdas, for example `id(input).entity_id()`. It lives in the same RAM buffer a linked input uses. +class WizardInput { + public: + explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} + /// The Home Assistant entity ID, empty until the wizard sets one. + StringRef entity_id() const { return StringRef(this->entity_id_); } + bool has_entity_id() const { return this->entity_id_[0] != '\0'; } + + protected: + const char *entity_id_; +}; +#endif + +} // namespace esphome::api + +#endif // USE_API_WIZARD diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py new file mode 100644 index 00000000000..314b907e4d0 --- /dev/null +++ b/esphome/components/api/wizard.py @@ -0,0 +1,461 @@ +"""The device wizard of the api component: schema, validation and code generation. + +Home Assistant shows the wizard when the device is added. See api_wizard.h for the C++ side. +""" + +from collections.abc import Callable +import importlib +import json +from types import ModuleType +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_DESCRIPTION +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_ID, + CONF_INTERNAL, + CONF_NAME, + CONF_PAGES, + CONF_PLATFORM, + CONF_TARGET, +) +from esphome.core import CORE, ID +import esphome.final_validate as fv +from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash +from esphome.types import ConfigType + +API_DOMAIN = "api" + +CONF_ENTITIES = "entities" +CONF_ENTITY = "entity" +CONF_INPUTS = "inputs" +CONF_INTEGRATION = "integration" +CONF_SUPPORTED_FEATURES = "supported_features" +CONF_TITLE = "title" +CONF_WIZARD = "wizard" + +_API = cg.esphome_ns.namespace("api") +WizardInput = _API.class_("WizardInput") + +WIZARD_ENTITY_ID_BUFFER_SIZE = ( + 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes +) +# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, +# Noise) and footer (16 bytes, Noise MAC) +WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 +# Version of the JSON the wizard is sent as, see wizard_document() +WIZARD_JSON_VERSION = 1 +# zstd level the JSON is compressed with. Output is deterministic for a given zstd version. +WIZARD_ZSTD_LEVEL = 19 + +# Wizard string limits; api.proto documents the same values +WIZARD_TITLE_MAX_LENGTH = 127 +WIZARD_DESCRIPTION_MAX_LENGTH = 255 +WIZARD_FILTER_MAX_LENGTH = 63 +WIZARD_SUPPORTED_FEATURE_MAX_LENGTH = 127 + + +def _wizard_text(max_length: int) -> Callable[[Any], str]: + """A string passed to Home Assistant verbatim, so it may be a [%key:...%] translation placeholder.""" + return cv.All(cv.string_strict, cv.Length(max=max_length)) + + +def _wizard_strings(max_length: int) -> Callable[[Any], list[str]]: + """A single string or a list of strings, always validated to a non-empty list.""" + return cv.All( + cv.ensure_list(cv.All(cv.string_strict, cv.Length(min=1, max=max_length))), + cv.Length(min=1), + ) + + +# Mirrors Home Assistant's EntityFilterSelectorConfig +WIZARD_ENTITY_FILTER_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_INTEGRATION): cv.All( + cv.string_strict, cv.Length(min=1, max=WIZARD_FILTER_MAX_LENGTH) + ), + cv.Optional(CONF_DOMAIN): _wizard_strings(WIZARD_FILTER_MAX_LENGTH), + cv.Optional(CONF_DEVICE_CLASS): _wizard_strings(WIZARD_FILTER_MAX_LENGTH), + cv.Optional(CONF_SUPPORTED_FEATURES): _wizard_strings( + WIZARD_SUPPORTED_FEATURE_MAX_LENGTH + ), + } + ), + cv.has_at_least_one_key( + CONF_INTEGRATION, CONF_DOMAIN, CONF_DEVICE_CLASS, CONF_SUPPORTED_FEATURES + ), +) + +WIZARD_ENTITY_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(cg.EntityBase), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + } +) + + +# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no +# entity_id of its own: Home Assistant sets it +WIZARD_INPUT_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_ID): cv.declare_id(WizardInput), + cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_TARGET): cv.Schema( + { + cv.Required(CONF_ENTITY): cv.All( + cv.ensure_list(WIZARD_ENTITY_FILTER_SCHEMA), cv.Length(min=1) + ), + } + ), + } + ), + cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), +) + +WIZARD_PAGE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_TITLE): _wizard_text(WIZARD_TITLE_MAX_LENGTH), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_ENTITIES): cv.All( + cv.ensure_list(WIZARD_ENTITY_SCHEMA), cv.Length(min=1) + ), + cv.Optional(CONF_INPUTS): cv.All( + cv.ensure_list(WIZARD_INPUT_SCHEMA), cv.Length(min=1) + ), + } + ), + cv.has_at_least_one_key(CONF_ENTITIES, CONF_INPUTS), +) + + +def _wizard_inputs(wizard: ConfigType) -> list[ConfigType]: + return [conf for page in wizard[CONF_PAGES] for conf in page.get(CONF_INPUTS, [])] + + +def _wizard_input_id(conf: ConfigType) -> ID: + """The ID that names an input: the one a standalone input declares, or its linked entity.""" + return conf[CONF_ID] if CONF_ID in conf else conf[CONF_ENTITY] + + +def _validate_unique_wizard_inputs(wizard: ConfigType) -> ConfigType: + """An input is identified on the wire by a hash of its ID, so IDs and hashes must be unique.""" + seen: dict[int, str] = {} + for conf in _wizard_inputs(wizard): + input_id = _wizard_input_id(conf).id + if (key := fnv1_hash(input_id)) in seen: + if seen[key] == input_id: + raise cv.Invalid(f"Wizard input '{input_id}' is used more than once") + raise cv.Invalid( + f"Wizard inputs '{seen[key]}' and '{input_id}' have the same hash, rename one" + ) + seen[key] = input_id + return wizard + + +WIZARD_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PAGES): cv.All( + cv.ensure_list(WIZARD_PAGE_SCHEMA), cv.Length(min=1) + ), + } + ), + _validate_unique_wizard_inputs, +) + +# Platforms of the homeassistant component that a wizard input can stand for +WIZARD_INPUT_DOMAINS = ( + "binary_sensor", + "button", + "number", + "select", + "sensor", + "switch", + "text", + "text_sensor", +) +# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within +WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text") + + +def wizard_input_ids(api_config: ConfigType) -> set[str]: + """The IDs of the entities that are linked inputs of the wizard in the given api config.""" + if (wizard := api_config.get(CONF_WIZARD)) is None: + return set() + return { + conf[CONF_ENTITY].id for conf in _wizard_inputs(wizard) if CONF_ENTITY in conf + } + + +def _wizard_buffer_name(entity_id: ID) -> str: + return f"api_wizard_input_{entity_id.id}" + + +def wizard_input_buffer(entity_id: ID) -> str | None: + """Name of the RAM buffer holding the Home Assistant entity ID of a wizard input. + + Returns None when the entity is not an input of the wizard. The buffer is defined by the api + codegen, and the homeassistant entity of the input is given it in place of a constant. + """ + if entity_id.id not in wizard_input_ids(CORE.config.get(API_DOMAIN, {})): + return None + return _wizard_buffer_name(entity_id) + + +def _wizard_input_declaration( + config: fv.FinalValidateConfig, entity_id: ID +) -> tuple[str, ConfigType]: + """The domain (like sensor) and the config an input ID is declared in.""" + path = config.get_path_for_id(entity_id)[:-1] + return path[0], config.get_config_for_path(path) + + +def _wizard_input_filters( + conf: ConfigType, config: fv.FinalValidateConfig +) -> list[ConfigType]: + """The entity filters of an input, with the defaults of a domain limited linked platform.""" + if (filters := conf.get(CONF_TARGET, {}).get(CONF_ENTITY)) is not None: + return filters + if CONF_ENTITY in conf: + domain, _ = _wizard_input_declaration(config, conf[CONF_ENTITY]) + if domains := _wizard_default_domains(domain): + return [{CONF_DOMAIN: domains}] + return [] + + +def _wizard_defines(wizard: ConfigType) -> set[str]: + """The defines for the parts of the wizard the configuration uses, so the rest is not compiled.""" + inputs = _wizard_inputs(wizard) + defines = {"USE_API_WIZARD"} + if inputs: + defines.add("USE_API_WIZARD_INPUTS") + if any(CONF_ENTITY in conf for conf in inputs): + defines.add("USE_API_WIZARD_LINKED_INPUTS") + if any(CONF_ID in conf for conf in inputs): + defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + return defines + + +def _wizard_default_domains(domain: str) -> list[str] | None: + """The domains Home Assistant entities can be picked from when the input sets no target.""" + if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS: + platform = importlib.import_module(f"esphome.components.homeassistant.{domain}") + return list(platform.SUPPORTED_DOMAINS) + return None + + +def _validate_wizard_input(conf: ConfigType) -> ConfigType: + if CONF_ENTITY not in conf: + return conf + domain, declaration = _wizard_input_declaration( + fv.full_config.get(), conf[CONF_ENTITY] + ) + if ( + declaration.get(CONF_PLATFORM) != "homeassistant" + or domain not in WIZARD_INPUT_DOMAINS + ): + raise cv.Invalid( + f"Wizard input '{conf[CONF_ENTITY].id}' must be a homeassistant " + f"{', '.join(WIZARD_INPUT_DOMAINS)} entity" + ) + if CONF_ENTITY_ID in declaration: + # An entity_id in the configuration is a static entry, which is kept apart from the dynamic ones + raise cv.Invalid( + f"'{conf[CONF_ENTITY].id}' has an entity_id set in its configuration, so it cannot be a " + "wizard input. Remove entity_id to let Home Assistant set it through the wizard." + ) + if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS: + supported = _wizard_default_domains(domain) + for entity_filter in conf.get(CONF_TARGET, {}).get(CONF_ENTITY, []): + if not (domains := entity_filter.get(CONF_DOMAIN)): + raise cv.Invalid( + f"Every filter of a homeassistant {domain} input must set domain" + ) + if unsupported := [d for d in domains if d not in supported]: + raise cv.Invalid( + f"The homeassistant {domain} does not support the domain(s) " + f"{', '.join(unsupported)}. Supported: {', '.join(supported)}" + ) + return conf + + +def _validate_wizard_entity_exposed(value: ID) -> ID: + """Reject entities that are internal, as they are not exposed over the API, or have no name. + + The key a client knows an entity by is a hash of its name. Without a name of its own, the + device works the name out at runtime from its friendly name, which can add the MAC address, + so the key cannot be known when the wizard is built. + """ + _, declaration = _wizard_input_declaration(fv.full_config.get(), value) + if declaration.get(CONF_INTERNAL, False): + raise cv.Invalid( + f"Entity '{value.id}' is internal, so it is not exposed over the API " + "and cannot be used in the wizard" + ) + if not declaration.get(CONF_NAME): + raise cv.Invalid( + f"Entity '{value.id}' has no name of its own, so its key is not known " + "when the wizard is built. Give it a name to use it in the wizard" + ) + return value + + +_WIZARD_FINAL_VALIDATE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WIZARD): { + cv.Optional(CONF_PAGES): [ + { + cv.Optional(CONF_ENTITIES): [ + {cv.Optional(CONF_ID): _validate_wizard_entity_exposed} + ], + cv.Optional(CONF_INPUTS): [_validate_wizard_input], + } + ] + } + }, + extra=cv.ALLOW_EXTRA, +) + + +def final_validate(config: ConfigType) -> None: + """Final validation of the wizard in the given api config, if it has one.""" + _WIZARD_FINAL_VALIDATE_SCHEMA(config) + if (wizard := config.get(CONF_WIZARD)) is not None: + size = len(wizard_blob(wizard, fv.full_config.get())) + if size > WIZARD_RESPONSE_MAX_SIZE: + raise cv.Invalid( + f"The compressed wizard is {size} bytes, {size - WIZARD_RESPONSE_MAX_SIZE} " + f"bytes over the {WIZARD_RESPONSE_MAX_SIZE} bytes one API message can hold. " + "Shorten the texts or use fewer pages, entities or filters", + path=[CONF_WIZARD], + ) + + +WIZARD_INPUT_IS_SET_SCHEMA = cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(WizardInput)}, key=CONF_ID +) + +# Only for standalone inputs: a linked input is read through its homeassistant entity +automation.register_apply_condition( + "api.wizard.input_is_set", WIZARD_INPUT_IS_SET_SCHEMA, "has_entity_id()" +) + + +def zstd_module() -> ModuleType: + """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" + try: + return importlib.import_module("compression.zstd") + except ImportError: + return importlib.import_module("backports.zstd") + + +def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + """An entity of the device that the page shows, keyed as ListEntitiesResponse keys it.""" + _, declaration = _wizard_input_declaration(config, conf[CONF_ID]) + document: ConfigType = {"key": fnv1_hash_object_id(declaration[CONF_NAME])} + if (device := declaration.get(CONF_DEVICE_ID)) is not None: + document["device_id"] = fnv1a_32bit_hash(device.id) + if description := conf.get(CONF_DESCRIPTION): + document[CONF_DESCRIPTION] = description + return document + + +def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} + if description := conf.get(CONF_DESCRIPTION): + document[CONF_DESCRIPTION] = description + if filters := _wizard_input_filters(conf, config): + document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] + return document + + +def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + """The wizard as the JSON document the device sends, before it is serialised. + + This is the format Home Assistant reads, and api.proto documents it for clients. Version 1: + + {"version": 1, + "pages": [{"title": "...", "description": "...", + "entities": [{"key": 123, "device_id": 456, "description": "..."}], + "inputs": [{"key": 789, "description": "...", + "entity_filters": [{"integration": "...", "domain": ["..."], + "device_class": ["..."], "supported_features": ["..."]}]}]}]} + + Anything empty or unset, and every empty list, is left out. Strings are passed through as + written, so they may be Home Assistant translation placeholders. + + - An entity key is the key ListEntitiesResponse sends for the entity: the FNV-1 hash of the + object id made from its name (entity_helpers). device_id is the hash of the ESPHome id of + the device it belongs to (esphome/core/config.py), and is left out for the main device. + - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked + entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - entity_filters are the filters of the input, or the default filters of a linked switch, + number, text, select or button. + """ + pages: list[ConfigType] = [] + for page in wizard[CONF_PAGES]: + document: ConfigType = {} + for key in (CONF_TITLE, CONF_DESCRIPTION): + if value := page.get(key): + document[key] = value + if entities := [ + _entity_document(e, config) for e in page.get(CONF_ENTITIES, []) + ]: + document[CONF_ENTITIES] = entities + if inputs := [_input_document(i, config) for i in page.get(CONF_INPUTS, [])]: + document[CONF_INPUTS] = inputs + pages.append(document) + return {"version": WIZARD_JSON_VERSION, CONF_PAGES: pages} + + +def wizard_blob(wizard: ConfigType, config: fv.FinalValidateConfig) -> bytes: + """The wizard document as compact, sorted UTF-8 JSON in a single zstd frame.""" + text = json.dumps( + wizard_document(wizard, config), + separators=(",", ":"), + sort_keys=True, + ensure_ascii=False, + ) + return zstd_module().compress(text.encode("utf-8"), level=WIZARD_ZSTD_LEVEL) + + +async def to_code(wizard: ConfigType) -> None: + """Emit the compressed wizard, the table of inputs and the defines. + + The API reads both tables from its own sources, so they are externally linked PROGMEM arrays. + """ + blob = wizard_blob(wizard, CORE.config) + cg.extern_progmem_array("esphome::api::API_WIZARD_DATA", cg.uint8, list(blob)) + cg.add_define("API_WIZARD_DATA_SIZE", len(blob)) + for define in sorted(_wizard_defines(wizard)): + cg.add_define(define) + entries: list[cg.RawExpression] = [] + for conf in _wizard_inputs(wizard): + input_id = _wizard_input_id(conf) + # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. + buffer = _wizard_buffer_name(input_id) + cg.add_global( + cg.RawStatement( + f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' + ) + ) + if CONF_ID in conf: + cg.new_Pvariable(input_id, cg.RawExpression(buffer)) + entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + if entries: + cg.extern_progmem_array( + "esphome::api::API_WIZARD_INPUTS", + cg.esphome_ns.namespace("api").struct("WizardInputEntry"), + entries, + ) + cg.add_define("API_WIZARD_INPUT_COUNT", len(entries)) diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 1b66842f1eb..9fead561af3 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -2,8 +2,9 @@ from collections.abc import Callable, Iterable import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL +from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL from esphome.cpp_generator import MockObj +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] @@ -14,10 +15,12 @@ def validate_entity_domain( platform: str, supported_domains: Iterable[str] ) -> Callable[[ConfigType], ConfigType]: def validator(config: ConfigType) -> ConfigType: - domain = config[CONF_ENTITY_ID].split(".", 1)[0] - if domain not in supported_domains: + # A wizard input supplies the entity ID later; the wizard checks its domains + if (entity_id := config.get(CONF_ENTITY_ID)) is None: + return config + if entity_id.split(".", 1)[0] not in supported_domains: raise cv.Invalid( - f"Entity ID {config[CONF_ENTITY_ID]} is not supported by the {platform} platform." + f"Entity ID {entity_id} is not supported by the {platform} platform." ) return config @@ -26,7 +29,7 @@ def validate_entity_domain( HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema( { - cv.Required(CONF_ENTITY_ID): cv.entity_id, + cv.Optional(CONF_ENTITY_ID): cv.entity_id, cv.Optional(CONF_ATTRIBUTE): cv.string, cv.Optional(CONF_INTERNAL, default=True): cv.boolean, } @@ -34,14 +37,35 @@ HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema( { - cv.Required(CONF_ENTITY_ID): cv.entity_id, + cv.Optional(CONF_ENTITY_ID): cv.entity_id, cv.Optional(CONF_INTERNAL, default=True): cv.boolean, } ) +def final_validate_entity_id(config: ConfigType) -> ConfigType: + """Without an entity_id, the entity must be a wizard input, as Home Assistant then supplies it.""" + if CONF_ENTITY_ID in config: + return config + from esphome.components.api import wizard + + if config[CONF_ID].id not in wizard.wizard_input_ids( + fv.full_config.get()[wizard.API_DOMAIN] + ): + raise cv.Invalid( + f"{CONF_ENTITY_ID} is required unless this entity is a wizard input" + ) + return config + + def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: - cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) + from esphome.components.api import wizard + + if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None: + # The entity ID is chosen in the wizard and lives in a buffer the API owns + cg.add(var.set_entity_id(cg.RawExpression(buffer))) + else: + cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) cg.add_define("USE_API_HOMEASSISTANT_STATES") diff --git a/esphome/components/homeassistant/binary_sensor/__init__.py b/esphome/components/homeassistant/binary_sensor/__init__.py index 6ea17b68313..310ade27a8e 100644 --- a/esphome/components/homeassistant/binary_sensor/__init__.py +++ b/esphome/components/homeassistant/binary_sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py index 660c59164e9..f486a841263 100644 --- a/esphome/components/homeassistant/button/__init__.py +++ b/esphome/components/homeassistant/button/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -29,6 +30,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("button", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp index d8d2faa37af..87280c2b5b1 100644 --- a/esphome/components/homeassistant/button/homeassistant_button.cpp +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -24,6 +24,13 @@ void HomeassistantButton::press_action() { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press"); static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 9658d3dc84e..6288db68805 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -26,6 +27,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("number", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 553c2466f84..e8a679e91cf 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -80,6 +80,13 @@ void HomeassistantNumber::control(float value) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + this->publish_state(value); static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index 8be0a4545c5..e8ad0fc23b8 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -40,6 +41,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("select", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 82571c768d3..d3401de7f0e 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -230,6 +230,13 @@ void HomeassistantSelect::control(size_t index) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/sensor/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index abee957fda3..1417c57bb54 100644 --- a/esphome/components/homeassistant/sensor/__init__.py +++ b/esphome/components/homeassistant/sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) diff --git a/esphome/components/homeassistant/switch/__init__.py b/esphome/components/homeassistant/switch/__init__.py index 55854cd6591..495779b6ed3 100644 --- a/esphome/components/homeassistant/switch/__init__.py +++ b/esphome/components/homeassistant/switch/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -36,6 +37,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("switch", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 8a4ea19f2e5..79d75c91336 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -40,6 +40,13 @@ void HomeassistantSwitch::write_state(bool state) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_ON = StringRef::from_lit("homeassistant.turn_on"); static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py index 6ca920bac7a..67377e02d05 100644 --- a/esphome/components/homeassistant/text/__init__.py +++ b/esphome/components/homeassistant/text/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -29,6 +30,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("text", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp index a8fabd5e145..31e4919700f 100644 --- a/esphome/components/homeassistant/text/homeassistant_text.cpp +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -79,6 +79,13 @@ void HomeassistantText::control(const std::string &value) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/text_sensor/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index 265250c6959..f57b398111e 100644 --- a/esphome/components/homeassistant/text_sensor/__init__.py +++ b/esphome/components/homeassistant/text_sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend( HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0a1d16a2b97..12f61467d65 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -272,6 +272,10 @@ #define API_OUTGOING_CONNECTION_PORT 6054 #define API_OUTGOING_CONNECTION_DELAY 60000 #define USE_API_VARINT64 +#define USE_API_WIZARD +#define USE_API_WIZARD_INPUTS +#define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES @@ -280,6 +284,8 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 #define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64 +#define API_WIZARD_DATA_SIZE 1 +#define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a5ffaefe0be..267e86c2edf 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -468,6 +468,23 @@ def progmem_array(id_, rhs) -> "MockObj": return obj +class ExternProgmemAssignmentExpression(ProgmemAssignmentExpression): + __slots__ = () + + def __str__(self): + return f"const {self.type} {self.name}[] PROGMEM = {self.rhs}" + + +def extern_progmem_array( + qualified_name: str, type_: "MockObjClass", rhs: SafeExpType +) -> "MockObj": + """Emit an externally linked PROGMEM table that a component declares extern and reads itself.""" + CORE.add_global( + ExternProgmemAssignmentExpression(type_, qualified_name, safe_exp(rhs)) + ) + return MockObj(qualified_name, ".") + + def shared_progmem_array( name: str, type_: "MockObjClass", rhs: SafeExpType, *, share: bool = True ) -> "MockObj": diff --git a/requirements.txt b/requirements.txt index cd952b57526..91a36ecbf70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 +backports.zstd==1.7.0; python_version < "3.14" # api wizard; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py new file mode 100644 index 00000000000..276f42d624e --- /dev/null +++ b/tests/component_tests/api/test_wizard.py @@ -0,0 +1,942 @@ +"""Tests for the api wizard: schema, final validation and generated tables.""" + +from collections.abc import Callable +import json +from pathlib import Path +import random +import re +import string +import textwrap + +import pytest + +from esphome.components.api import wizard +from esphome.components.homeassistant.switch import SUPPORTED_DOMAINS as SWITCH_DOMAINS +from esphome.config import load_config +from esphome.core import CORE +from esphome.helpers import fnv1_hash, fnv1a_32bit_hash +from tests.component_tests.helpers import get_define_value + +ESP32_HEADER = """ + esphome: + name: test + + esp32: + variant: esp32 + + wifi: + ssid: test + password: testtest + + logger: + """ + +ESP8266_HEADER = """ + esphome: + name: test + + esp8266: + board: d1_mini + + wifi: + ssid: test + password: testtest + + logger: + """ + +ENTITIES = """ + switch: + - platform: template + id: sw + name: Switch + optimistic: true + - platform: template + id: internal_sw + name: Internal switch + internal: true + optimistic: true + - platform: template + id: unnamed_sw + optimistic: true + - platform: homeassistant + id: ha_switch + %(light)s + sensor: + - platform: template + id: template_sensor + name: Template sensor + - platform: homeassistant + id: ha_plain_sensor + entity_id: sensor.plain + - platform: homeassistant + id: ha_sensor + %(sensor)s + binary_sensor: + - platform: homeassistant + id: ha_binary + %(binary)s + text_sensor: + - platform: homeassistant + id: ha_text + %(text)s + number: + - platform: homeassistant + id: ha_number + %(number)s + text: + - platform: homeassistant + id: ha_txt + %(text_entity)s + select: + - platform: homeassistant + id: ha_select + %(select)s + button: + - platform: homeassistant + id: ha_button + %(button)s + """ + + +def build_entities(inputs: set[str]) -> str: + """The test entities. The homeassistant ones that are wizard inputs set no entity_id, the others do.""" + ids = { + "light": ("ha_switch", "entity_id: light.lamp"), + "sensor": ("ha_sensor", "entity_id: sensor.a"), + "binary": ("ha_binary", "entity_id: binary_sensor.a"), + "text": ("ha_text", "entity_id: sensor.b"), + "number": ("ha_number", "entity_id: number.a"), + "text_entity": ("ha_txt", "entity_id: text.a"), + "select": ("ha_select", "entity_id: select.a"), + "button": ("ha_button", "entity_id: button.a"), + } + return ENTITIES % { + key: "" if name in inputs else yaml for key, (name, yaml) in ids.items() + } + + +GOOD_PAGES = """ + api: + wizard: + pages: + - title: Setup + description: "[%key:component::domain::section::name%]" + entities: + - id: sw + description: Enable + - id: sw + - inputs: + - entity: ha_sensor + description: Weather + target: + entity: + - integration: met + domain: sensor + device_class: [temperature, humidity] + supported_features: weather.WeatherEntityFeature.FORECAST_DAILY + - domain: [sensor, number] + - entity: ha_binary + - entity: ha_text + - entity: ha_number + - entity: ha_switch + - entity: ha_txt + - entity: ha_select + - entity: ha_button + - id: weather_input + target: + entity: + - domain: weather + - id: unset_input + """ + + +def write_config( + tmp_path: Path, header: str, api: str, yaml_entities: str | None = None +) -> Path: + """Write a config. By default the homeassistant entities that the wizard does not use as inputs set an entity_id.""" + path = tmp_path / "test.yaml" + if yaml_entities is None: + yaml_entities = build_entities(set(re.findall(r"entity: (\w+)", api))) + path.write_text( + textwrap.dedent(header) + textwrap.dedent(yaml_entities) + textwrap.dedent(api) + ) + return path + + +def write_input_config(tmp_path: Path, header: str) -> Path: + """Write a config with GOOD_PAGES.""" + return write_config(tmp_path, header, GOOD_PAGES) + + +def config_errors(path: Path) -> list[str]: + CORE.config_path = path + return [str(err.msg) for err in load_config({}).errors] + + +def wizard_api(pages: str) -> str: + return "api:\n wizard:\n pages:\n" + textwrap.indent( + textwrap.dedent(pages).strip("\n"), " " + ) + + +def test_no_wizard_emits_nothing( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config( + tmp_path, + ESP32_HEADER, + "api:\n", + "sensor:\n - platform: homeassistant\n id: s\n entity_id: sensor.s\n", + ) + ) + + assert "API_WIZARD_PAGES" not in main_cpp + assert 's->set_entity_id("sensor.s");' in main_cpp + assert not any(define.name == "USE_API_WIZARD" for define in CORE.defines) + + +def test_valid_wizard_has_no_errors(tmp_path: Path) -> None: + assert config_errors(write_input_config(tmp_path, ESP32_HEADER)) == [] + + +def wizard_inputs(inputs: str) -> str: + return wizard_api("- inputs:\n" + textwrap.indent(textwrap.dedent(inputs), " ")) + + +def test_switch_input_with_supported_domains_is_valid(tmp_path: Path) -> None: + api = wizard_inputs( + """ + - entity: ha_switch + target: + entity: + - domain: [light, fan] + """ + ) + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("api", "message"), + [ + pytest.param( + wizard_inputs("- entity: template_sensor"), + "must be a homeassistant", + id="input-not-homeassistant", + ), + pytest.param( + wizard_inputs("- entity: sw"), + "must be a homeassistant", + id="input-not-homeassistant-switch", + ), + pytest.param( + wizard_inputs( + """ + - entity: ha_switch + target: + entity: + - domain: [light, valve] + """ + ), + "does not support the domain(s) valve", + id="switch-unsupported-domain", + ), + pytest.param( + wizard_inputs( + """ + - entity: ha_switch + target: + entity: + - integration: hue + """ + ), + "must set domain", + id="switch-filter-without-domain", + ), + pytest.param( + wizard_inputs("- entity: ha_sensor\n- entity: ha_sensor"), + "'ha_sensor' is used more than once", + id="duplicate-input", + ), + ], +) +def test_invalid_inputs_are_rejected(tmp_path: Path, api: str, message: str) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any(message in error for error in errors), errors + + +def test_hash_collision_is_rejected(tmp_path: Path) -> None: + entities = "".join( + f"sensor:\n - platform: homeassistant\n id: {name}\n" + if index == 0 + else f" - platform: homeassistant\n id: {name}\n" + for index, name in enumerate(("a179599", "a362382")) + ) + api = wizard_inputs("- entity: a179599\n- entity: a362382") + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api, entities)) + + assert any("have the same hash" in error for error in errors), errors + + +@pytest.mark.parametrize( + "extra", + [ + "", + " entity_id: sensor.default\n", + ], +) +def test_entity_id_is_optional_only_for_wizard_inputs( + tmp_path: Path, extra: str +) -> None: + entities = f"sensor:\n - platform: homeassistant\n id: lonely\n{extra}" + errors = config_errors(write_config(tmp_path, ESP32_HEADER, "api:\n", entities)) + + if extra: + assert errors == [] + else: + assert any( + "entity_id is required unless this entity is a wizard input" in error + for error in errors + ), errors + + +def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked( + tmp_path: Path, +) -> None: + entities = "switch:\n - platform: homeassistant\n id: ha_switch\n entity_id: climate.heater\n" + errors = config_errors( + write_config( + tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_switch"), entities + ) + ) + + # The YAML entity ID is still checked against the platform's domains + assert any("not supported by the switch platform" in error for error in errors) + + +@pytest.mark.parametrize( + ("pages", "message"), + [ + pytest.param( + "- title: Empty", + "at least one of entities, inputs", + id="page-without-fields", + ), + pytest.param( + """ + - entities: [] + """, + "length of value must be at least 1", + id="empty-entities-list", + ), + pytest.param( + """ + - entities: [{id: internal_sw}] + """, + "Entity 'internal_sw' is internal", + id="internal-entity", + ), + pytest.param( + """ + - entities: [{id: unnamed_sw}] + """, + "Entity 'unnamed_sw' is internal", + id="unnamed-entity-is-internal", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: + - {} + """, + "at least one of", + id="empty-filter", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: [] + """, + "length of value must be at least 1", + id="no-filters", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: + - domain: [] + """, + "length of value must be at least 1", + id="empty-domain-list", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: + - supported_features: FEATURE + """.replace("FEATURE", "x" * 128), + "length of value must be at most 127", + id="long-feature", + ), + pytest.param( + """ + - description: TEXT + entities: [{id: sw}] + """.replace("TEXT", "x" * 256), + "length of value must be at most 255", + id="long-description", + ), + ], +) +def test_invalid_wizards_are_rejected(tmp_path: Path, pages: str, message: str) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_api(pages))) + + assert any(message in error for error in errors), errors + + +def test_no_pages_is_rejected(tmp_path: Path) -> None: + errors = config_errors( + write_config(tmp_path, ESP32_HEADER, "api:\n wizard:\n pages: []\n") + ) + + assert any("length of value must be at least 1" in error for error in errors), ( + errors + ) + + +def test_input_is_set_condition( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + interval = """ + interval: + - interval: 1h + then: + - if: + condition: + api.wizard.input_is_set: weather_input + then: + - logger.log: set + - if: + condition: + api.wizard.input_is_set: + id: unset_input + then: + - logger.log: set + """ + path = write_input_config(tmp_path, ESP32_HEADER) + path.write_text(path.read_text() + textwrap.dedent(interval)) + main_cpp = generate_main(path) + + assert "weather_input->has_entity_id()" in main_cpp + assert "unset_input->has_entity_id()" in main_cpp + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + pytest.param( + "- description: Neither", + "exactly one of id, entity", + id="neither-id-nor-entity", + ), + pytest.param( + "- id: standalone\n entity: ha_sensor", + "more than one of id, entity", + id="both-id-and-entity", + ), + pytest.param( + "- entity: ha_sensor\n entity_id: sensor.a", + "extra keys not allowed", + id="entity-id-on-linked-input", + ), + pytest.param( + "- id: standalone\n entity_id: weather.home", + "extra keys not allowed", + id="entity-id-on-standalone-input", + ), + pytest.param( + "- entity: ha_sensor\n- entity: ha_sensor", + "used more than once", + id="duplicate-linked-input", + ), + ], +) +def test_standalone_and_linked_input_rules( + tmp_path: Path, inputs: str, message: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs))) + + assert any(message in error for error in errors), errors + + +def test_standalone_input_is_valid(tmp_path: Path) -> None: + api = wizard_inputs("- id: standalone") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +WIZARD_DEFINES = { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", +} + + +@pytest.mark.parametrize( + ("pages", "expected"), + [ + pytest.param( + "- entities: [{id: sw}]", + {"USE_API_WIZARD"}, + id="entity-only", + ), + pytest.param( + "- inputs: [{id: standalone}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="standalone-input-without-filter", + ), + pytest.param( + "- inputs: [{id: standalone, target: {entity: [{domain: weather}]}}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="standalone-input-with-filter", + ), + pytest.param( + "- inputs: [{entity: ha_sensor}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + }, + id="linked-sensor-without-filter", + ), + pytest.param( + "- inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + }, + id="linked-sensor-with-filter", + ), + pytest.param( + # A switch without a target gets the default filter + "- inputs: [{entity: ha_switch}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + }, + id="linked-switch-default-filter", + ), + pytest.param( + "- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]", + WIZARD_DEFINES, + id="everything", + ), + ], +) +def test_only_the_defines_the_wizard_needs_are_emitted( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + pages: str, + expected: set[str], +) -> None: + api = wizard_api(pages) + generate_main(write_config(tmp_path, ESP32_HEADER, api)) + + assert {d.name for d in CORE.defines} & WIZARD_DEFINES == expected + + +@pytest.mark.parametrize( + ("entity", "bad_domain"), + [ + ("ha_txt", "select"), + ("ha_select", "text"), + ("ha_button", "switch"), + ("ha_number", "sensor"), + ], +) +def test_text_select_and_button_inputs_keep_to_their_domains( + tmp_path: Path, entity: str, bad_domain: str +) -> None: + api = wizard_inputs( + f"- entity: {entity}\n target:\n entity:\n - domain: {bad_domain}" + ) + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any( + f"does not support the domain(s) {bad_domain}" in error for error in errors + ), errors + + +@pytest.mark.parametrize("entity", ["ha_txt", "ha_select", "ha_button", "ha_number"]) +def test_text_select_and_button_filters_must_set_a_domain( + tmp_path: Path, entity: str +) -> None: + api = wizard_inputs( + f"- entity: {entity}\n target:\n entity:\n - integration: hue" + ) + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any("must set domain" in error for error in errors), errors + + +def test_number_input_accepts_input_number(tmp_path: Path) -> None: + api = wizard_inputs( + "- entity: ha_number\n target:\n entity:\n - domain: input_number" + ) + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +def test_text_select_and_button_default_to_their_domains( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) + document = json.loads(wizard.zstd_module().decompress(blob_in(main_cpp))) + filters = { + entry["key"]: entry.get("entity_filters") + for page in document["pages"] + for entry in page.get("inputs", []) + } + + # ha_txt and ha_button have no target, so they take every supported domain + assert filters[fnv1_hash("ha_txt")] == [{"domain": ["input_text", "text"]}] + assert filters[fnv1_hash("ha_button")] == [{"domain": ["button", "input_button"]}] + assert filters[fnv1_hash("ha_select")] == [{"domain": ["input_select", "select"]}] + # A number takes number and input_number entities, and the other sensors take anything + assert filters[fnv1_hash("ha_number")] == [{"domain": ["input_number", "number"]}] + assert filters[fnv1_hash("ha_binary")] is None + for entity in ("ha_txt", "ha_select", "ha_button"): + assert f"{entity}->set_entity_id(api_wizard_input_{entity});" in main_cpp + + +@pytest.mark.parametrize( + "entity_yaml", + [ + "text:\n - platform: homeassistant\n id: lonely\n", + "select:\n - platform: homeassistant\n id: lonely\n", + "button:\n - platform: homeassistant\n id: lonely\n", + ], +) +def test_new_platforms_need_an_entity_id_unless_they_are_inputs( + tmp_path: Path, entity_yaml: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, "api:\n", entity_yaml)) + + assert any( + "entity_id is required unless this entity is a wizard input" in error + for error in errors + ), errors + + +DEVICES_HEADER = """ + esphome: + name: test + devices: + - id: kitchen_dev + name: Kitchen + + esp32: + variant: esp32 + + wifi: + ssid: test + password: testtest + + logger: + """ + +JSON_ENTITIES = """ + switch: + - platform: template + id: sw + name: Switch + optimistic: true + - platform: template + id: sw2 + name: Kitchen Switch + device_id: kitchen_dev + optimistic: true + - platform: homeassistant + id: ha_switch + sensor: + - platform: homeassistant + id: ha_sensor + """ + +JSON_PAGES = """ + - title: Audio + description: Pick + entities: + - id: sw + description: Enable + - id: sw2 + inputs: + - id: weather + description: Weather + target: + entity: + - integration: met + domain: weather + device_class: [temperature, humidity] + - domain: [weather, sensor] + - entity: ha_switch + - inputs: [{entity: ha_sensor}] + """ + + +def blob_in(main_cpp: str) -> bytes: + """The compressed wizard that the generated code puts in flash.""" + match = re.search( + r"const uint8_t esphome::api::API_WIZARD_DATA\[\] PROGMEM = \{([^}]*)\};", + main_cpp, + ) + assert match is not None + return bytes(int(byte) for byte in match.group(1).split(", ")) + + +def entity_hash(main_cpp: str, variable: str) -> int: + """The key the generated code passes to configure_entity_, which ListEntities then sends.""" + match = re.search( + rf'App\.register_switch\({variable}, "[^"]*", (\d+)UL, \d+\)', main_cpp + ) + assert match is not None, variable + return int(match.group(1)) + + +def test_the_blob_is_the_exact_json_document( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config(tmp_path, DEVICES_HEADER, wizard_api(JSON_PAGES), JSON_ENTITIES) + ) + blob = blob_in(main_cpp) + + text = wizard.zstd_module().decompress(blob).decode("utf-8") + expected = { + "version": 1, + "pages": [ + { + "title": "Audio", + "description": "Pick", + "entities": [ + # No device_id for the main device, and no description when unset + {"key": entity_hash(main_cpp, "sw"), "description": "Enable"}, + { + "key": entity_hash(main_cpp, "sw2"), + "device_id": fnv1a_32bit_hash("kitchen_dev"), + }, + ], + "inputs": [ + { + "key": fnv1_hash("weather"), + "description": "Weather", + "entity_filters": [ + { + "integration": "met", + "domain": ["weather"], + "device_class": ["temperature", "humidity"], + }, + {"domain": ["weather", "sensor"]}, + ], + }, + { + "key": fnv1_hash("ha_switch"), + # The default filter of a switch + "entity_filters": [{"domain": SWITCH_DOMAINS}], + }, + ], + }, + {"inputs": [{"key": fnv1_hash("ha_sensor")}]}, + ], + } + assert text == json.dumps( + expected, separators=(",", ":"), sort_keys=True, ensure_ascii=False + ) + # The device id is the one the generated code gives the device + assert f"set_device_id({fnv1a_32bit_hash('kitchen_dev')})" in main_cpp + assert get_define_value("API_WIZARD_DATA_SIZE") == str(len(blob)) + + +def test_the_blob_is_deterministic_and_the_same_on_every_platform( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + api = wizard_api(JSON_PAGES) + first = blob_in( + generate_main(write_config(tmp_path, DEVICES_HEADER, api, JSON_ENTITIES)) + ) + # Compressing the same document again gives the same bytes + document = wizard.wizard_document(CORE.config["api"]["wizard"], CORE.config) + again = wizard.zstd_module().compress( + json.dumps( + document, separators=(",", ":"), sort_keys=True, ensure_ascii=False + ).encode("utf-8"), + level=wizard.WIZARD_ZSTD_LEVEL, + ) + + assert again == first + assert first[:4] == b"\x28\xb5\x2f\xfd" # a zstd frame + + +def test_the_blob_is_in_flash_on_esp8266( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP8266_HEADER)) + + assert "esphome::api::API_WIZARD_DATA[] PROGMEM = {" in main_cpp + assert ( + "const api::WizardInputEntry esphome::api::API_WIZARD_INPUTS[] PROGMEM = {" + in main_cpp + ) + # Only the compressed data and the input table are emitted, no strings or row tables + assert "api_wizard_str" not in main_cpp + assert "WizardEntityRow" not in main_cpp + + +def test_inputs_have_buffers_hashes_and_a_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) + + # The buffer holds the YAML entity ID until the wizard sets another + assert 'static char api_wizard_input_ha_sensor[256] = "";' in main_cpp + entities = ( + "ha_sensor", + "ha_binary", + "ha_text", + "ha_number", + "ha_switch", + "ha_txt", + "ha_select", + "ha_button", + "weather_input", + "unset_input", + ) + for entity in entities: + assert f"{{{fnv1_hash(entity)}u, api_wizard_input_{entity}}}" in main_cpp + assert get_define_value("API_WIZARD_INPUT_COUNT") == str(len(entities)) + for entity in entities[:-2]: + assert f"{entity}->set_entity_id(api_wizard_input_{entity});" in main_cpp + # The standalone inputs are objects that read the same buffer + assert "WizardInput(api_wizard_input_weather_input)" in main_cpp + assert "WizardInput(api_wizard_input_unset_input)" in main_cpp + # Not an input, so it keeps its constant + assert 'ha_plain_sensor->set_entity_id("sensor.plain");' in main_cpp + + +def test_an_entity_without_a_name_cannot_be_in_the_wizard(tmp_path: Path) -> None: + yaml_entities = 'switch:\n - platform: template\n id: nameless\n name: ""\n optimistic: true\n' + errors = config_errors( + write_config( + tmp_path, + ESP32_HEADER, + wizard_api("- entities: [{id: nameless}]"), + yaml_entities, + ) + ) + + assert any("has no name of its own" in error for error in errors), errors + + +def test_zstd_falls_back_to_the_backport(monkeypatch: pytest.MonkeyPatch) -> None: + """Before Python 3.14 the standard library has no zstd, so the backport is used.""" + backport = object() + + def import_module(name: str) -> object: + if name == "compression.zstd": + raise ImportError(name) + assert name == "backports.zstd" + return backport + + monkeypatch.setattr(wizard.importlib, "import_module", import_module) + + assert wizard.zstd_module() is backport + + +def test_a_wizard_too_big_for_one_message_is_rejected(tmp_path: Path) -> None: + # Random text does not compress, so this needs more than the limit even compressed + rng = random.Random(1) + alphabet = string.ascii_letters + string.digits + entities = ",\n".join( + "{id: sw, description: " + + "".join(rng.choice(alphabet) for _ in range(255)) + + "}" + for _ in range(400) + ) + api = wizard_api(f"- entities: [\n{entities}\n]") + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any( + "The compressed wizard is" in error and "bytes over the 65512 bytes" in error + for error in errors + ), errors + + +def test_repeated_text_compresses_well_inside_the_limit(tmp_path: Path) -> None: + # 400 identical entities are far over the limit as JSON, but compress to very little + entities = ",\n".join(["{id: sw, description: " + "x" * 255 + "}"] * 400) + api = wizard_api(f"- entities: [\n{entities}\n]") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("platform", "entity_id"), + [ + ("sensor", "sensor.a"), + ("switch", "light.a"), + ("text", "text.a"), + ("button", "button.a"), + ], +) +def test_a_linked_entity_must_not_set_an_entity_id( + tmp_path: Path, platform: str, entity_id: str +) -> None: + yaml_entities = ( + f"{platform}:\n - platform: homeassistant\n id: fixed\n" + f" entity_id: {entity_id}\n" + ) + errors = config_errors( + write_config( + tmp_path, ESP32_HEADER, wizard_inputs("- entity: fixed"), yaml_entities + ) + ) + + assert any( + "'fixed' has an entity_id set in its configuration" in error + and "Remove entity_id" in error + for error in errors + ), errors + + +def test_an_entity_with_an_entity_id_that_is_no_input_is_valid( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) + + # Static entries keep their literal and are kept apart from the wizard buffers + assert 'ha_plain_sensor->set_entity_id("sensor.plain");' in main_cpp + assert "api_wizard_input_ha_plain_sensor" not in main_cpp + # Every buffer starts empty + assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0] diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index a5e3731c751..4a66287c33f 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -8,6 +8,18 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config): cg.add_define("USE_API") cg.add_define("USE_API_PLAINTEXT") + # Linked wizard inputs only exist next to homeassistant entities, which need this + cg.add_define("USE_API_HOMEASSISTANT_STATES") + # test_wizard.cpp supplies the tables that codegen emits for a real build + for define in ( + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + ): + cg.add_define(define) + cg.add_define("API_WIZARD_DATA_SIZE", 200) + cg.add_define("API_WIZARD_INPUT_COUNT", 2) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml new file mode 100644 index 00000000000..5e1936a042d --- /dev/null +++ b/tests/components/api/common-wizard.yaml @@ -0,0 +1,108 @@ +api: + homeassistant_services: true + wizard: + pages: + - title: Features + description: Choose which features to enable + entities: + - id: feature_enable_switch + description: "[%key:component::example::feature::enable_switch%]" + - id: guest_mode_switch + description: Guest Mode + - id: plain_switch + - title: Sources + inputs: + - entity: outdoor_temp + description: Outdoor temperature + target: + entity: + - integration: met + domain: sensor + device_class: [temperature, humidity] + supported_features: weather.WeatherEntityFeature.FORECAST_DAILY + - domain: [sensor, number] + - entity: default_temp + - entity: door_open + - entity: house_mode + - entity: target_temp + - entity: ha_light_switch + target: + entity: + - domain: light + - entity: ha_text + - entity: ha_select + target: + entity: + - domain: input_select + - entity: ha_button + - id: weather_input + description: Weather source + target: + entity: + - domain: weather + - id: unset_input + +switch: + - platform: template + id: feature_enable_switch + name: Feature Enable + optimistic: true + - platform: template + id: guest_mode_switch + name: Guest Mode + optimistic: true + - platform: template + id: plain_switch + name: Plain Switch + optimistic: true + - platform: homeassistant + id: ha_light_switch + +sensor: + - platform: homeassistant + id: outdoor_temp + - platform: homeassistant + id: default_temp + - platform: homeassistant + id: fixed_temp + entity_id: sensor.fixed_temperature + +binary_sensor: + - platform: homeassistant + id: door_open + +text_sensor: + - platform: homeassistant + id: house_mode + +number: + - platform: homeassistant + id: target_temp + +text: + - platform: homeassistant + id: ha_text + +select: + - platform: homeassistant + id: ha_select + +button: + - platform: homeassistant + id: ha_button + - platform: homeassistant + id: fixed_button + entity_id: button.restart + +interval: + - interval: 1h + then: + - if: + condition: + api.wizard.input_is_set: weather_input + then: + - homeassistant.action: + action: weather.get_forecasts + data: + type: daily + entity_id: !lambda 'return id(weather_input).entity_id().c_str();' diff --git a/tests/components/api/test-wizard.esp32-idf.yaml b/tests/components/api/test-wizard.esp32-idf.yaml new file mode 100644 index 00000000000..4f33f5b30da --- /dev/null +++ b/tests/components/api/test-wizard.esp32-idf.yaml @@ -0,0 +1,6 @@ +packages: + wizard: !include common-wizard.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/api/test-wizard.esp8266-ard.yaml b/tests/components/api/test-wizard.esp8266-ard.yaml new file mode 100644 index 00000000000..4f33f5b30da --- /dev/null +++ b/tests/components/api/test-wizard.esp8266-ard.yaml @@ -0,0 +1,6 @@ +packages: + wizard: !include common-wizard.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/api/test-wizard.host.yaml b/tests/components/api/test-wizard.host.yaml new file mode 100644 index 00000000000..ffbcfcd36b2 --- /dev/null +++ b/tests/components/api/test-wizard.host.yaml @@ -0,0 +1,4 @@ +packages: + wizard: !include common-wizard.yaml + +network: diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp new file mode 100644 index 00000000000..4e257e2e6ac --- /dev/null +++ b/tests/components/api/test_wizard.cpp @@ -0,0 +1,150 @@ +#ifdef USE_HOST +#include + +#include +#include +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_wizard.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api { + +// RAM buffers of the inputs, as codegen defines them +static char *wizard_input_weather() { + static char buffer[WIZARD_ENTITY_ID_BUFFER_SIZE] = "sensor.default"; + return buffer; +} +static char *wizard_input_other() { + static char buffer[WIZARD_ENTITY_ID_BUFFER_SIZE] = ""; + return buffer; +} + +static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; +static constexpr uint32_t OTHER_KEY = 0x11223344; + +// The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two +// bytes as a varint. +const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { + 0x03, 0x0a, 0x11, 0x18, 0x1f, 0x26, 0x2d, 0x34, 0x3b, 0x42, 0x49, 0x50, 0x57, 0x5e, 0x65, 0x6c, 0x73, 0x7a, 0x81, + 0x88, 0x8f, 0x96, 0x9d, 0xa4, 0xab, 0xb2, 0xb9, 0xc0, 0xc7, 0xce, 0xd5, 0xdc, 0xe3, 0xea, 0xf1, 0xf8, 0xff, 0x06, + 0x0d, 0x14, 0x1b, 0x22, 0x29, 0x30, 0x37, 0x3e, 0x45, 0x4c, 0x53, 0x5a, 0x61, 0x68, 0x6f, 0x76, 0x7d, 0x84, 0x8b, + 0x92, 0x99, 0xa0, 0xa7, 0xae, 0xb5, 0xbc, 0xc3, 0xca, 0xd1, 0xd8, 0xdf, 0xe6, 0xed, 0xf4, 0xfb, 0x02, 0x09, 0x10, + 0x17, 0x1e, 0x25, 0x2c, 0x33, 0x3a, 0x41, 0x48, 0x4f, 0x56, 0x5d, 0x64, 0x6b, 0x72, 0x79, 0x80, 0x87, 0x8e, 0x95, + 0x9c, 0xa3, 0xaa, 0xb1, 0xb8, 0xbf, 0xc6, 0xcd, 0xd4, 0xdb, 0xe2, 0xe9, 0xf0, 0xf7, 0xfe, 0x05, 0x0c, 0x13, 0x1a, + 0x21, 0x28, 0x2f, 0x36, 0x3d, 0x44, 0x4b, 0x52, 0x59, 0x60, 0x67, 0x6e, 0x75, 0x7c, 0x83, 0x8a, 0x91, 0x98, 0x9f, + 0xa6, 0xad, 0xb4, 0xbb, 0xc2, 0xc9, 0xd0, 0xd7, 0xde, 0xe5, 0xec, 0xf3, 0xfa, 0x01, 0x08, 0x0f, 0x16, 0x1d, 0x24, + 0x2b, 0x32, 0x39, 0x40, 0x47, 0x4e, 0x55, 0x5c, 0x63, 0x6a, 0x71, 0x78, 0x7f, 0x86, 0x8d, 0x94, 0x9b, 0xa2, 0xa9, + 0xb0, 0xb7, 0xbe, 0xc5, 0xcc, 0xd3, 0xda, 0xe1, 0xe8, 0xef, 0xf6, 0xfd, 0x04, 0x0b, 0x12, 0x19, 0x20, 0x27, 0x2e, + 0x35, 0x3c, 0x43, 0x4a, 0x51, 0x58, 0x5f, 0x66, 0x6d, 0x74}; +const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { + {WEATHER_KEY, wizard_input_weather()}, + {OTHER_KEY, wizard_input_other()}, +}; + +using Bytes = std::vector; + +static Bytes encode(const ProtoMessage &msg, uint32_t (*calc)(const void *), + uint8_t *(*enc)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { + APIBuffer buffer; + uint32_t size = calc(&msg); + EXPECT_TRUE(buffer.resize(size)); + ProtoWriteBuffer writer(&buffer, 0); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buffer.data() + buffer.size(); +#endif + uint8_t *end = enc(&msg, writer PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buffer.data()), size); + return Bytes(buffer.data(), buffer.data() + size); +} + +TEST(DeviceWizard, ResponseSendsTheDataUnchanged) { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = API_WIZARD_DATA_SIZE; + + // Field 1, length delimited, then the 200 byte length as a two byte varint, then the data + Bytes expected{0x0a, 0xc8, 0x01}; + expected.insert(expected.end(), API_WIZARD_DATA, API_WIZARD_DATA + API_WIZARD_DATA_SIZE); + + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); +} + +TEST(DeviceWizard, ShortDataHasAOneByteLength) { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = 3; + + Bytes expected{0x0a, 0x03, API_WIZARD_DATA[0], API_WIZARD_DATA[1], API_WIZARD_DATA[2]}; + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); +} + +TEST(DeviceWizard, NoDataEncodesNothing) { + DeviceWizardResponse resp; + EXPECT_EQ(resp.calculate_size(), 0u); + EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response).empty()); +} + +TEST(DeviceWizard, CapabilitiesAnnounceTheWizard) { + DeviceCapabilitiesResponse resp; + EXPECT_TRUE( + encode(resp, &DeviceCapabilitiesResponse::calc_size_msg, &DeviceCapabilitiesResponse::encode_msg).empty()); + resp.wizard.configured = true; + // Field 5 (the wizard), length delimited, holding field 1 (configured) set to 1 + EXPECT_EQ(encode(resp, &DeviceCapabilitiesResponse::calc_size_msg, &DeviceCapabilitiesResponse::encode_msg), + (Bytes{0x2a, 0x02, 0x08, 0x01})); +} + +// A request for the input with the given key and entity id +static const char *set_input(uint32_t key, const char *entity_id) { + WizardInputSetRequest request; + request.key = key; + request.entity_id = StringRef(entity_id); + return wizard_set_input(request); +} + +TEST(DeviceWizard, SetInputStoresTheEntityIdInTheBuffer) { + EXPECT_EQ(set_input(OTHER_KEY, "sensor.outdoor"), wizard_input_other()); + EXPECT_STREQ(wizard_input_other(), "sensor.outdoor"); + // A shorter id replaces a longer one completely + EXPECT_EQ(set_input(OTHER_KEY, "light.a"), wizard_input_other()); + EXPECT_STREQ(wizard_input_other(), "light.a"); + // The longest valid id fills the buffer + std::string longest = "sensor." + std::string(WIZARD_ENTITY_ID_BUFFER_SIZE - 1 - 7, 'x'); + EXPECT_EQ(set_input(OTHER_KEY, longest.c_str()), wizard_input_other()); + EXPECT_EQ(std::string(wizard_input_other()), longest); +} + +TEST(DeviceWizard, SetInputFindsTheInputByItsKey) { + EXPECT_EQ(set_input(WEATHER_KEY, "weather.home"), wizard_input_weather()); + EXPECT_STREQ(wizard_input_weather(), "weather.home"); + EXPECT_EQ(set_input(OTHER_KEY, "weather.away"), wizard_input_other()); + EXPECT_STREQ(wizard_input_weather(), "weather.home"); +} + +TEST(DeviceWizard, SetInputIgnoresWhatIsNotAnEntityId) { + std::string before = wizard_input_weather(); + EXPECT_EQ(set_input(WEATHER_KEY, ""), nullptr); + EXPECT_EQ(set_input(WEATHER_KEY, "nodot"), nullptr); + std::string too_long = "sensor." + std::string(WIZARD_ENTITY_ID_BUFFER_SIZE, 'x'); + EXPECT_EQ(set_input(WEATHER_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(set_input(0xdeadbeef, "sensor.a"), nullptr); + EXPECT_EQ(std::string(wizard_input_weather()), before); +} + +TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { + strcpy(wizard_input_other(), ""); + WizardInput input(wizard_input_other()); + EXPECT_FALSE(input.has_entity_id()); + EXPECT_TRUE(input.entity_id().empty()); + + ASSERT_EQ(set_input(OTHER_KEY, "weather.home"), wizard_input_other()); + EXPECT_TRUE(input.has_entity_id()); + EXPECT_EQ(input.entity_id(), "weather.home"); +} + +} // namespace esphome::api +#endif // USE_HOST diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index 3e2f37e16c1..0f25de7c08b 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -40,6 +40,7 @@ API_DIR = Path(esphome.__file__).parent / "components" / "api" PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8") HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8") CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8") +SERVICE_TEXT = (API_DIR / "api_pb2_service.cpp").read_text(encoding="utf-8") API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8") # Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse @@ -66,7 +67,9 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "voice_assistant": 2, "zwave_proxy": 3, "serial_proxies": 4, + "wizard": 5, }, + "WizardCapabilities": {"configured": 1}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, @@ -359,6 +362,90 @@ def test_device_capabilities_rpc_requires_authentication() -> None: ) +def test_device_wizard_messages_keep_their_wire_ids() -> None: + """Message ids are part of the wire protocol and must not change.""" + for message, expected in ( + ("DeviceWizardRequest", 156), + ("DeviceWizardResponse", 157), + ("WizardInputSetRequest", 158), + ): + body = _extract_proto_message(PROTO_TEXT, message) + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, f"{message} is missing `option (id)`" + assert int(match.group(1)) == expected, ( + f"{message} has id {match.group(1)}, expected {expected}" + ) + + +def test_device_wizard_message_fields_keep_their_wire_numbers() -> None: + """Clients decode the wizard purely by field number.""" + fields: dict[str, dict[str, int]] = { + "WizardInputSetRequest": {"key": 1, "entity_id": 2}, + "DeviceWizardResponse": {"data": 1}, + } + for message, message_fields in fields.items(): + body = _extract_proto_message(PROTO_TEXT, message) + for field_name, number in message_fields.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), ( + f"{field_name} in {message} is no longer field number {number}" + ) + + +def _enclosing_ifdefs(text: str, anchor: str) -> list[str]: + """The conditions of the #if blocks that enclose the first line starting with anchor.""" + stack: list[str] = [] + for line in text.splitlines(): + if line.startswith(anchor): + return stack + if line.startswith("#if"): + stack.append(line) + elif line.startswith("#endif"): + stack.pop() + raise AssertionError(f"{anchor} not found") + + +# The define that compiles each wizard message in, so a device only pays for what its wizard uses +WIZARD_MESSAGE_DEFINES: dict[str, str] = { + "DeviceWizardRequest": "USE_API_WIZARD", + "DeviceWizardResponse": "USE_API_WIZARD", + "WizardInputSetRequest": "USE_API_WIZARD_INPUTS", +} +# Not marked in api.proto: the generator gives it the guard of the field that uses it +NESTED_WIZARD_MESSAGE_DEFINES: dict[str, str] = {"WizardCapabilities": "USE_API_WIZARD"} + + +def test_wizard_messages_are_compiled_out_without_a_wizard() -> None: + """A device must pay nothing for wizard parts it does not use: every wizard + message carries its define in api.proto, and the generated code guards the + nested message with the define of the field that holds it. + """ + for message, define in WIZARD_MESSAGE_DEFINES.items(): + body = _extract_proto_message(PROTO_TEXT, message) + assert f'option (ifdef) = "{define}";' in body, message + # The request is empty, so it has no class, only its dispatch case + assert "#ifdef USE_API_WIZARD\n case 156 /* DeviceWizardRequest" in SERVICE_TEXT + classes = {**WIZARD_MESSAGE_DEFINES, **NESTED_WIZARD_MESSAGE_DEFINES} + for message, define in classes.items(): + if message == "DeviceWizardRequest": + continue + assert any( + define in cond + for cond in _enclosing_ifdefs(HEADER_TEXT, f"class {message} final") + ), f"{message} is not guarded by {define} in api_pb2.h" + assert "#ifdef USE_API_WIZARD\n resp.wizard.configured = true;" in ( + API_CONNECTION_TEXT + ) + + +def test_device_wizard_rpc_requires_authentication() -> None: + """The wizard is only served on an authenticated connection, so the rpc + must not set `needs_authentication` and inherits the default of true. + """ + body = _extract_rpc_body(PROTO_TEXT, "device_wizard") + assert "needs_authentication" not in body + + # ==================== Group C: advertised API version ==================== diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 06b7c56baeb..b6f8fd1ec10 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -845,3 +845,11 @@ class TestSharedProgmemArray: CORE.register_variable(ID("table", is_declaration=True), cg.MockObj("table")) array = cg.shared_progmem_array("table", ct.uint8, [1]) assert str(array) == "table_2" + + +def test_extern_progmem_array_is_an_externally_linked_global() -> None: + array = cg.extern_progmem_array("esphome::api::API_TABLE", ct.uint8, [1, 2]) + assert str(array) == "esphome::api::API_TABLE" + assert [str(st) for st in CORE.global_statements if "PROGMEM" in str(st)] == [ + "const uint8_t esphome::api::API_TABLE[] PROGMEM = {1, 2};" + ] From 1ba323e9a3f23e7b8dc01ba270d54f07092cd03a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 09:51:17 -1000 Subject: [PATCH 020/435] [core] Keep log tags in flash on ESP8266 with ESPHOME_LOG_TAG (#20290) --- esphome/components/logger/log_buffer.h | 15 ++++++++- esphome/components/logger/logger.cpp | 5 ++- esphome/components/logger/logger.h | 20 ++++++++++-- .../components/touchscreen/touchscreen.cpp | 5 +-- esphome/core/application.cpp | 2 +- esphome/core/component.cpp | 2 +- esphome/core/entity_base.cpp | 3 +- esphome/core/helpers.cpp | 2 +- esphome/core/log.h | 8 +++++ esphome/core/scheduler.cpp | 2 +- esphome/core/time_64.cpp | 2 +- tests/components/logger/log_tag_test.cpp | 31 +++++++++++++++++++ 12 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 tests/components/logger/log_tag_test.cpp diff --git a/esphome/components/logger/log_buffer.h b/esphome/components/logger/log_buffer.h index 14121d3dd9d..019d13950a3 100644 --- a/esphome/components/logger/log_buffer.h +++ b/esphome/components/logger/log_buffer.h @@ -3,11 +3,14 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::logger { // Maximum header size: 35 bytes fixed + 32 bytes tag + 16 bytes thread name = 83 bytes (45 byte safety margin) static constexpr uint16_t MAX_HEADER_SIZE = 128; +// Longest tag the header budget allows +static constexpr size_t MAX_TAG_LENGTH = 32; // ANSI color code last digit (30-38 range, store only last digit to save RAM on ESP8266) static const char LOG_LEVEL_COLOR_DIGIT[] PROGMEM = { @@ -189,13 +192,23 @@ struct LogBuffer { *p++ = 'm'; } // Copy string without null terminator, updates pointer in place - // Caller is responsible for ensuring buffer has sufficient space + // Caller is responsible for ensuring buffer has sufficient space. Tags may be in PROGMEM on ESP8266. void copy_string_(char *&p, const char *str) { +#ifdef USE_ESP8266 + // Only ESP8266 caps the length; tags may be in PROGMEM, so read each byte once + for (size_t i = 0; i < MAX_TAG_LENGTH; i++) { + const char c = static_cast(progmem_read_byte(reinterpret_cast(str + i))); + if (c == '\0') + break; + *p++ = c; + } +#else const size_t len = strlen(str); // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - intentionally no null terminator, building string piece by // piece memcpy(p, str, len); p += len; +#endif } }; diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 757ba6771e0..2a0a1c9c0ac 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -144,7 +144,10 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS - auto it = this->log_levels_.find(tag); + // Skip the lookup when no per-tag levels are set + if (this->log_levels_.empty()) + return this->current_level_; + auto it = this->log_levels_.find(FlashTag{tag}); if (it != this->log_levels_.end()) return it->second; #endif diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 6bdd5910f63..b82aaa7c219 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -12,6 +12,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "log_buffer.h" #include "task_log_buffer_host.h" @@ -85,12 +86,16 @@ class LoggerLevelListener { }; #endif -#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS -// Comparison function for const char* keys in log_levels_ map +// RAM map keys compared with a tag that may be in PROGMEM, without copying it +struct FlashTag { + const char *tag; +}; struct CStrCompare { + using is_transparent = void; bool operator()(const char *a, const char *b) const { return strcmp(a, b) < 0; } + bool operator()(const char *key, FlashTag t) const { return ESPHOME_strcmp_P(key, t.tag) < 0; } + bool operator()(FlashTag t, const char *key) const { return ESPHOME_strcmp_P(key, t.tag) > 0; } }; -#endif // Stack buffer size for retrieving thread/task names from the OS // macOS allows up to 64 bytes, Linux up to 16 @@ -488,6 +493,12 @@ class LoggerMessageTrigger final : public Trigger(self); if (level <= trigger->level_) { +#ifdef USE_ESP8266 + // User lambdas may strcmp the tag, which may be in PROGMEM. The copy lives in + // the trigger so an automation that suspends (delay) still sees a valid tag. + ESPHOME_strncpy_P(trigger->ram_tag_, tag, MAX_TAG_LENGTH); + tag = trigger->ram_tag_; +#endif trigger->trigger(level, tag, message); } }); @@ -495,6 +506,9 @@ class LoggerMessageTrigger final : public Triggertouched = true; } @@ -63,9 +64,9 @@ void Touchscreen::loop() { // Simulate a touch after touch_timeout_> ms. This will reset any existing timeout operation. // This is to detect touch release. if (this->is_touched_) { - this->set_timeout(TAG, this->touch_timeout_, [this]() { this->store_.touched = true; }); + this->set_timeout(TOUCH_TIMEOUT_ID, this->touch_timeout_, [this]() { this->store_.touched = true; }); } else { - this->cancel_timeout(TAG); + this->cancel_timeout(TOUCH_TIMEOUT_ID); } } } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 50d1c619595..2ddc97efb5f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -36,7 +36,7 @@ namespace esphome { -static const char *const TAG = "app"; +ESPHOME_LOG_TAG(TAG, "app"); // Delay after setup() finishes before trimming the scheduler freelist of its post-boot peak. // 10 s is well past the bulk of post-setup async work (Wi-Fi/MQTT connects, first-read latency). diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 41dd32ea665..0aa2dc21a24 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -12,7 +12,7 @@ namespace esphome { -static const char *const TAG = "component"; +ESPHOME_LOG_TAG(TAG, "component"); // Global vectors for component data that doesn't belong in every instance. // Using vector instead of unordered_map for both because: diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index dc27c1e56a2..cbb14a37302 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,12 +1,13 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome { -static const char *const TAG = "entity_base"; +ESPHOME_LOG_TAG(TAG, "entity_base"); void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a11b07808d7..6130f7e27df 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -28,7 +28,7 @@ std::string progmem_string(ProgmemStr str) { } #endif -static const char *const TAG = "helpers"; +ESPHOME_LOG_TAG(TAG, "helpers"); __attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, size_t elem_size) { diff --git a/esphome/core/log.h b/esphome/core/log.h index 362ed83b915..145ba3a6f8a 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -190,4 +190,12 @@ struct LogString; #endif +// Declares a static log tag at namespace scope in a .cpp file; on ESP8266 it lives in flash, so read it with +// the _P functions, never strcmp/strlen. +#ifdef USE_ESP8266 +#define ESPHOME_LOG_TAG(name, tag) static const char name[] PROGMEM = tag +#else +#define ESPHOME_LOG_TAG(name, tag) static constexpr const char *const name = tag +#endif + } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index afb323f78ef..5acd2eec273 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -12,7 +12,7 @@ namespace esphome { -static const char *const TAG = "scheduler"; +ESPHOME_LOG_TAG(TAG, "scheduler"); // Maximum number of logically deleted (cancelled) items before forcing cleanup. // Empirically chosen to balance cleanup overhead against tombstone accumulation in items_. diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index 25076228d5e..1b0a49acaf7 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -17,7 +17,7 @@ namespace esphome { #ifdef ESPHOME_DEBUG_SCHEDULER -static const char *const TAG = "time_64"; +ESPHOME_LOG_TAG(TAG, "time_64"); #endif #ifdef ESPHOME_THREAD_SINGLE diff --git a/tests/components/logger/log_tag_test.cpp b/tests/components/logger/log_tag_test.cpp new file mode 100644 index 00000000000..c702790da12 --- /dev/null +++ b/tests/components/logger/log_tag_test.cpp @@ -0,0 +1,31 @@ +#include +#include +#include + +#include "esphome/components/logger/log_buffer.h" +#include "esphome/components/logger/logger.h" +#include "esphome/core/log.h" + +namespace esphome::logger::testing { + +ESPHOME_LOG_TAG(TEST_TAG, "test.tag"); + +TEST(LogTagTest, HeaderContainsTagAndLine) { + char data[256]; + LogBuffer buf{data, sizeof(data)}; + buf.write_header(5, TEST_TAG, 42, nullptr); + const std::string header(data, buf.pos); + EXPECT_NE(header.find("[D][test.tag:042]"), std::string::npos) << header; +} + +TEST(LogTagTest, PerTagLevelLookupMatchesByContent) { + std::map levels{{"api", 1}, {"sensor", 2}, {"wifi", 3}}; + // Copies, so a match must compare content rather than pointers + char api[] = "api", sensor[] = "sensor", wifi[] = "wifi", missing[] = "switch"; + EXPECT_EQ(levels.find(FlashTag{api})->second, 1); + EXPECT_EQ(levels.find(FlashTag{sensor})->second, 2); + EXPECT_EQ(levels.find(FlashTag{wifi})->second, 3); + EXPECT_EQ(levels.find(FlashTag{missing}), levels.end()); +} + +} // namespace esphome::logger::testing From 0323fd36d45abad13f03601f03a9f101367f350f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 10:00:07 -1000 Subject: [PATCH 021/435] [core] Clone git PlatformIO packages in parallel in the prefetch (#18837) --- esphome/platformio/prefetch.py | 79 +++++++++---- tests/unit_tests/test_platformio_prefetch.py | 114 ++++++++++++++++++- 2 files changed, 166 insertions(+), 27 deletions(-) diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index fbb31ae4529..991164eb993 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -62,6 +62,9 @@ def _preserved_sys_path() -> Iterator[None]: # Concurrent registry resolutions / HEAD probes (each is network-bound) _RESOLVE_WORKERS = 8 +# Concurrent VCS clones; network-bound, so wider than the extraction pool +_CLONE_WORKERS = 8 + # A hung child must not block the build; downloads resume on the next run _PREFETCH_TIMEOUT = 20 * 60 @@ -372,42 +375,64 @@ def _registry_jobs( return jobs, failed, installable +def _is_vcs_spec_uri(url: str) -> bool: + """Whether pio's ``install_from_uri`` would clone this URI rather than + copy or download it (PackageSpec normalizes git URLs to ``git+``).""" + return not url.startswith(("file://", "symlink://", "http://", "https://")) + + +def _is_clone_entry(entry: tuple) -> bool: + """Whether this pre-install entry's spec is cloned, not extracted.""" + url = entry[1].uri + return bool(url) and _is_vcs_spec_uri(url) + + +def _spec_name(spec: Any, url: str) -> str: + """The spec's name; the URL basename fallback is defensive only + (PackageSpec derives a name from the URI itself).""" + return spec.name or url.split("#", 1)[0].rsplit("/", 1)[-1] + + def _uri_jobs( - manager: Any, specs: list[Any], seen: set[str] + manager: Any, specs: list[Any], seen: set[str], trusted_names: bool = False ) -> tuple[list[tuple[str, int, Any]], int, list[tuple[str, Any]]]: """Jobs for direct-URL specs; a HEAD sizes each for the combined bar. Also returns how many HEAD probes errored (an absent length is not an - error) and the ``(name, spec)`` pairs whose archives will be - installable. + error) and the ``(name, spec)`` pairs to pre-install, including VCS + specs, which the pre-install clones itself. ``trusted_names`` marks + platform packages, whose platform.json keys match their manifests. """ from esphome.net_retry import fetch_with_retry, http_request - candidates: list[tuple[str, str, Path, Any]] = [] + candidates: list[tuple[str, str, Path, Any, bool]] = [] installable: list[tuple[str, Any]] = [] for spec in specs: url = spec.uri - if not url or not url.startswith(("http://", "https://")): - continue # git+/file specs are cloned/copied, not downloaded - if url.split("#", 1)[0].endswith(".git"): - continue # bare-URL VCS spec; PlatformIO clones it + if not url: + continue + is_vcs = _is_vcs_spec_uri(url) + if not is_vcs and not url.startswith(("http://", "https://")): + continue # file/symlink specs are copied in place by pio run if manager.get_package(spec): continue - name = spec.name or url.rsplit("/", 1)[-1] + name = _spec_name(spec, url) + # Only a name that is also the install dir may pre-install + safe_name = trusted_names or spec.has_custom_name() + if is_vcs: + if safe_name: + installable.append((name, spec)) + continue # PlatformIO downloads URL specs with no checksum dl_path = Path(manager.compute_download_path(url, "")) if dl_path.is_file(): - if spec.has_custom_name(): - # Only a custom name (Foo=https://...) is the destination - # dir; a URI-derived name's destination comes from the - # archive manifest, so its dedupe key could collide with - # another name and race one directory. pio run installs it. + if safe_name: installable.append((name, spec)) # fetched by an earlier run continue if str(dl_path) in seen: continue # another spec already claimed this .part seen.add(str(dl_path)) - candidates.append((spec.name, url, dl_path, spec)) + candidates.append((name, url, dl_path, spec, safe_name)) errors: list[str] = [] @@ -434,16 +459,17 @@ def _uri_jobs( if not candidates: return [], 0, installable with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(candidates))) as ex: - sizes = list(ex.map(_head_size, [url for _, url, _, _ in candidates])) + sizes = list(ex.map(_head_size, [url for _, url, _, _, _ in candidates])) jobs: list[tuple[str, int, Any]] = [] failed = 0 - for (name, url, dl_path, spec), size in zip(candidates, sizes, strict=True): + for (name, url, dl_path, spec, safe_name), size in zip( + candidates, sizes, strict=True + ): if size < 0: failed += 1 elif size: jobs.append((name, size, _uri_fetch_job(manager, url, dl_path, size))) - if spec.has_custom_name(): - # See above: derived-name specs stay with pio run's installer + if safe_name: installable.append((name, spec)) else: # Missing or unusable Content-Length; visible under -v @@ -703,7 +729,11 @@ def _preinstall( would hang, not fail). Waves skip dependencies; the installed manifests feed the next wave. Any failure falls back to pio run. """ - workers = extract_workers(len(entries)) + # Clones wait on the network: sort them first, dependency waves too + entries = sorted(entries, key=lambda entry: not _is_clone_entry(entry)) + clones = sum(map(_is_clone_entry, entries)) + # Network-bound clones get a wider pool than CPU-bound extraction + workers = max(extract_workers(len(entries)), min(clones, _CLONE_WORKERS)) # One manager per worker (_install mutates instance state); built # serially because construction rewires the shared manager logger managers: SimpleQueue = SimpleQueue() @@ -735,8 +765,9 @@ def _preinstall( raise _LOGGER.info( - "Installing %d PlatformIO package(s) with %d extraction worker(s): %s", + "Installing %d PlatformIO package(s)%s with %d worker(s): %s", len(entries), + f" ({clones} clone(s))" if clones else "", workers, ", ".join(name for name, *_ in entries), ) @@ -871,8 +902,10 @@ def _prefetch(build_dir: Path, env: str) -> None: unresolved = 0 for mgr, batch, is_platform in ((p.pm, specs, True), (lm, lib_specs, False)): entries: list[tuple[str, Any]] = [] - for build_jobs in (_registry_jobs, _uri_jobs): - batch_jobs, failed, installable = build_jobs(mgr, batch, seen) + for batch_jobs, failed, installable in ( + _registry_jobs(mgr, batch, seen), + _uri_jobs(mgr, batch, seen, trusted_names=is_platform), + ): jobs += batch_jobs unresolved += failed entries += installable diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index ef573767f74..3f01fb22e6d 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -661,7 +661,8 @@ def test_registry_jobs_one_bad_spec_keeps_the_rest(tmp_path: Path) -> None: def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None: - """HEAD sizes direct-URL specs; git and unreachable URLs are skipped.""" + """HEAD sizes direct-URL specs; VCS specs skip the download but are + still installable (the pre-install clones them in parallel).""" m = _fake_manager(tmp_path) resp = MagicMock() resp.headers = {"content-length": "2222"} @@ -670,15 +671,24 @@ def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None: m, [ _FakeSpec(uri="https://x/big.zip", name="big", custom_name=True), - _FakeSpec(uri="git+https://x/repo.git", name="repo"), - _FakeSpec(uri="https://x/repo.git#v1", name="barevcs"), + _FakeSpec(uri="git+https://x/repo.git", name="repo", custom_name=True), _FakeSpec(name="registry"), ], set(), ) assert failed == 0 assert [(n, s) for n, s, _ in jobs] == [("big", 2222)] - assert [n for n, _ in installable] == ["big"] + assert [n for n, _ in installable] == ["repo", "big"] + # A derived-name platform archive sized in the same run is trusted too + with patch("esphome.net_retry.http_request", return_value=resp): + jobs, _, installable = pf._uri_jobs( + m, + [_FakeSpec(uri="https://x/fresh.zip", name="fresh")], + set(), + trusted_names=True, + ) + assert [n for n, _, _ in jobs] == ["fresh"] + assert [n for n, _ in installable] == ["fresh"] # a successful HEAD with no Content-Length is a clean skip resp.headers = {} with patch("esphome.net_retry.http_request", return_value=resp): @@ -687,6 +697,53 @@ def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None: ) == ([], 0, []) +def test_uri_jobs_vcs_specs_installable_without_probe(tmp_path: Path) -> None: + """VCS specs never probe the network here (there is no archive); an + uninstalled custom-named one is handed to the pre-install, while + derived names, installed specs, and file/symlink specs are skipped.""" + m = _fake_manager(tmp_path) + with patch("esphome.net_retry.http_request") as mock_head: + jobs, failed, installable = pf._uri_jobs( + m, + [ + _FakeSpec( + uri="git+https://x/tool.git#1.0", name="tool", custom_name=True + ), + _FakeSpec(uri="hg+https://x/old", name="mercurial", custom_name=True), + # A derived name is not the destination dir; pio run clones it + _FakeSpec(uri="git+https://x/derived#v2", name="derived"), + _FakeSpec(uri="file:///local/dir", name="local"), + _FakeSpec(uri="symlink:///local/dir", name="link"), + ], + set(), + ) + mock_head.assert_not_called() + assert (jobs, failed) == ([], 0) + assert [n for n, _ in installable] == ["tool", "mercurial"] + + # trusted_names admits platform packages, which have no custom name + with patch("esphome.net_retry.http_request"): + _, _, installable = pf._uri_jobs( + m, + [_FakeSpec(uri="git+https://x/derived#v2", name="derived")], + set(), + trusted_names=True, + ) + assert [n for n, _ in installable] == ["derived"] + + m.get_package.return_value = object() # already installed: warm and silent + with patch("esphome.net_retry.http_request"): + assert pf._uri_jobs( + m, + [ + _FakeSpec( + uri="git+https://x/tool.git#1.0", name="tool", custom_name=True + ) + ], + set(), + ) == ([], 0, []) + + def test_uri_jobs_head_failure_counts_as_unresolved( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -750,6 +807,11 @@ def test_uri_jobs_skips_installed_cached_and_seen(tmp_path: Path) -> None: jobs, failed, installable = pf._uri_jobs(m, spec, set()) assert (jobs, failed) == ([], 0) assert [n for n, _ in installable] == ["a"] + # a cached platform archive with a derived name is trusted the same way + derived = [_FakeSpec(uri="https://x/a.zip", name="a")] + assert pf._uri_jobs(m, derived, set())[2] == [] + _, _, installable = pf._uri_jobs(m, derived, set(), trusted_names=True) + assert [n for n, _ in installable] == ["a"] dl.unlink() # a registry job already claimed this download path assert pf._uri_jobs(m, spec, {str(dl)}) == ([], 0, []) @@ -875,6 +937,44 @@ def test_dependency_entries_filter_seen_names(tmp_path: Path) -> None: assert pf._dependency_entries(m, [("top@1", _FakeSpec(name="top"))], {"dep"}) == [] +def test_preinstall_widens_the_pool_for_clones( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Network-bound clones all run at once even when the CPU-sized + extraction pool would serialize them; the barrier deadlocks otherwise.""" + m = _fake_manager(tmp_path) + monkeypatch.setattr(pf, "extract_workers", lambda jobs=None: 1) + barrier = threading.Barrier(3) + m._install.side_effect = lambda spec, **kw: barrier.wait(timeout=5) + pf._preinstall( + m, + [ + (f"g{i}", _FakeSpec(uri=f"git+https://x/g{i}.git", name=f"g{i}")) + for i in range(3) + ], + ) + assert barrier.broken is False + + +def test_preinstall_orders_clones_before_extractions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A clone must not queue behind CPU-bound archive extractions.""" + m = _fake_manager(tmp_path) + monkeypatch.setattr(pf, "extract_workers", lambda jobs=None: 1) + calls: list[str] = [] + m._install.side_effect = lambda spec, **kw: calls.append(spec.name) + pf._preinstall( + m, + [ + ("a@1", _FakeSpec(name="a")), + ("b@1", _FakeSpec(name="b")), + ("g@1", _FakeSpec(uri="git+https://x/g.git", name="g")), + ], + ) + assert calls[0] == "g" + + def test_preinstall_cleanup_cannot_displace_the_inflight_error( tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1890,3 +1990,9 @@ def test_platformio_private_api_contract() -> None: derived = PackageSpec("https://x/y/archive/master.zip") assert derived.name and not derived.has_custom_name() assert PackageSpec("Foo=https://x/y/archive/master.zip").has_custom_name() + # _is_vcs_spec_uri relies on bare .git URLs normalizing to git+, on + # both parse paths (raw string, and requirements= for platform tools) + assert PackageSpec("https://github.com/x/y.git#v1").uri.startswith("git+") + assert PackageSpec( + owner="o", name="tool-x", requirements="https://github.com/x/y.git" + ).uri.startswith("git+") From 3296bccd3cbfcc49c4a1995c17e761d2d5436608 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 10:08:25 -1000 Subject: [PATCH 022/435] [api] Keep the protobuf write cursor in a register and emit sub-message tags inline (#20124) --- esphome/components/api/api_connection.cpp | 3 +- esphome/components/api/api_connection.h | 2 +- .../components/api/api_connection_buffer.h | 4 +- esphome/components/api/api_pb2.cpp | 356 ++++++--------- esphome/components/api/api_pb2.h | 416 +++++++++--------- esphome/components/api/api_wizard.cpp | 3 +- esphome/components/api/api_wizard.h | 2 +- esphome/components/api/proto.cpp | 70 ++- esphome/components/api/proto.h | 71 ++- script/api_protobuf/api_protobuf.py | 12 +- .../components/api/test_proto_sub_message.cpp | 88 ++++ tests/components/api/test_wizard.cpp | 6 +- 12 files changed, 495 insertions(+), 538 deletions(-) create mode 100644 tests/components/api/test_proto_sub_message.cpp diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c5457d9482a..7d56b1ce583 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2352,8 +2352,7 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, #endif // Capacity reserved above, cannot fail (void) shared_buf.resize(write_start + payload_size); - ProtoWriteBuffer buffer{&shared_buf, write_start}; - uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + uint8_t *end = encode_fn(msg, shared_buf.data() + write_start PROTO_ENCODE_DEBUG_INIT(&shared_buf)); #ifdef ESPHOME_DEBUG_API proto_check_encode_end(end, shared_buf.data() + shared_buf.size()); #else diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 84f8ec95970..4d927b28857 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -361,7 +361,7 @@ class APIConnection final : public APIServerConnectionBase { void on_no_setup_connection(); // Function pointer type for type-erased message encoding - using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM); + using MessageEncodeFn = ProtoEncodeFn; // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h index 5fbe171b523..9b05da76e5d 100644 --- a/esphome/components/api/api_connection_buffer.h +++ b/esphome/components/api/api_connection_buffer.h @@ -45,8 +45,8 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c conn->fatal_out_of_memory_(); return 0; } - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + uint8_t *end = + encode_fn(msg, shared_buf.data() + shared_buf.size() - calculated_size PROTO_ENCODE_DEBUG_INIT(&shared_buf)); #ifdef ESPHOME_DEBUG_API // A body that writes fewer bytes than calculate_size() promised would ship stale buffer bytes proto_check_encode_end(end, shared_buf.data() + shared_buf.size()); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 58ae9184e21..67a5d9792e5 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -27,9 +27,8 @@ void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, p #endif } } -uint8_t *HelloResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *HelloResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.api_version_major); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.api_version_minor); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); @@ -54,9 +53,8 @@ void DisconnectRequest::decode_field(void *self, uint32_t tag, const uint8_t *da break; } } -uint8_t *DisconnectRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *DisconnectRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.reason)); return pos; } @@ -67,9 +65,8 @@ uint32_t DisconnectRequest::calc_size_msg(const void *self) { return size; } #ifdef USE_AREAS -uint8_t *AreaInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *AreaInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.area_id); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); return pos; @@ -83,9 +80,8 @@ uint32_t AreaInfo::calc_size_msg(const void *self) { } #endif #ifdef USE_DEVICES -uint8_t *DeviceInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *DeviceInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.area_id); @@ -101,9 +97,8 @@ uint32_t DeviceInfo::calc_size_msg(const void *self) { } #endif #ifdef USE_SERIAL_PROXY -uint8_t *SerialProxyInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *SerialProxyInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.port_type)); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.configured_line_states); @@ -118,9 +113,8 @@ uint32_t SerialProxyInfo::calc_size_msg(const void *self) { return size; } #endif -uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.mac_address); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); @@ -157,16 +151,16 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buff #endif #ifdef USE_DEVICES for (const auto &it : msg.devices) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 20, it); } #endif #ifdef USE_AREAS for (const auto &it : msg.areas) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 21, it); } #endif #ifdef USE_AREAS - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, msg.area); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 22, msg.area); #endif #ifdef USE_ZWAVE_PROXY pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, msg.zwave_proxy_feature_flags); @@ -176,7 +170,7 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buff #endif #ifdef USE_SERIAL_PROXY for (const auto &it : msg.serial_proxies) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 25, it); } #endif #ifdef USE_API_NOISE @@ -257,9 +251,8 @@ uint32_t DeviceInfoResponse::calc_size_msg(const void *self) { return size; } #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothProxyCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothProxyCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.mac_address); return pos; @@ -273,9 +266,8 @@ uint32_t BluetoothProxyCapabilities::calc_size_msg(const void *self) { } #endif #ifdef USE_VOICE_ASSISTANT -uint8_t *VoiceAssistantCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *VoiceAssistantCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); return pos; } @@ -287,9 +279,8 @@ uint32_t VoiceAssistantCapabilities::calc_size_msg(const void *self) { } #endif #ifdef USE_ZWAVE_PROXY -uint8_t *ZWaveProxyCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ZWaveProxyCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.home_id); return pos; @@ -303,9 +294,8 @@ uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { } #endif #ifdef USE_API_WIZARD -uint8_t *WizardCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *WizardCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.configured); return pos; } @@ -316,25 +306,24 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { return size; } #endif -uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_BLUETOOTH_PROXY - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, msg.bluetooth_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.bluetooth_proxy); #endif #ifdef USE_VOICE_ASSISTANT - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, msg.voice_assistant); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.voice_assistant); #endif #ifdef USE_ZWAVE_PROXY - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, msg.zwave_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.zwave_proxy); #endif #ifdef USE_SERIAL_PROXY for (const auto &it : msg.serial_proxies) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, it); } #endif #ifdef USE_API_WIZARD - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 5, msg.wizard); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard); #endif return pos; } @@ -361,9 +350,8 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { return size; } #ifdef USE_API_WIZARD -uint8_t *DeviceWizardResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *DeviceWizardResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); return pos; } @@ -390,9 +378,8 @@ void WizardInputSetRequest::decode_field(void *self, uint32_t tag, const uint8_t #endif #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -426,9 +413,8 @@ uint32_t ListEntitiesBinarySensorResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *BinarySensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BinarySensorStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); @@ -450,9 +436,8 @@ uint32_t BinarySensorStateResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_COVER -uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -492,9 +477,8 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *CoverStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); if (uint32_t raw = float_to_raw(msg.position); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); @@ -551,9 +535,8 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * } #endif #ifdef USE_FAN -uint8_t *ListEntitiesFanResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesFanResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -599,9 +582,8 @@ uint32_t ListEntitiesFanResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *FanStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *FanStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.oscillating); @@ -673,9 +655,8 @@ void FanCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *da } #endif #ifdef USE_LIGHT -uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -727,9 +708,8 @@ uint32_t ListEntitiesLightResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *LightStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *LightStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); if (uint32_t raw = float_to_raw(msg.brightness); raw != 0) [[likely]] { @@ -881,9 +861,8 @@ void LightCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * } #endif #ifdef USE_SENSOR -uint8_t *ListEntitiesSensorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesSensorResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -925,9 +904,8 @@ uint32_t ListEntitiesSensorResponse::calc_size_msg(const void *self) { } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +SensorStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); if (uint32_t raw = float_to_raw(msg.state); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); @@ -953,9 +931,8 @@ SensorStateResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_SWITCH -uint8_t *ListEntitiesSwitchResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesSwitchResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -989,9 +966,8 @@ uint32_t ListEntitiesSwitchResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *SwitchStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *SwitchStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); #ifdef USE_DEVICES @@ -1031,9 +1007,8 @@ void SwitchCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t #endif #ifdef USE_TEXT_SENSOR uint8_t *ListEntitiesTextSensorResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -1065,9 +1040,8 @@ uint32_t ListEntitiesTextSensorResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *TextSensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *TextSensorStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); @@ -1102,9 +1076,8 @@ void SubscribeLogsRequest::decode_field(void *self, uint32_t tag, const uint8_t } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SubscribeLogsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +SubscribeLogsResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.level)); pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.message_len_); @@ -1133,9 +1106,8 @@ void NoiseEncryptionSetKeyRequest::decode_field(void *self, uint32_t tag, const } } uint8_t *NoiseEncryptionSetKeyResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.success); return pos; } @@ -1147,9 +1119,8 @@ uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -uint8_t *HomeassistantServiceMap::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *HomeassistantServiceMap::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); return pos; @@ -1161,18 +1132,17 @@ uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.value.size()); return size; } -uint8_t *HomeassistantActionRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *HomeassistantActionRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); for (auto &it : msg.data) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } for (auto &it : msg.data_template) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } for (auto &it : msg.variables) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, it); } pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -1244,9 +1214,8 @@ void HomeassistantActionResponse::decode_field(void *self, uint32_t tag, const u #endif #ifdef USE_API_HOMEASSISTANT_STATES uint8_t *SubscribeHomeAssistantStateResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.entity_id); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.attribute); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.once); @@ -1333,9 +1302,9 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data } } #ifdef USE_API_USER_DEFINED_ACTIONS -uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA @@ -1359,13 +1328,13 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { #endif return size; } -uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); for (auto &it : msg.args) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA @@ -1463,9 +1432,8 @@ void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { } #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES -uint8_t *ExecuteServiceResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ExecuteServiceResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.call_id); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error_message); @@ -1487,9 +1455,8 @@ uint32_t ExecuteServiceResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_CAMERA -uint8_t *ListEntitiesCameraResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesCameraResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -1519,9 +1486,8 @@ uint32_t ListEntitiesCameraResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *CameraImageResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *CameraImageResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.done); @@ -1555,9 +1521,8 @@ void CameraImageRequest::decode_field(void *self, uint32_t tag, const uint8_t *d } #endif #ifdef USE_CLIMATE -uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -1659,9 +1624,8 @@ uint32_t ListEntitiesClimateResponse::calc_size_msg(const void *self) { size += msg.temperature_unit ? 3 : 0; return size; } -uint8_t *ClimateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ClimateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { @@ -1794,9 +1758,8 @@ void ClimateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t #endif #ifdef USE_WATER_HEATER uint8_t *ListEntitiesWaterHeaterResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -1848,9 +1811,8 @@ uint32_t ListEntitiesWaterHeaterResponse::calc_size_msg(const void *self) { size += msg.temperature_unit ? 2 : 0; return size; } -uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); @@ -1923,9 +1885,8 @@ void WaterHeaterCommandRequest::decode_field(void *self, uint32_t tag, const uin } #endif #ifdef USE_NUMBER -uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -1973,9 +1934,8 @@ uint32_t ListEntitiesNumberResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *NumberStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *NumberStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); if (uint32_t raw = float_to_raw(msg.state); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); @@ -2016,9 +1976,8 @@ void NumberCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t } #endif #ifdef USE_SELECT -uint8_t *ListEntitiesSelectResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesSelectResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -2056,9 +2015,8 @@ uint32_t ListEntitiesSelectResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *SelectStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *SelectStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); @@ -2097,9 +2055,8 @@ void SelectCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t } #endif #ifdef USE_SIREN -uint8_t *ListEntitiesSirenResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesSirenResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -2141,9 +2098,8 @@ uint32_t ListEntitiesSirenResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *SirenStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *SirenStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); #ifdef USE_DEVICES @@ -2201,9 +2157,8 @@ void SirenCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * } #endif #ifdef USE_LOCK -uint8_t *ListEntitiesLockResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesLockResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -2241,9 +2196,8 @@ uint32_t ListEntitiesLockResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *LockStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *LockStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES @@ -2286,9 +2240,8 @@ void LockCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d } #endif #ifdef USE_BUTTON -uint8_t *ListEntitiesButtonResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesButtonResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -2336,9 +2289,8 @@ void ButtonCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t } #endif #ifdef USE_MEDIA_PLAYER -uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.format); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.sample_rate); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.num_channels); @@ -2357,9 +2309,8 @@ uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) { return size; } uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -2369,7 +2320,7 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); for (auto &it : msg.supported_formats) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 9, it); } #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); @@ -2399,9 +2350,8 @@ uint32_t ListEntitiesMediaPlayerResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.feature_flags); return size; } -uint8_t *MediaPlayerStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *MediaPlayerStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); if (uint32_t raw = float_to_raw(msg.volume); raw != 0) [[likely]] { @@ -2478,9 +2428,8 @@ void SubscribeBluetoothLEAdvertisementsRequest::decode_field(void *self, uint32_ } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -BluetoothLERawAdvertisementsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +BluetoothLERawAdvertisementsResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < msg.advertisements_len; i++) { auto &sub_msg = msg.advertisements[i]; pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); @@ -2537,9 +2486,8 @@ void BluetoothDeviceRequest::decode_field(void *self, uint32_t tag, const uint8_ } } uint8_t *BluetoothDeviceConnectionResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.connected); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.mtu); @@ -2565,9 +2513,8 @@ void BluetoothGATTGetServicesRequest::decode_field(void *self, uint32_t tag, con break; } } -uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); @@ -2587,9 +2534,8 @@ uint32_t BluetoothGATTDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTCharacteristic::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothGATTCharacteristic::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); @@ -2597,7 +2543,7 @@ uint8_t *BluetoothGATTCharacteristic::encode_msg(const void *self, ProtoWriteBuf pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.properties); for (auto &it : msg.descriptors) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, it); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.short_uuid); return pos; @@ -2619,16 +2565,15 @@ uint32_t BluetoothGATTCharacteristic::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTService::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothGATTService::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); for (auto &it : msg.characteristics) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.short_uuid); return pos; @@ -2650,12 +2595,11 @@ uint32_t BluetoothGATTService::calc_size_msg(const void *self) { return size; } uint8_t *BluetoothGATTGetServicesResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); for (auto &it : msg.services) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } return pos; } @@ -2671,9 +2615,8 @@ uint32_t BluetoothGATTGetServicesResponse::calc_size_msg(const void *self) { return size; } uint8_t *BluetoothGATTGetServicesDoneResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); return pos; } @@ -2696,9 +2639,8 @@ void BluetoothGATTReadRequest::decode_field(void *self, uint32_t tag, const uint break; } } -uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); @@ -2779,9 +2721,8 @@ void BluetoothGATTNotifyRequest::decode_field(void *self, uint32_t tag, const ui } } uint8_t *BluetoothGATTNotifyDataResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); @@ -2796,9 +2737,8 @@ uint32_t BluetoothGATTNotifyDataResponse::calc_size_msg(const void *self) { return size; } uint8_t *BluetoothConnectionsFreeResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.free); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.limit); for (const auto &it : msg.allocated) { @@ -2820,9 +2760,8 @@ uint32_t BluetoothConnectionsFreeResponse::calc_size_msg(const void *self) { } return size; } -uint8_t *BluetoothGATTErrorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothGATTErrorResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); @@ -2836,9 +2775,8 @@ uint32_t BluetoothGATTErrorResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothGATTWriteResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothGATTWriteResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; @@ -2850,9 +2788,8 @@ uint32_t BluetoothGATTWriteResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.handle); return size; } -uint8_t *BluetoothGATTNotifyResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *BluetoothGATTNotifyResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; @@ -2865,9 +2802,8 @@ uint32_t BluetoothGATTNotifyResponse::calc_size_msg(const void *self) { return size; } uint8_t *BluetoothDevicePairingResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.paired); pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); @@ -2882,9 +2818,8 @@ uint32_t BluetoothDevicePairingResponse::calc_size_msg(const void *self) { return size; } uint8_t *BluetoothDeviceUnpairingResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); @@ -2899,9 +2834,8 @@ uint32_t BluetoothDeviceUnpairingResponse::calc_size_msg(const void *self) { return size; } uint8_t *BluetoothDeviceClearCacheResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); @@ -2918,9 +2852,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calc_size_msg(const void *self) { #endif #ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothScannerStateResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.state)); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.configured_mode)); @@ -2959,9 +2892,8 @@ void SubscribeVoiceAssistantRequest::decode_field(void *self, uint32_t tag, cons break; } } -uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.noise_suppression_level); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.auto_gain); if (uint32_t raw = float_to_raw(msg.volume_multiplier); raw != 0) [[likely]] { @@ -2977,13 +2909,12 @@ uint32_t VoiceAssistantAudioSettings::calc_size_msg(const void *self) { size += ProtoSize::calc_float(1, msg.volume_multiplier); return size; } -uint8_t *VoiceAssistantRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *VoiceAssistantRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.start); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.conversation_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, msg.audio_settings); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.audio_settings); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wake_word_phrase); return pos; } @@ -3052,9 +2983,8 @@ void VoiceAssistantAudio::decode_field(void *self, uint32_t tag, const uint8_t * break; } } -uint8_t *VoiceAssistantAudio::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *VoiceAssistantAudio::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.end); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data2, msg.data2_len); @@ -3113,9 +3043,8 @@ void VoiceAssistantAnnounceRequest::decode_field(void *self, uint32_t tag, const } } uint8_t *VoiceAssistantAnnounceFinished::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.success); return pos; } @@ -3125,9 +3054,8 @@ uint32_t VoiceAssistantAnnounceFinished::calc_size_msg(const void *self) { size += ProtoSize::calc_bool(1, msg.success); return size; } -uint8_t *VoiceAssistantWakeWord::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *VoiceAssistantWakeWord::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.id); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.wake_word); for (auto &it : msg.trained_languages) { @@ -3187,11 +3115,10 @@ void VoiceAssistantConfigurationRequest::decode_field(void *self, uint32_t tag, } } uint8_t *VoiceAssistantConfigurationResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); for (auto &it : msg.available_wake_words) { - pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 1, it); } for (const auto &it : *msg.active_wake_words) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 2, it); @@ -3228,9 +3155,8 @@ void VoiceAssistantSetConfiguration::decode_field(void *self, uint32_t tag, cons #endif #ifdef USE_ALARM_CONTROL_PANEL uint8_t *ListEntitiesAlarmControlPanelResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3267,9 +3193,8 @@ uint32_t ListEntitiesAlarmControlPanelResponse::calc_size_msg(const void *self) return size; } uint8_t *AlarmControlPanelStateResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES @@ -3310,9 +3235,8 @@ void AlarmControlPanelCommandRequest::decode_field(void *self, uint32_t tag, con } #endif #ifdef USE_TEXT -uint8_t *ListEntitiesTextResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesTextResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3350,9 +3274,8 @@ uint32_t ListEntitiesTextResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *TextStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *TextStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); @@ -3391,9 +3314,8 @@ void TextCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d } #endif #ifdef USE_DATETIME_DATE -uint8_t *ListEntitiesDateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesDateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3423,9 +3345,8 @@ uint32_t ListEntitiesDateResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *DateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *DateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.year); @@ -3474,9 +3395,8 @@ void DateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d } #endif #ifdef USE_DATETIME_TIME -uint8_t *ListEntitiesTimeResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesTimeResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3506,9 +3426,8 @@ uint32_t ListEntitiesTimeResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *TimeStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *TimeStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.hour); @@ -3557,9 +3476,8 @@ void TimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d } #endif #ifdef USE_EVENT -uint8_t *ListEntitiesEventResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesEventResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3599,9 +3517,8 @@ uint32_t ListEntitiesEventResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *EventResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *EventResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.event_type); #ifdef USE_DEVICES @@ -3621,9 +3538,8 @@ uint32_t EventResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_VALVE -uint8_t *ListEntitiesValveResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesValveResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3661,9 +3577,8 @@ uint32_t ListEntitiesValveResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *ValveStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ValveStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); if (uint32_t raw = float_to_raw(msg.position); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); @@ -3710,9 +3625,9 @@ void ValveCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * } #endif #ifdef USE_DATETIME_DATETIME -uint8_t *ListEntitiesDateTimeResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesDateTimeResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3742,9 +3657,8 @@ uint32_t ListEntitiesDateTimeResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *DateTimeStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *DateTimeStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); if (uint32_t raw = msg.epoch_seconds; raw != 0) [[likely]] { @@ -3785,9 +3699,8 @@ void DateTimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_ } #endif #ifdef USE_UPDATE -uint8_t *ListEntitiesUpdateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesUpdateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -3819,9 +3732,8 @@ uint32_t ListEntitiesUpdateResponse::calc_size_msg(const void *self) { #endif return size; } -uint8_t *UpdateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *UpdateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.in_progress); @@ -3888,9 +3800,8 @@ void ZWaveProxyFrame::decode_field(void *self, uint32_t tag, const uint8_t *data } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -ZWaveProxyFrame::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +ZWaveProxyFrame::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); return pos; } @@ -3915,9 +3826,8 @@ void ZWaveProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *da break; } } -uint8_t *ZWaveProxyRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ZWaveProxyRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data, msg.data_len); return pos; @@ -3929,9 +3839,8 @@ uint32_t ZWaveProxyRequest::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data_len); return size; } -uint8_t *ZWaveProxyRequestResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ZWaveProxyRequestResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.status)); return pos; @@ -3945,9 +3854,9 @@ uint32_t ZWaveProxyRequestResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_INFRARED -uint8_t *ListEntitiesInfraredResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *ListEntitiesInfraredResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -4014,9 +3923,8 @@ void InfraredRFTransmitRawTimingsRequest::decode_field(void *self, uint32_t tag, } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -InfraredRFReceiveEvent::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +InfraredRFReceiveEvent::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); #endif @@ -4043,9 +3951,8 @@ InfraredRFReceiveEvent::calc_size_msg(const void *self) { return size; } uint8_t *InfraredRFTransmitCompleteResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); #endif @@ -4066,9 +3973,8 @@ uint32_t InfraredRFTransmitCompleteResponse::calc_size_msg(const void *self) { #endif #ifdef USE_RADIO_FREQUENCY uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); @@ -4135,9 +4041,8 @@ void SerialProxyConfigureRequest::decode_field(void *self, uint32_t tag, const u } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SerialProxyDataReceived::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +SerialProxyDataReceived::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); return pos; @@ -4188,9 +4093,8 @@ void SerialProxyGetModemPinsRequest::decode_field(void *self, uint32_t tag, cons } } uint8_t *SerialProxyGetModemPinsResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.line_states); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); @@ -4216,9 +4120,8 @@ void SerialProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *d break; } } -uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); @@ -4247,9 +4150,8 @@ void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uin break; } } -uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.vendor_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); @@ -4265,16 +4167,15 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.interface_number); return size; } -uint8_t *SerialProxyIdentity::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.source)); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.manufacturer); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.product); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.serial_number); - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 7, msg.usb); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.usb); return pos; } uint32_t SerialProxyIdentity::calc_size_msg(const void *self) { @@ -4314,9 +4215,8 @@ void BluetoothSetConnectionParamsRequest::decode_field(void *self, uint32_t tag, } } uint8_t *BluetoothSetConnectionParamsResponse::encode_msg(const void *self, - ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.error); return pos; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index faa9c979fcc..9ad114b2ca1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -459,9 +459,9 @@ class HelloResponse final : public ProtoMessage { uint32_t api_version_minor{0}; StringRef server_info{}; StringRef name{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -482,9 +482,9 @@ class DisconnectRequest final : public ProtoDecodableMessage { void decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -539,9 +539,9 @@ class AreaInfo final : public ProtoMessage { public: uint32_t area_id{0}; StringRef name{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -558,9 +558,9 @@ class DeviceInfo final : public ProtoMessage { uint32_t device_id{0}; StringRef name{}; uint32_t area_id{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -577,9 +577,9 @@ class SerialProxyInfo final : public ProtoMessage { StringRef name{nullptr, 0}; // null until set, encode only enums::SerialProxyPortType port_type{}; uint32_t configured_line_states{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -655,9 +655,9 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_API_OUTGOING_CONNECTION bool api_outgoing_connection_supported{false}; #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -672,9 +672,9 @@ class BluetoothProxyCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; StringRef mac_address{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -689,9 +689,9 @@ class BluetoothProxyCapabilities final : public ProtoMessage { class VoiceAssistantCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -707,9 +707,9 @@ class ZWaveProxyCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; uint32_t home_id{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -724,9 +724,9 @@ class ZWaveProxyCapabilities final : public ProtoMessage { class WizardCapabilities final : public ProtoMessage { public: bool configured{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -759,9 +759,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #ifdef USE_API_WIZARD WizardCapabilities wizard{}; #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -781,9 +781,9 @@ class DeviceWizardResponse final : public ProtoMessage { #endif const uint8_t *data{nullptr}; uint16_t data_len{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -838,9 +838,9 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { #endif StringRef device_class{nullptr, 0}; // null until set, encode only bool is_status_binary_sensor{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -859,9 +859,9 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { #endif bool state{false}; bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -885,9 +885,9 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { bool supports_tilt{false}; StringRef device_class{nullptr, 0}; // null until set, encode only bool supports_stop{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -907,9 +907,9 @@ class CoverStateResponse final : public StateResponseProtoMessage { float position{0.0f}; float tilt{0.0f}; enums::CoverOperation current_operation{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -955,9 +955,9 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage { bool supports_direction{false}; int32_t supported_speed_count{0}; const std::vector *supported_preset_modes{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -979,9 +979,9 @@ class FanStateResponse final : public StateResponseProtoMessage { enums::FanDirection direction{}; int32_t speed_level{0}; StringRef preset_mode{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1031,9 +1031,9 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { float min_mireds{0.0f}; float max_mireds{0.0f}; const FixedVector *effects{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1062,9 +1062,9 @@ class LightStateResponse final : public StateResponseProtoMessage { float cold_white{0.0f}; float warm_white{0.0f}; StringRef effect{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1131,9 +1131,9 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { bool force_update{false}; StringRef device_class{nullptr, 0}; // null until set, encode only enums::SensorStateClass state_class{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1152,9 +1152,9 @@ class SensorStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1175,9 +1175,9 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { #endif bool assumed_state{false}; StringRef device_class{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1196,9 +1196,9 @@ class SwitchStateResponse final : public StateResponseProtoMessage { #endif bool state{false}; bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1236,9 +1236,9 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } #endif StringRef device_class{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1257,9 +1257,9 @@ class TextSensorStateResponse final : public StateResponseProtoMessage { #endif StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1303,9 +1303,9 @@ class SubscribeLogsResponse final : public ProtoMessage { this->message_ptr_ = data; this->message_len_ = len; } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1343,9 +1343,9 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_response"); } #endif bool success{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1361,9 +1361,9 @@ class HomeassistantServiceMap final : public ProtoMessage { public: StringRef key{nullptr, 0}; // null until set, encode only StringRef value{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1394,9 +1394,9 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON StringRef response_template{nullptr, 0}; // null until set, encode only #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1444,9 +1444,9 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { StringRef entity_id{nullptr, 0}; // null until set, encode only StringRef attribute{nullptr, 0}; // null until set, encode only bool once{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1555,9 +1555,9 @@ class ListEntitiesServicesArgument final : public ProtoMessage { #ifdef USE_API_USER_DEFINED_ACTION_METADATA StringRef example{nullptr, 0}; // null until set, encode only #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1581,9 +1581,9 @@ class ListEntitiesServicesResponse final : public ProtoMessage { #ifdef USE_API_USER_DEFINED_ACTION_METADATA StringRef description{nullptr, 0}; // null until set, encode only #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1651,9 +1651,9 @@ class ExecuteServiceResponse final : public ProtoMessage { const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1672,9 +1672,9 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_camera_response"); } #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1698,9 +1698,9 @@ class CameraImageResponse final : public StateResponseProtoMessage { this->data_len_ = len; } bool done{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1757,9 +1757,9 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { float visual_max_humidity{0.0f}; uint32_t feature_flags{0}; enums::TemperatureUnit temperature_unit{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1790,9 +1790,9 @@ class ClimateStateResponse final : public StateResponseProtoMessage { float current_humidity{0.0f}; float target_humidity{0.0f}; bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1854,9 +1854,9 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { const water_heater::WaterHeaterModeMask *supported_modes{}; uint32_t supported_features{0}; enums::TemperatureUnit temperature_unit{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1880,9 +1880,9 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1930,9 +1930,9 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { StringRef unit_of_measurement{nullptr, 0}; // null until set, encode only enums::NumberMode mode{}; StringRef device_class{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1951,9 +1951,9 @@ class NumberStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -1991,9 +1991,9 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); } #endif const std::span *options{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2012,9 +2012,9 @@ class SelectStateResponse final : public StateResponseProtoMessage { #endif StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2054,9 +2054,9 @@ class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { const FixedVector *tones{}; bool supports_duration{false}; bool supports_volume{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2074,9 +2074,9 @@ class SirenStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("siren_state_response"); } #endif bool state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2124,9 +2124,9 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage { bool supports_open{false}; bool requires_code{false}; StringRef code_format{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2144,9 +2144,9 @@ class LockStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("lock_state_response"); } #endif enums::LockState state{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2186,9 +2186,9 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } #endif StringRef device_class{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2224,9 +2224,9 @@ class MediaPlayerSupportedFormat final : public ProtoMessage { uint32_t num_channels{0}; enums::MediaPlayerFormatPurpose purpose{}; uint32_t sample_bytes{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2245,9 +2245,9 @@ class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { #endif std::vector supported_formats{}; uint32_t feature_flags{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2267,9 +2267,9 @@ class MediaPlayerStateResponse final : public StateResponseProtoMessage { enums::MediaPlayerState state{}; float volume{0.0f}; bool muted{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2346,9 +2346,9 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { #endif std::array advertisements{}; uint16_t advertisements_len{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2392,9 +2392,9 @@ class BluetoothDeviceConnectionResponse final : public ProtoMessage { bool connected{false}; uint32_t mtu{0}; int32_t error{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2427,9 +2427,9 @@ class BluetoothGATTDescriptor final : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; uint32_t short_uuid{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2446,9 +2446,9 @@ class BluetoothGATTCharacteristic final : public ProtoMessage { uint32_t properties{0}; FixedVector descriptors{}; uint32_t short_uuid{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2464,9 +2464,9 @@ class BluetoothGATTService final : public ProtoMessage { uint32_t handle{0}; FixedVector characteristics{}; uint32_t short_uuid{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2485,9 +2485,9 @@ class BluetoothGATTGetServicesResponse final : public ProtoMessage { #endif uint64_t address{0}; std::vector services{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2505,9 +2505,9 @@ class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_done_response"); } #endif uint64_t address{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2551,9 +2551,9 @@ class BluetoothGATTReadResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2660,9 +2660,9 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2682,9 +2682,9 @@ class BluetoothConnectionsFreeResponse final : public ProtoMessage { uint32_t free{0}; uint32_t limit{0}; std::array allocated{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2704,9 +2704,9 @@ class BluetoothGATTErrorResponse final : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; int32_t error{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2725,9 +2725,9 @@ class BluetoothGATTWriteResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2746,9 +2746,9 @@ class BluetoothGATTNotifyResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2768,9 +2768,9 @@ class BluetoothDevicePairingResponse final : public ProtoMessage { uint64_t address{0}; bool paired{false}; int32_t error{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2790,9 +2790,9 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2812,9 +2812,9 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2836,9 +2836,9 @@ class BluetoothScannerStateResponse final : public ProtoMessage { enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; enums::BluetoothScannerMode configured_mode{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2892,9 +2892,9 @@ class VoiceAssistantAudioSettings final : public ProtoMessage { uint32_t noise_suppression_level{0}; uint32_t auto_gain{0}; float volume_multiplier{0.0f}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2916,9 +2916,9 @@ class VoiceAssistantRequest final : public ProtoMessage { uint32_t flags{0}; VoiceAssistantAudioSettings audio_settings{}; StringRef wake_word_phrase{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -2995,9 +2995,9 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { void decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3060,9 +3060,9 @@ class VoiceAssistantAnnounceFinished final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_finished"); } #endif bool success{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3077,9 +3077,9 @@ class VoiceAssistantWakeWord final : public ProtoMessage { StringRef id{nullptr, 0}; // null until set, encode only StringRef wake_word{nullptr, 0}; // null until set, encode only std::vector trained_languages{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3136,9 +3136,9 @@ class VoiceAssistantConfigurationResponse final : public ProtoMessage { std::vector available_wake_words{}; const std::vector *active_wake_words{}; uint32_t max_active_wake_words{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3178,9 +3178,9 @@ class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMess uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3198,9 +3198,9 @@ class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("alarm_control_panel_state_response"); } #endif enums::AlarmControlPanelState state{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3242,9 +3242,9 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage { uint32_t max_length{0}; StringRef pattern{nullptr, 0}; // null until set, encode only enums::TextMode mode{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3263,9 +3263,9 @@ class TextStateResponse final : public StateResponseProtoMessage { #endif StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3302,9 +3302,9 @@ class ListEntitiesDateResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_response"); } #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3325,9 +3325,9 @@ class DateStateResponse final : public StateResponseProtoMessage { uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3366,9 +3366,9 @@ class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_time_response"); } #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3389,9 +3389,9 @@ class TimeStateResponse final : public StateResponseProtoMessage { uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3432,9 +3432,9 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { #endif StringRef device_class{nullptr, 0}; // null until set, encode only const FixedVector *event_types{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3452,9 +3452,9 @@ class EventResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("event_response"); } #endif StringRef event_type{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3477,9 +3477,9 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3498,9 +3498,9 @@ class ValveStateResponse final : public StateResponseProtoMessage { #endif float position{0.0f}; enums::ValveOperation current_operation{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3539,9 +3539,9 @@ class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_time_response"); } #endif - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3560,9 +3560,9 @@ class DateTimeStateResponse final : public StateResponseProtoMessage { #endif bool missing_state{false}; uint32_t epoch_seconds{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3600,9 +3600,9 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } #endif StringRef device_class{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3628,9 +3628,9 @@ class UpdateStateResponse final : public StateResponseProtoMessage { StringRef title{nullptr, 0}; // null until set, encode only StringRef release_summary{nullptr, 0}; // null until set, encode only StringRef release_url{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3672,9 +3672,9 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { void decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3698,9 +3698,9 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { void decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3720,9 +3720,9 @@ class ZWaveProxyRequestResponse final : public ProtoMessage { #endif enums::ZWaveProxyRequestType type{}; enums::ZWaveProxyStatus status{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3743,9 +3743,9 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { #endif uint32_t capabilities{0}; uint32_t receiver_frequency{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3796,9 +3796,9 @@ class InfraredRFReceiveEvent final : public ProtoMessage { #endif uint32_t key{0}; const std::vector *timings{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3820,9 +3820,9 @@ class InfraredRFTransmitCompleteResponse final : public ProtoMessage { #endif uint32_t key{0}; bool success{false}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3845,9 +3845,9 @@ class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage uint32_t frequency_min{0}; uint32_t frequency_max{0}; uint32_t supported_modulations{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3896,9 +3896,9 @@ class SerialProxyDataReceived final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -3975,9 +3975,9 @@ class SerialProxyGetModemPinsResponse final : public ProtoMessage { uint32_t instance{0}; uint32_t line_states{0}; enums::SerialProxyStatus status{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -4017,9 +4017,9 @@ class SerialProxyRequestResponse final : public ProtoMessage { enums::SerialProxyRequestType type{}; enums::SerialProxyStatus status{}; StringRef error_message{nullptr, 0}; // null until set, encode only - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -4054,9 +4054,9 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t product_id{0}; uint32_t bcd_device{0}; uint32_t interface_number{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -4080,9 +4080,9 @@ class SerialProxyIdentity final : public ProtoMessage { StringRef product{nullptr, 0}; // null until set, encode only StringRef serial_number{nullptr, 0}; // null until set, encode only UsbDeviceDescriptor usb{}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } @@ -4125,9 +4125,9 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { #endif uint64_t address{0}; int32_t error{0}; - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); } static uint32_t calc_size_msg(const void *self); uint32_t calculate_size() const { return calc_size_msg(this); } diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index 7d4e8fdcea9..ec270ab4111 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -13,9 +13,8 @@ namespace esphome::api { static const char *const TAG = "api.wizard"; -uint8_t *wizard_encode_response(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { +uint8_t *wizard_encode_response(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - uint8_t *__restrict__ pos = buffer.get_pos(); if (msg.data_len == 0) return pos; pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 0ba204d3b72..948b9445f22 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -24,7 +24,7 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM; /// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read /// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere. -uint8_t *wizard_encode_response(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); +uint8_t *wizard_encode_response(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); #ifdef USE_API_WIZARD_INPUTS /// Where the entity id of an input is kept, found by the key the client uses for it. diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index fd1a9519dcd..6738d5d8b71 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -119,7 +119,7 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size } // Single-pass encode for repeated submessage elements (non-template core). -// Writes field tag, reserves 1 byte for length varint, encodes the submessage body, +// Reserves 1 byte for length varint, encodes the submessage body, // then backpatches the actual length. For the common case (body < 128 bytes), this is // just a single byte write with no memmove — all current repeated submessage types // (BLE advertisements at ~47B, GATT descriptors at ~24B, service args, etc.) take @@ -143,51 +143,34 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size // // After writing 2-byte varint at len_pos: // [tag][v1][v2][body ..... body] -// ^-- pos_ = element end, within buffer -void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value, - uint8_t *(*encode_fn)(const void *, - ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { - this->encode_field_raw(field_id, 2); - // Reserve 1 byte for length varint (optimistic: submessage < 128 bytes) - uint8_t *len_pos = this->pos_; - this->debug_check_bounds_(1); - this->pos_++; - uint8_t *body_start = this->pos_; - this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); - uint32_t body_size = static_cast(this->pos_ - body_start); - if (body_size < 128) [[likely]] { +// ^-- returned cursor = element end, within buffer +uint8_t *ProtoEncode::encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *value, + ProtoEncodeFn encode_fn) { + // Reserve 1 byte for the length varint (optimistic: submessage < 128 bytes) + uint8_t *len_pos = pos; + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + uint8_t *body_start = pos + 1; + uint8_t *after_body = encode_fn(value, body_start PROTO_ENCODE_DEBUG_ARG); + uint32_t body_size = static_cast(after_body - body_start); + if (body_size < VARINT_MAX_1_BYTE) [[likely]] { // Common case: 1-byte varint, just backpatch *len_pos = static_cast(body_size); - return; + return after_body; } - // Compute extra bytes needed for varint beyond the 1 already reserved + // Shift the body forward to make room for the extra length varint bytes uint8_t extra = ProtoSize::varint(body_size) - 1; - // Shift body forward to make room for the extra varint bytes - this->debug_check_bounds_(extra); + PROTO_ENCODE_CHECK_BOUNDS(after_body, extra); std::memmove(body_start + extra, body_start, body_size); - uint8_t *end = this->pos_ + extra; // Write the full varint at len_pos - this->pos_ = len_pos; - this->encode_varint_raw(body_size); - this->pos_ = end; + (void) encode_varint_raw_loop(len_pos PROTO_ENCODE_DEBUG_ARG, body_size); + return after_body + extra; } // Non-template core for encode_optional_sub_message. -void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value, - uint8_t *(*encode_fn)(const void *, - ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { - if (nested_size == 0) - return; - this->encode_field_raw(field_id, 2); - this->encode_varint_raw(nested_size); -#ifdef ESPHOME_DEBUG_API - uint8_t *start = this->pos_; - this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); - if (static_cast(this->pos_ - start) != nested_size) - this->debug_check_encode_size_(field_id, nested_size, this->pos_ - start); -#else - this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); -#endif +uint8_t *ProtoEncode::encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t nested_size, const void *value, ProtoEncodeFn encode_fn) { + pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, nested_size); + return encode_fn(value, pos PROTO_ENCODE_DEBUG_ARG); } #ifdef ESPHOME_DEBUG_API @@ -201,6 +184,14 @@ void proto_check_encode_end(const uint8_t *end, const uint8_t *expected) { ESP_LOGE(TAG, "Proto encode ended %td bytes off the calculated size", end - expected); abort(); } +void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end) { + ptrdiff_t actual = end - (len_pos + ProtoSize::varint(expected)); + if (actual == static_cast(expected)) + return; + ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id, + expected, actual); + abort(); +} void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) { if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) { ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes, @@ -208,11 +199,6 @@ void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) { abort(); } } -void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual) { - ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id, - expected, actual); - abort(); -} #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 214d9f417fe..5f2a90d5472 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -231,6 +231,8 @@ void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t * /// Aborts unless an encode body ended exactly where calculate_size() promised. A plain check rather than /// assert(), so NDEBUG cannot switch it off. void proto_check_encode_end(const uint8_t *end, const uint8_t *expected); +/// Aborts unless a sized sub-message (length prefix at len_pos) ended where its calculated size said. +void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end); #else #define PROTO_ENCODE_DEBUG_PARAM #define PROTO_ENCODE_DEBUG_ARG @@ -263,21 +265,6 @@ class ProtoWriteBuffer { * Following https://protobuf.dev/programming-guides/encoding/#structure */ void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); } - /// Single-pass encode for repeated submessage elements. - /// Thin template wrapper; all buffer work is in the non-template core. - template void encode_sub_message(uint32_t field_id, const T &value); - /// Encode an optional singular submessage field — skips if empty. - /// Thin template wrapper; all buffer work is in the non-template core. - template void encode_optional_sub_message(uint32_t field_id, const T &value); - - // NOLINTBEGIN(readability-identifier-naming) - // Non-template core for encode_sub_message — backpatch approach. - void encode_sub_message(uint32_t field_id, const void *value, - uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)); - // Non-template core for encode_optional_sub_message. - void encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value, - uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)); - // NOLINTEND(readability-identifier-naming) APIBuffer *get_buffer() const { return buffer_; } uint8_t *get_pos() const { return pos_; } void set_pos(uint8_t *pos) { pos_ = pos; } @@ -288,7 +275,6 @@ class ProtoWriteBuffer { #ifdef ESPHOME_DEBUG_API void debug_check_bounds_(size_t bytes, const char *caller = __builtin_FUNCTION()); - void debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual); #else void debug_check_bounds_([[maybe_unused]] size_t bytes) {} #endif @@ -313,6 +299,9 @@ class ProtoWriteBuffer { constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128 constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384 +/// Generated encode body: writes the fields at pos, returns the cursor past them. +using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM); + /// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and /// returns it advanced, so outlined calls at -Os chain through the return register instead of a /// stack slot. Helpers without a _force suffix skip fields holding the proto3 default. @@ -575,22 +564,36 @@ class ProtoEncode { uint32_t field_id, int64_t value) { return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value)); } - /// Sub-message encoding: sync pos to buffer, delegate, read the cursor back. + /// Repeated sub-message element; the constant tag is written inline. template [[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, - ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) { - buffer.set_pos(pos); - buffer.encode_sub_message(field_id, value); - return buffer.get_pos(); + uint32_t field_id, const T &value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); + return encode_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, &value, &T::encode_msg); } + /// Singular sub-message field, skipped when it encodes to nothing. template [[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, - ProtoWriteBuffer &buffer, uint32_t field_id, - const T &value) { - buffer.set_pos(pos); - buffer.encode_optional_sub_message(field_id, value); - return buffer.get_pos(); + uint32_t field_id, const T &value) { + uint32_t nested_size = T::calc_size_msg(&value); + if (nested_size == 0) + return pos; + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); +#ifdef ESPHOME_DEBUG_API + uint8_t *end = encode_sized_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, nested_size, &value, &T::encode_msg); + proto_check_sub_message_size(field_id, nested_size, pos, end); + return end; +#else + return encode_sized_sub_message_body(pos, nested_size, &value, &T::encode_msg); +#endif } + /// Length and body, length backpatched after the body is written. + [[nodiscard]] static uint8_t *encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + const void *value, ProtoEncodeFn encode_fn); + /// Length and body for a precomputed size. + [[nodiscard]] static uint8_t *encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t nested_size, const void *value, + ProtoEncodeFn encode_fn); private: /// Unaligned little endian store of four bytes: byte stores where the outlined helper lives (ESP-IDF, ARM @@ -704,9 +707,7 @@ class ProtoMessage { public: // Non-virtual defaults for messages with no fields; generated classes hide all four. The // static encode_msg/calc_size_msg take const void * so &T::encode_msg needs no thunk. - static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { - return buffer.get_pos(); - } + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM) { return pos; } static uint32_t calc_size_msg(const void *self) { return 0; } uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); } uint32_t calculate_size() const { return 0; } @@ -960,18 +961,6 @@ class ProtoSize { } }; -// Implementation of methods that depend on ProtoSize being fully defined - -// Thin template wrapper; delegates to non-template core in proto.cpp. -template inline void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const T &value) { - this->encode_sub_message(field_id, &value, &T::encode_msg); -} - -// Thin template wrapper; delegates to non-template core. -template inline void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, const T &value) { - this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg); -} - template const char *proto_enum_to_string(T value); // ProtoService removed — its methods were inlined into APIConnection. diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index db548ae4f62..f644859d013 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -941,7 +941,7 @@ class MessageType(TypeInfo): return False def encode_element(self, number: int, element: str) -> str: - return _encode_call("encode_sub_message", "buffer", str(number), element) + return _encode_call("encode_sub_message", str(number), element) @property def cpp_type(self) -> str: @@ -964,9 +964,8 @@ class MessageType(TypeInfo): @property def encode_content(self) -> str: - # Sub-message encoding needs buffer for backpatch/sync return _encode_call( - self.encode_func, "buffer", str(self.number), f"this->{self.field_name}" + self.encode_func, str(self.number), f"this->{self.field_name}" ) @property @@ -2722,19 +2721,18 @@ def build_message_type( ) for line in encode ] - o = f"{speed_attr}uint8_t *{desc.name}::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {{\n" o += f" const auto &msg = *static_cast(self);\n" - o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" o += indent("\n".join(encode_debug)).replace("this->", "msg.") + "\n" o += " return pos;\n" o += "}\n" cpp += o public_content.append( - "static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);" + "static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);" ) public_content.append( "uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {\n" - " return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);\n" + " return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);\n" "}" ) # If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used diff --git a/tests/components/api/test_proto_sub_message.cpp b/tests/components/api/test_proto_sub_message.cpp new file mode 100644 index 00000000000..ead9487736b --- /dev/null +++ b/tests/components/api/test_proto_sub_message.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Sub-message whose body is raw bytes, so the body size is set directly by the test. +struct BlobMessage { + const uint8_t *data; + uint32_t len; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + // An empty vector's data() may be null, and memcpy needs a valid source even for zero bytes + if (msg.len == 0) + return pos; + return ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data, msg.len); + } + static uint32_t calc_size_msg(const void *self) { return static_cast(self)->len; } +}; + +static void append_varint(std::vector &out, uint32_t value) { + while (value > 0x7F) { + out.push_back(static_cast(value | 0x80)); + value >>= 7; + } + out.push_back(static_cast(value)); +} + +static std::vector make_body(uint32_t len) { + std::vector body(len); + for (uint32_t i = 0; i < len; i++) + body[i] = static_cast(i * 7 + 1); + return body; +} + +static std::vector expected_field(uint32_t field_id, const std::vector &body) { + std::vector out; + append_varint(out, (field_id << 3) | 2); + append_varint(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); + return out; +} + +// Encodes into a buffer of exactly the expected size, so any overrun trips ASan or the debug bounds check. +template static void verify(uint32_t field_id, uint32_t body_len) { + std::vector body = make_body(body_len); + std::vector expected = expected_field(field_id, body); + if (OPTIONAL && body_len == 0) + expected.clear(); + BlobMessage msg{body.data(), body_len}; + + APIBuffer buf; + ASSERT_TRUE(buf.resize(expected.empty() ? 1 : expected.size())); + uint8_t *pos = buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buf.data() + buf.size(); +#endif + uint8_t *end; + if constexpr (OPTIONAL) { + end = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, field_id, msg); + } else { + end = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, field_id, msg); + } + + ASSERT_EQ(static_cast(end - buf.data()), expected.size()) << "field " << field_id << " body " << body_len; + EXPECT_EQ(std::vector(buf.data(), end), expected) << "field " << field_id << " body " << body_len; +} + +TEST(ProtoSubMessage, OneByteTag) { verify(4, 10); } +TEST(ProtoSubMessage, TwoByteTag) { verify(20, 10); } +TEST(ProtoSubMessage, EmptyBody) { verify(20, 0); } +TEST(ProtoSubMessage, LongestOneByteLength) { verify(20, 127); } +// The length outgrows its reserved byte, so the body is moved forward +TEST(ProtoSubMessage, TwoByteLength) { + verify(20, 128); + verify(25, 200); +} +TEST(ProtoSubMessage, ThreeByteLength) { verify(4, 20000); } + +TEST(ProtoOptionalSubMessage, EmptyIsSkipped) { verify(22, 0); } +TEST(ProtoOptionalSubMessage, OneByteTag) { verify(1, 10); } +TEST(ProtoOptionalSubMessage, TwoByteTagAndLength) { verify(22, 200); } + +} // namespace esphome::api::testing diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 4e257e2e6ac..2e6b38edb99 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -47,16 +47,14 @@ const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { using Bytes = std::vector; -static Bytes encode(const ProtoMessage &msg, uint32_t (*calc)(const void *), - uint8_t *(*enc)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { +static Bytes encode(const ProtoMessage &msg, uint32_t (*calc)(const void *), ProtoEncodeFn enc) { APIBuffer buffer; uint32_t size = calc(&msg); EXPECT_TRUE(buffer.resize(size)); - ProtoWriteBuffer writer(&buffer, 0); #ifdef ESPHOME_DEBUG_API uint8_t *proto_debug_end_ = buffer.data() + buffer.size(); #endif - uint8_t *end = enc(&msg, writer PROTO_ENCODE_DEBUG_ARG); + uint8_t *end = enc(&msg, buffer.data() PROTO_ENCODE_DEBUG_ARG); EXPECT_EQ(static_cast(end - buffer.data()), size); return Bytes(buffer.data(), buffer.data() + size); } From 6db2e781ce5e5b455ad10a424268acff587f3eac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 10:09:49 -1000 Subject: [PATCH 023/435] [api] Keep Home Assistant action fields in a flash table (#20237) --- esphome/components/api/__init__.py | 178 ++++++------ .../components/api/homeassistant_service.h | 267 +++++++----------- .../api/test_homeassistant_fields.py | 33 +++ .../api/test_homeassistant_fields.yaml | 29 ++ .../api/test_homeassistant_variables.py | 15 +- .../fixtures/api_string_lambda.yaml | 4 +- tests/integration/test_api_string_lambda.py | 4 +- 7 files changed, 268 insertions(+), 262 deletions(-) create mode 100644 tests/component_tests/api/test_homeassistant_fields.py create mode 100644 tests/component_tests/api/test_homeassistant_fields.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ff3e215a404..5e68e200312 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -51,9 +51,9 @@ from esphome.const import ( CONF_VARIABLES, ) from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import Expression, MockObj, TemplateArgsType import esphome.final_validate as fv -from esphome.helpers import fnv1_hash +from esphome.helpers import cpp_string_escape, fnv1_hash from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType @@ -93,6 +93,7 @@ APIServer = api_ns.class_("APIServer", cg.Component) HomeAssistantServiceCallAction = api_ns.class_( "HomeAssistantServiceCallAction", automation.Action ) +HomeAssistantField = api_ns.struct("HomeAssistantField") ActionResponse = api_ns.class_("ActionResponse") HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger @@ -772,6 +773,11 @@ VARIABLES_SCHEMA = cv.Schema( {cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))} ) +# The action stores each map's entry count in a uint8_t +_FIELD_MAP_MAX = 255 +DATA_FIELDS_SCHEMA = cv.All(KEY_VALUE_SCHEMA, cv.Length(max=_FIELD_MAP_MAX)) +VARIABLES_FIELDS_SCHEMA = cv.All(VARIABLES_SCHEMA, cv.Length(max=_FIELD_MAP_MAX)) + def _validate_response_config(config: ConfigType) -> ConfigType: # Validate dependencies: @@ -806,9 +812,9 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.templatable( cv.string ), - cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, + cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA, cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), @@ -821,6 +827,62 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) +def _field_string(value: str) -> Expression: + # ESP8266 can only keep a string in flash as its own PROGMEM array + literal = cg.RawExpression(cpp_string_escape(value)) + if CORE.is_esp8266: + return cg.shared_progmem_array("ha_field_str", cg.char, literal) + return literal + + +async def _new_service_call_action( + server_id: ID, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + is_event: bool, + service: Any, + data: dict[str, Any], + data_template: dict[str, Any], + variables: dict[str, Any], +) -> MockObj: + """Create the action with its name and fields in one shared flash table.""" + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + serv = await cg.get_variable(server_id) + field_type = HomeAssistantField.template(template_arg) + groups = [data, data_template, variables] + # A lambda may keep static state, so a table with lambdas is never shared + has_lambda = cg.is_template(service) or any( + cg.is_template(value) for group in groups for value in group.values() + ) + + async def field(key: str | None, value: Any, output_type: Any = None) -> Expression: + key_exp = cg.nullptr if key is None else _field_string(key) + if cg.is_template(value): + # output_type=None lets lambdas return numbers or char pointers; C++ converts them + lam = await cg.process_lambda(value, args, return_type=output_type) + return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})") + return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr) + + entries = [await field(None, service, cg.std_string)] + for group in groups: + for key, value in group.items(): + entries.append(await field(key, value)) + table = cg.shared_progmem_array( + "ha_action_fields", + field_type, + cg.ArrayInitializer(*entries, multiline=True), + share=not has_lambda, + ) + return cg.new_Pvariable( + action_id, template_arg, serv, is_event, table, *(len(g) for g in groups) + ) + + +def _service_call_fields(config: ConfigType) -> tuple[dict[str, Any], ...]: + return config[CONF_DATA], config[CONF_DATA_TEMPLATE], config[CONF_VARIABLES] + + # synchronous=False: when on_success/on_error is configured, play() stores the # trigger args until the HomeassistantActionResponse arrives, so non-owning args # (StringRef into the API receive buffer) must not be used. @@ -842,36 +904,15 @@ async def homeassistant_service_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, False) - templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string) - cg.add(var.set_service(templ)) - - # Initialize FixedVectors with exact sizes from config - cg.add(var.init_data(len(config[CONF_DATA]))) - for key, value in config[CONF_DATA].items(): - # output_type=None because lambdas can return non-string types (int, - # float, char*) that TemplatableStringValue converts via to_string. - # Static strings are manually wrapped for PROGMEM on ESP8266. - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) - for key, value in config[CONF_DATA_TEMPLATE].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_variables(len(config[CONF_VARIABLES]))) - for key, value in config[CONF_VARIABLES].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) + var = await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + False, + config[CONF_ACTION], + *_service_call_fields(config), + ) if on_error := config.get(CONF_ON_ERROR): cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES") @@ -923,9 +964,9 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(APIServer), cv.Required(CONF_EVENT): validate_homeassistant_event, - cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, + cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA, } ) @@ -944,38 +985,15 @@ async def homeassistant_event_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, True) - templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string) - cg.add(var.set_service(templ)) - - # Initialize FixedVectors with exact sizes from config - cg.add(var.init_data(len(config[CONF_DATA]))) - for key, value in config[CONF_DATA].items(): - # output_type=None because lambdas can return non-string types (int, - # float, char*) that TemplatableStringValue converts via to_string. - # Static strings are manually wrapped for PROGMEM on ESP8266. - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) - for key, value in config[CONF_DATA_TEMPLATE].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_variables(len(config[CONF_VARIABLES]))) - for key, value in config[CONF_VARIABLES].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) - - return var + return await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + True, + config[CONF_EVENT], + *_service_call_fields(config), + ) HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( @@ -999,15 +1017,17 @@ async def homeassistant_tag_scanned_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, True) - cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned"))) - # Initialize FixedVector with exact size (1 data field) - cg.add(var.init_data(1)) - templ = await cg.templatable(config[CONF_TAG], args, cg.std_string) - cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ)) - return var + return await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + True, + "esphome.tag_scanned", + {"tag_id": config[CONF_TAG]}, + {}, + {}, + ) CONF_SUCCESS = "success" diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 9e0faf98819..e7798a5d267 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -4,64 +4,40 @@ #ifdef USE_API #ifdef USE_API_HOMEASSISTANT_SERVICES #include +#include +#include #include -#include #include "api_pb2.h" #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #include "esphome/components/json/json_util.h" #endif #include "esphome/core/automation.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::api { -template class TemplatableStringValue : public TemplatableValue { - // Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation) - // rather than being wrapped in a lambda. The base class constructor for const char* is more - // specialized than the templated constructor here, so it should be selected. - static_assert(std::is_constructible_v, const char *>, - "Base class must have const char* constructor for STATIC_STRING optimization"); +// Converts a lambda result to the string sent to Home Assistant +template +requires(!std::is_pointer_v>) std::string field_to_string(T &&val) { + return to_string(std::forward(val)); // NOLINT +} +inline std::string field_to_string(const char *val) { return val ? std::string(val) : std::string(); } +inline std::string field_to_string(std::string val) { return val; } +inline std::string field_to_string(StringRef val) { return val.str(); } - private: - // Helper to convert value to string - handles the case where value is already a string - template static std::string value_to_string(T &&val) { - return to_string(std::forward(val)); // NOLINT +/// A key and value from codegen; on ESP8266 the table and its strings are in flash. +/// The value is the constant `value`, or the result of `fn` when it is set. +template struct HomeAssistantField { + const char *key; + const char *value; + std::string (*fn)(const Ts &...); + + template static constexpr HomeAssistantField from_lambda(const char *key, F /*lambda*/) { + return {key, nullptr, &call_lambda}; } - - // Overloads for string types - needed because std::to_string doesn't support them - static std::string value_to_string(char *val) { - return val ? std::string(val) : std::string(); - } // For lambdas returning char* (e.g., itoa) - static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str() - static std::string value_to_string(const std::string &val) { return val; } - static std::string value_to_string(std::string &&val) { return std::move(val); } - static std::string value_to_string(const StringRef &val) { return val.str(); } - static std::string value_to_string(StringRef &&val) { return val.str(); } - - public: - TemplatableStringValue() : TemplatableValue() {} - - template::value, int> = 0> - TemplatableStringValue(F value) : TemplatableValue(value) {} - - template::value, int> = 0> - TemplatableStringValue(F f) - : TemplatableValue([f](X... x) -> std::string { return value_to_string(f(x...)); }) {} -}; - -template class TemplatableKeyValuePair { - public: - // Default constructor needed for FixedVector::emplace_back() - TemplatableKeyValuePair() = default; - - // Keys are always string literals from YAML dictionary keys (e.g., "code", "event") - // and never templatable values or lambdas. Only the value parameter can be a lambda/template. - // Using const char* avoids std::string heap allocation - keys remain in flash. - template TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {} - - const char *key{nullptr}; - TemplatableStringValue value; + template static std::string call_lambda(const Ts &...x) { return field_to_string(F{}(x...)); } }; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -106,45 +82,20 @@ template using ActionResponseCallback = std::function class HomeAssistantServiceCallAction final : public Action { public: - explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) { + using Field = HomeAssistantField; + + /// `fields` is a codegen table: the action or event name (no key), then the data, data_template + /// and variables entries. + HomeAssistantServiceCallAction(APIServer *parent, bool is_event, const Field *fields, uint8_t data_count, + uint8_t data_template_count, uint8_t variables_count) + : parent_(parent), + fields_(fields), + data_count_(data_count), + data_template_count_(data_template_count), + variables_count_(variables_count) { this->flags_.is_event = is_event; } - template void set_service(T service) { this->service_ = service; } - - // Initialize FixedVector members - called from Python codegen with compile-time known sizes. - // Must be called before any add_* methods; capacity must match the number of subsequent add_* calls. - void init_data(size_t count) { this->data_.init(count); } - void init_data_template(size_t count) { this->data_template_.init(count); } - void init_variables(size_t count) { this->variables_.init(count); } - - // Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))). - // The value parameter can be a lambda/template, but keys are never templatable. - // Using const char* for keys avoids std::string heap allocation - keys remain in flash. - template void add_data(const char *key, V &&value) { - this->add_kv_(this->data_, key, std::forward(value)); - } - template void add_data_template(const char *key, V &&value) { - this->add_kv_(this->data_template_, key, std::forward(value)); - } - template void add_variable(const char *key, V &&value) { - this->add_kv_(this->variables_, key, std::forward(value)); - } - -#ifdef USE_ESP8266 - // On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer). - // Store as const char* — populate_service_map copies from PROGMEM at play() time. - template void add_data(const __FlashStringHelper *key, V &&value) { - this->add_kv_(this->data_, reinterpret_cast(key), std::forward(value)); - } - template void add_data_template(const __FlashStringHelper *key, V &&value) { - this->add_kv_(this->data_template_, reinterpret_cast(key), std::forward(value)); - } - template void add_variable(const __FlashStringHelper *key, V &&value) { - this->add_kv_(this->variables_, reinterpret_cast(key), std::forward(value)); - } -#endif - #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES template void set_response_template(T response_template) { this->response_template_ = response_template; @@ -162,19 +113,61 @@ template class HomeAssistantServiceCallAction final : public Act #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES void play(const Ts &...x) override { + const Field *fields = this->fields_; + const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_; + + // Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send + size_t lambda_count = 0; +#ifdef USE_ESP8266 + size_t flash_len = 0; +#endif + for (size_t i = 0; i < total; i++) { + lambda_count += fields[i].fn != nullptr; +#ifdef USE_ESP8266 + if (fields[i].fn == nullptr) + flash_len += ESPHOME_strlen_P(fields[i].value); + if (fields[i].key != nullptr) + flash_len += ESPHOME_strlen_P(fields[i].key); +#endif + } + FixedVector results; + results.init(lambda_count); +#ifdef USE_ESP8266 + SmallBufferWithHeapFallback<128, char> flash_copy(flash_len); + char *cursor = flash_copy.get(); +#endif + auto string_ref = [&](const char *str) { +#ifdef USE_ESP8266 + size_t len = ESPHOME_strlen_P(str); + memcpy_P(cursor, str, len); + StringRef ref(cursor, len); + cursor += len; + return ref; +#else + return StringRef(str); +#endif + }; + auto value_ref = [&](const Field &field) { + if (field.fn == nullptr) + return string_ref(field.value); + results.push_back(field.fn(x...)); + return StringRef(results.back()); + }; + auto fill = [&](FixedVector &dest, uint8_t count) { + dest.init(count); + for (uint8_t i = 0; i < count; i++, fields++) { + auto &kv = dest.emplace_back(); + kv.key = string_ref(fields->key); + kv.value = value_ref(*fields); + } + }; + HomeassistantActionRequest resp; - std::string service_value = this->service_.value(x...); - resp.service = StringRef(service_value); + resp.service = value_ref(*fields++); resp.is_event = this->flags_.is_event; - - // Local storage for lambda-evaluated strings - lives until after send - FixedVector data_storage; - FixedVector data_template_storage; - FixedVector variables_storage; - - this->populate_service_map(resp.data, this->data_, data_storage, x...); - this->populate_service_map(resp.data_template, this->data_template_, data_template_storage, x...); - this->populate_service_map(resp.variables, this->variables_, variables_storage, x...); + fill(resp.data, this->data_count_); + fill(resp.data_template, this->data_template_count_); + fill(resp.variables, this->variables_count_); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON @@ -223,90 +216,26 @@ template class HomeAssistantServiceCallAction final : public Act } protected: - // Helper to add key-value pairs to FixedVectors - // Keys are always string literals (const char*), values can be lambdas/templates - template void add_kv_(FixedVector> &vec, const char *key, V &&value) { - auto &kv = vec.emplace_back(); - kv.key = key; - kv.value = std::forward(value); - } - - template - static void populate_service_map(VectorType &dest, SourceType &source, FixedVector &value_storage, - Ts... x) { - dest.init(source.size()); - -#ifdef USE_ESP8266 - // On ESP8266, all static strings from codegen are FLASH_STRING (PROGMEM), - // so is_static_string() is always false — the zero-copy STATIC_STRING fast - // path from the non-ESP8266 branch cannot trigger. We copy all keys and - // values unconditionally: keys via _P functions (may be in PROGMEM), values - // via value() which handles FLASH_STRING internally. - value_storage.init(source.size() * 2); - - for (auto &it : source) { - auto &kv = dest.emplace_back(); - - // Key: copy from possible PROGMEM - { - size_t key_len = strlen_P(it.key); - value_storage.push_back(std::string(key_len, '\0')); - memcpy_P(value_storage.back().data(), it.key, key_len); - kv.key = StringRef(value_storage.back()); - } - - // Value: value() handles FLASH_STRING via _P functions internally - value_storage.push_back(it.value.value(x...)); - kv.value = StringRef(value_storage.back()); - } -#else - // On non-ESP8266, strings are directly readable from flash-mapped memory. - // Count non-static strings to allocate exact storage needed. - size_t lambda_count = 0; - for (const auto &it : source) { - if (!it.value.is_static_string()) { - lambda_count++; - } - } - value_storage.init(lambda_count); - - for (auto &it : source) { - auto &kv = dest.emplace_back(); - kv.key = StringRef(it.key); - - if (it.value.is_static_string()) { - // Static string — pointer directly readable, zero allocation - kv.value = StringRef(it.value.get_static_string()); - } else { - // Lambda — evaluate and store result - value_storage.push_back(it.value.value(x...)); - kv.value = StringRef(value_storage.back()); - } - } -#endif - } - APIServer *parent_; - TemplatableStringValue service_{}; - FixedVector> data_; - FixedVector> data_template_; - FixedVector> variables_; -#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES -#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - TemplatableStringValue response_template_{""}; - Trigger success_trigger_with_response_; -#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - Trigger success_trigger_; - Trigger error_trigger_; -#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES - + const Field *fields_; + uint8_t data_count_; + uint8_t data_template_count_; + uint8_t variables_count_; struct Flags { uint8_t is_event : 1; uint8_t wants_status : 1; uint8_t wants_response : 1; uint8_t has_response_template : 1; - uint8_t reserved : 5; + uint8_t reserved : 4; } flags_{0}; +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON + TemplatableValue response_template_{}; + Trigger success_trigger_with_response_; +#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON + Trigger success_trigger_; + Trigger error_trigger_; +#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES }; } // namespace esphome::api diff --git a/tests/component_tests/api/test_homeassistant_fields.py b/tests/component_tests/api/test_homeassistant_fields.py new file mode 100644 index 00000000000..0dec970ae3b --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_fields.py @@ -0,0 +1,33 @@ +"""Tests for the Home Assistant action field tables.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.api import DATA_FIELDS_SCHEMA, VARIABLES_FIELDS_SCHEMA +import esphome.config_validation as cv + +CONFIG = "tests/component_tests/api/test_homeassistant_fields.yaml" + + +def test_field_tables(generate_main: Callable[[str | Path], str]) -> None: + """Strings are single PROGMEM arrays on ESP8266; only tables without lambdas are shared.""" + main_cpp = generate_main(CONFIG) + + assert main_cpp.count('PROGMEM = "message";') == 1 + assert main_cpp.count('PROGMEM = "notify.notify";') == 1 + assert 'ESPHOME_F("message")' not in main_cpp + + assert main_cpp.count("(api_apiserver_id, false, ha_action_fields, 1, 0, 0);") == 2 + + assert "(api_apiserver_id, true, ha_action_fields_2, 1, 0, 0);" in main_cpp + assert "(api_apiserver_id, true, ha_action_fields_3, 1, 0, 0);" in main_cpp + + +@pytest.mark.parametrize("schema", [DATA_FIELDS_SCHEMA, VARIABLES_FIELDS_SCHEMA]) +def test_field_map_limit(schema: cv.Schema) -> None: + """Each map is counted in a uint8_t, so 256 entries fail validation.""" + schema({f"key{i}": "value" for i in range(255)}) + with pytest.raises(cv.Invalid): + schema({f"key{i}": "value" for i in range(256)}) diff --git a/tests/component_tests/api/test_homeassistant_fields.yaml b/tests/component_tests/api/test_homeassistant_fields.yaml new file mode 100644 index 00000000000..270a925a7ef --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_fields.yaml @@ -0,0 +1,29 @@ +esphome: + name: test + on_boot: + then: + - homeassistant.action: + action: notify.notify + data: + message: Booted + - homeassistant.action: + action: notify.notify + data: + message: Booted + - homeassistant.event: + event: esphome.booted + data: + message: !lambda 'static int n = 0; return ++n;' + - homeassistant.event: + event: esphome.booted + data: + message: !lambda 'static int n = 0; return ++n;' + +esp8266: + board: esp01_1m + +wifi: + ssid: SomeNetwork + password: SomePassword + +api: diff --git a/tests/component_tests/api/test_homeassistant_variables.py b/tests/component_tests/api/test_homeassistant_variables.py index 48e53d8f4c3..6d154382120 100644 --- a/tests/component_tests/api/test_homeassistant_variables.py +++ b/tests/component_tests/api/test_homeassistant_variables.py @@ -17,7 +17,7 @@ def test_plain_string_with_return_is_compiled_as_lambda_with_warning( with caplog.at_level(logging.WARNING): main_cpp = generate_main(CONFIG) - assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2 + assert main_cpp.count('from_lambda("lambda_var", []() {') == 2 assert "return millis();" in main_cpp # The source text must not be sent as a static string value. assert '"return millis();"' not in main_cpp @@ -28,16 +28,11 @@ def test_static_string_is_kept_as_static_value( generate_main: Callable[[str | Path], str], caplog: pytest.LogCaptureFixture, ) -> None: - """A static string stays static, PROGMEM wrapped, with no warning.""" + """A static string stays a constant table entry, with no warning.""" with caplog.at_level(logging.WARNING): main_cpp = generate_main(CONFIG) - assert ( - main_cpp.count( - 'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));' - ) - == 2 - ) + assert main_cpp.count('{"static_var", "static value", nullptr}') == 2 assert "static value" not in caplog.text @@ -49,7 +44,7 @@ def test_static_id_value_stays_literal_with_hint( with caplog.at_level(logging.WARNING): main_cpp = generate_main(CONFIG) - assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp + assert '{"hint_var", "id(test_sensor).state", nullptr}' in main_cpp assert "sent as literal text" in caplog.text @@ -59,5 +54,5 @@ def test_explicit_lambda_tag_is_compiled_as_lambda( """A !lambda value keeps working unchanged.""" main_cpp = generate_main(CONFIG) - assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp + assert 'from_lambda("tagged_var", []() {' in main_cpp assert "return App.get_name();" in main_cpp diff --git a/tests/integration/fixtures/api_string_lambda.yaml b/tests/integration/fixtures/api_string_lambda.yaml index e2da4683c0a..8f2a11cf311 100644 --- a/tests/integration/fixtures/api_string_lambda.yaml +++ b/tests/integration/fixtures/api_string_lambda.yaml @@ -37,7 +37,7 @@ api: args: [input_number] # Test that int lambdas still work correctly with to_string - # The TemplatableStringValue should automatically convert int to string + # Home Assistant action lambdas convert an int result to a string - homeassistant.event: event: esphome.test_int_lambda data: @@ -54,7 +54,7 @@ api: args: [input_float] # Test that float lambdas still work correctly with to_string - # The TemplatableStringValue should automatically convert float to string + # Home Assistant action lambdas convert a float result to a string - homeassistant.event: event: esphome.test_float_lambda data: diff --git a/tests/integration/test_api_string_lambda.py b/tests/integration/test_api_string_lambda.py index ece8b192a20..373bd3c20b8 100644 --- a/tests/integration/test_api_string_lambda.py +++ b/tests/integration/test_api_string_lambda.py @@ -1,4 +1,4 @@ -"""Integration test for TemplatableStringValue with string lambdas.""" +"""Integration test for Home Assistant action lambdas returning strings and numbers.""" from __future__ import annotations @@ -16,7 +16,7 @@ async def test_api_string_lambda( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test TemplatableStringValue works with lambdas that return different types.""" + """Test Home Assistant action lambdas that return different types are sent as strings.""" loop = asyncio.get_running_loop() # Track log messages for all four service calls From 677c089614c36dc356dcb340a633a80a0b65609e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 10:23:59 -1000 Subject: [PATCH 024/435] [sx127x] Keep constant send_packet payloads in shared flash tables (#20275) --- esphome/components/sx127x/__init__.py | 29 ++++++----- esphome/components/sx127x/automation.h | 30 ++--------- esphome/components/sx127x/sx127x.cpp | 25 +++++---- esphome/components/sx127x/sx127x.h | 9 +++- tests/component_tests/sx127x/__init__.py | 0 .../sx127x/config/packet_tables.yaml | 24 +++++++++ .../sx127x/test_packet_tables.py | 51 +++++++++++++++++++ 7 files changed, 114 insertions(+), 54 deletions(-) create mode 100644 tests/component_tests/sx127x/__init__.py create mode 100644 tests/component_tests/sx127x/config/packet_tables.yaml create mode 100644 tests/component_tests/sx127x/test_packet_tables.py diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 9f95c618445..68c56cdff49 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -135,6 +135,11 @@ def validate_raw_data(value: Any) -> bytes | list[int]: ) +MAX_PACKET_SIZE = 255 +# The radio's payload length register is 8 bits, and empty packets are rejected. +validate_packet_data = cv.All(validate_raw_data, cv.Length(min=1, max=MAX_PACKET_SIZE)) + + def validate_config(config: ConfigType) -> ConfigType: if config[CONF_MODULATION] == "LORA": bws = [ @@ -198,7 +203,9 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=0, max=17), cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP), cv.Optional(CONF_PACKET_MODE): cv.boolean, - cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=256), + cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range( + min=0, max=MAX_PACKET_SIZE + ), cv.Optional(CONF_PREAMBLE_DETECT, default=0): cv.int_range(min=0, max=3), cv.Optional(CONF_PREAMBLE_ERRORS, default=0): cv.int_range(min=0, max=31), cv.Optional(CONF_PREAMBLE_POLARITY, default=0xAA): cv.All( @@ -286,7 +293,7 @@ for _name, _call in ( SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SX127x), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_packet_data), }, key=CONF_DATA, ) @@ -306,15 +313,11 @@ async def send_packet_action_to_code( ) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "sx127x_data", + ) return var diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index 2ed16146813..8d409f0b012 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -7,35 +7,13 @@ namespace esphome::sx127x { template class SendPacketAction final : public Action, public Parented { + TEMPLATABLE_BYTES(data) + public: - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } - void play(const Ts &...x) override { - std::vector data; - if (this->len_ >= 0) { - // Static mode: copy from flash to vector - data.assign(this->data_.data, this->data_.data + this->len_); - } else { - // Template mode: call function - data = this->data_.func(x...); - } - this->parent_->transmit_packet(data); + this->data_.template visit( + [this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; }; } // namespace esphome::sx127x diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index cd81f089149..9af30400a7c 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -43,11 +43,11 @@ void SX127x::read_fifo_(std::vector &packet) { this->disable(); } -void SX127x::write_fifo_(const std::vector &packet) { +void SX127x::write_fifo_(const uint8_t *data, size_t len) { this->enable(); this->write_byte(REG_FIFO | 0x80); - for (const auto &byte : packet) { - this->transfer_byte(byte); + for (size_t i = 0; i < len; i++) { + this->transfer_byte(data[i]); } this->disable(); } @@ -250,18 +250,17 @@ size_t SX127x::get_max_packet_size() { return this->payload_length_; } if (this->modulation_ == MOD_LORA) { - return 256; - } else { - return 64; + return SX127X_MAX_PACKET_SIZE; } + return 64; } -SX127xError SX127x::transmit_packet(const std::vector &packet) { - if (this->payload_length_ > 0 && this->payload_length_ != packet.size()) { +SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { + if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX127xError::INVALID_PARAMS; } - if (packet.empty() || packet.size() > this->get_max_packet_size()) { + if (len == 0 || len > this->get_max_packet_size()) { ESP_LOGE(TAG, "Packet size out of range"); return SX127xError::INVALID_PARAMS; } @@ -275,18 +274,18 @@ SX127xError SX127x::transmit_packet(const std::vector &packet) { if (this->modulation_ == MOD_LORA) { this->set_mode_standby(); if (this->payload_length_ == 0) { - this->write_register_(REG_PAYLOAD_LENGTH, packet.size()); + this->write_register_(REG_PAYLOAD_LENGTH, len); } this->write_register_(REG_IRQ_FLAGS, 0xFF); this->write_register_(REG_FIFO_ADDR_PTR, 0); - this->write_fifo_(packet); + this->write_fifo_(data, len); this->set_mode_tx(); } else { this->set_mode_standby(); if (this->payload_length_ == 0) { - this->write_register_(REG_FIFO, packet.size()); + this->write_register_(REG_FIFO, len); } - this->write_fifo_(packet); + this->write_fifo_(data, len); this->set_mode_tx(); } diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 070a6eeb96a..27031d98b2b 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -9,6 +9,8 @@ namespace esphome::sx127x { +static constexpr size_t SX127X_MAX_PACKET_SIZE = 255; // the payload length register is 8 bits + enum SX127xBw : uint8_t { SX127X_BW_2_6, SX127X_BW_3_1, @@ -81,7 +83,10 @@ class SX127x final : public Component, void set_sync_value(const std::vector &sync_value) { this->sync_value_ = sync_value; } void run_image_cal(); void configure(); - SX127xError transmit_packet(const std::vector &packet); + SX127xError transmit_packet(const uint8_t *data, size_t len); + SX127xError transmit_packet(const std::vector &packet) { + return this->transmit_packet(packet.data(), packet.size()); + } void register_listener(SX127xListener *listener) { this->listeners_.push_back(listener); } Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } @@ -90,7 +95,7 @@ class SX127x final : public Component, void configure_fsk_ook_(); void configure_lora_(); void set_mode_(uint8_t modulation, uint8_t mode); - void write_fifo_(const std::vector &packet); + void write_fifo_(const uint8_t *data, size_t len); void read_fifo_(std::vector &packet); void write_register_(uint8_t reg, uint8_t value); void call_listeners_(const std::vector &packet, float rssi, float snr); diff --git a/tests/component_tests/sx127x/__init__.py b/tests/component_tests/sx127x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/sx127x/config/packet_tables.yaml b/tests/component_tests/sx127x/config/packet_tables.yaml new file mode 100644 index 00000000000..a1cf7b3f00d --- /dev/null +++ b/tests/component_tests/sx127x/config/packet_tables.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + on_boot: + then: + - sx127x.send_packet: [0xC5, 0x51, 0x78, 0x82] + - sx127x.send_packet: [0xC5, 0x51, 0x78, 0x82] + - sx127x.send_packet: "hi" + - sx127x.send_packet: !lambda return {0x09}; + +esp32: + board: esp32dev + +spi: + clk_pin: 18 + mosi_pin: 23 + miso_pin: 19 + +sx127x: + cs_pin: 12 + rst_pin: 13 + dio0_pin: 26 + frequency: 433920000 + modulation: LORA + preamble_size: 8 diff --git a/tests/component_tests/sx127x/test_packet_tables.py b/tests/component_tests/sx127x/test_packet_tables.py new file mode 100644 index 00000000000..f7573bb1cac --- /dev/null +++ b/tests/component_tests/sx127x/test_packet_tables.py @@ -0,0 +1,51 @@ +"""Tests for SX127x constant packets in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +from esphome.components.sx127x import CONFIG_SCHEMA, validate_packet_data +import esphome.config_validation as cv + + +def test_constant_packets_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal constant packets share one table; lambdas stay templates.""" + main_cpp = generate_main(component_config_path("packet_tables.yaml")) + + tables = dict( + re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + ) + assert sorted(tables.values()) == sorted( + ["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"] + ) + shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}") + assert main_cpp.count(f"set_data_static({shared}, 4);") == 2 + assert "set_data_template(" in main_cpp + + +def test_packet_data_length_limit() -> None: + """Constant packets are limited to the 255 bytes the length register holds.""" + assert len(validate_packet_data([0x01] * 255)) == 255 + with pytest.raises(cv.Invalid): + validate_packet_data([0x01] * 256) + with pytest.raises(cv.Invalid): + validate_packet_data("x" * 256) + with pytest.raises(cv.Invalid): + validate_packet_data([]) + + +def test_payload_length_limit() -> None: + """payload_length goes into an 8 bit register, so 256 is rejected.""" + validator = next( + v for k, v in CONFIG_SCHEMA.schema.items() if str(k) == "payload_length" + ) + assert validator(255) == 255 + with pytest.raises(cv.Invalid): + validator(256) From 16568c0453440132a9af928e7ed1253a7e4eb3da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 10:24:14 -1000 Subject: [PATCH 025/435] [sx126x] Keep constant send_packet payloads in shared flash tables (#20273) --- esphome/components/sx126x/__init__.py | 29 +++++++------- esphome/components/sx126x/automation.h | 29 ++------------ esphome/components/sx126x/sx126x.cpp | 18 ++++----- esphome/components/sx126x/sx126x.h | 9 ++++- tests/component_tests/sx126x/__init__.py | 0 .../sx126x/config/packet_tables.yaml | 27 +++++++++++++ .../sx126x/test_packet_tables.py | 39 +++++++++++++++++++ 7 files changed, 101 insertions(+), 50 deletions(-) create mode 100644 tests/component_tests/sx126x/__init__.py create mode 100644 tests/component_tests/sx126x/config/packet_tables.yaml create mode 100644 tests/component_tests/sx126x/test_packet_tables.py diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index ce47a570501..63c3711b10d 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -146,6 +146,11 @@ def validate_raw_data(value: Any) -> bytes | list[int]: ) +MAX_PACKET_SIZE = 255 +# The radio sends packets of 1 to MAX_PACKET_SIZE bytes. +validate_packet_data = cv.All(validate_raw_data, cv.Length(min=1, max=MAX_PACKET_SIZE)) + + def validate_config(config: ConfigType) -> ConfigType: lora_bws = [ "7_8kHz", @@ -205,7 +210,9 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=-3, max=22), cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP), - cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=255), + cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range( + min=0, max=MAX_PACKET_SIZE + ), cv.Optional(CONF_PREAMBLE_DETECT, default=2): cv.int_range(min=0, max=4), cv.Optional(CONF_PREAMBLE_SIZE, default=8): cv.int_range(min=1, max=65535), cv.Required(CONF_RST_PIN): pins.gpio_output_pin_schema, @@ -314,7 +321,7 @@ automation.register_apply_action( SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SX126x), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_packet_data), }, key=CONF_DATA, ) @@ -334,15 +341,11 @@ async def send_packet_action_to_code( ) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "sx126x_data", + ) return var diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 411de123413..0dbf3cb42c4 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -7,35 +7,12 @@ namespace esphome::sx126x { template class SendPacketAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - std::vector data; - if (this->len_ >= 0) { - // Static mode: copy from flash to vector - data.assign(this->data_.data, this->data_.data + this->len_); - } else { - // Template mode: call function - data = this->data_.func(x...); - } - this->parent_->transmit_packet(data); + this->data_.template visit( + [this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; }; } // namespace esphome::sx126x diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 376676ce850..673464d5eb7 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -42,13 +42,13 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { return status; } -void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { +void SX126x::write_fifo_(uint8_t offset, const uint8_t *data, size_t len) { this->enable(); this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); - for (const uint8_t &byte : packet) { - this->transfer_byte(byte); + for (size_t i = 0; i < len; i++) { + this->transfer_byte(data[i]); } this->disable(); delayMicroseconds(SWITCHING_DELAY_US); @@ -280,7 +280,7 @@ size_t SX126x::get_max_packet_size() { if (this->payload_length_ > 0) { return this->payload_length_; } - return 255; + return SX126X_MAX_PACKET_SIZE; } void SX126x::set_packet_params_(uint8_t payload_length) { @@ -312,12 +312,12 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } } -SX126xError SX126x::transmit_packet(const std::vector &packet) { - if (this->payload_length_ > 0 && this->payload_length_ != packet.size()) { +SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { + if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX126xError::INVALID_PARAMS; } - if (packet.empty() || packet.size() > this->get_max_packet_size()) { + if (len == 0 || len > this->get_max_packet_size()) { ESP_LOGE(TAG, "Packet size out of range"); return SX126xError::INVALID_PARAMS; } @@ -325,9 +325,9 @@ SX126xError SX126x::transmit_packet(const std::vector &packet) { SX126xError ret = SX126xError::NONE; this->set_mode_standby(STDBY_XOSC); if (this->payload_length_ == 0) { - this->set_packet_params_(packet.size()); + this->set_packet_params_(len); } - this->write_fifo_(0x00, packet); + this->write_fifo_(0x00, data, len); this->set_mode_tx(); // wait until transmit completes, typically the delay will be less than 100 ms diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index b3dfe6590a3..536bb93c48f 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -10,6 +10,8 @@ namespace esphome::sx126x { +static constexpr size_t SX126X_MAX_PACKET_SIZE = 255; + enum SX126xBw : uint8_t { // FSK SX126X_BW_4800, @@ -97,7 +99,10 @@ class SX126x final : public Component, void set_tcxo_delay(uint32_t tcxo_delay) { this->tcxo_delay_ = tcxo_delay; } void run_image_cal(); void configure(); - SX126xError transmit_packet(const std::vector &packet); + SX126xError transmit_packet(const uint8_t *data, size_t len); + SX126xError transmit_packet(const std::vector &packet) { + return this->transmit_packet(packet.data(), packet.size()); + } void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); } Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } @@ -107,7 +112,7 @@ class SX126x final : public Component, void configure_lora_(); void set_packet_params_(uint8_t payload_length); uint8_t read_fifo_(uint8_t offset, std::vector &packet); - void write_fifo_(uint8_t offset, const std::vector &packet); + void write_fifo_(uint8_t offset, const uint8_t *data, size_t len); void write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size); uint8_t read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size); void write_register_(uint16_t reg, uint8_t *data, uint8_t size); diff --git a/tests/component_tests/sx126x/__init__.py b/tests/component_tests/sx126x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/sx126x/config/packet_tables.yaml b/tests/component_tests/sx126x/config/packet_tables.yaml new file mode 100644 index 00000000000..d8b2f042815 --- /dev/null +++ b/tests/component_tests/sx126x/config/packet_tables.yaml @@ -0,0 +1,27 @@ +esphome: + name: test + on_boot: + then: + - sx126x.send_packet: [0xC5, 0x51, 0x78, 0x82] + - sx126x.send_packet: [0xC5, 0x51, 0x78, 0x82] + - sx126x.send_packet: "hi" + - sx126x.send_packet: !lambda return {0x09}; + +esp32: + board: esp32dev + +spi: + clk_pin: 18 + mosi_pin: 23 + miso_pin: 19 + +sx126x: + cs_pin: 12 + rst_pin: 13 + busy_pin: 25 + dio1_pin: 26 + frequency: 433920000 + hw_version: sx1262 + modulation: LORA + bandwidth: 125_0kHz + rf_switch: true diff --git a/tests/component_tests/sx126x/test_packet_tables.py b/tests/component_tests/sx126x/test_packet_tables.py new file mode 100644 index 00000000000..9ee3379f085 --- /dev/null +++ b/tests/component_tests/sx126x/test_packet_tables.py @@ -0,0 +1,39 @@ +"""Tests for SX126x constant packets in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +from esphome.components.sx126x import validate_packet_data +import esphome.config_validation as cv + + +def test_constant_packets_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal constant packets share one table; lambdas stay templates.""" + main_cpp = generate_main(component_config_path("packet_tables.yaml")) + + tables = dict( + re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + ) + assert sorted(tables.values()) == sorted( + ["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"] + ) + shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}") + assert main_cpp.count(f"set_data_static({shared}, 4);") == 2 + assert "set_data_template(" in main_cpp + + +def test_packet_data_length_limit() -> None: + """Constant packets must be 1 to 255 bytes long.""" + assert len(validate_packet_data([0x01] * 255)) == 255 + with pytest.raises(cv.Invalid): + validate_packet_data([0x01] * 256) + with pytest.raises(cv.Invalid): + validate_packet_data([]) From 5e4990badce88ce6ca0271499eb96e0b0365d9d6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:27:43 +1300 Subject: [PATCH 026/435] Bump version to 2026.10.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 16193713239..ec43afe83a5 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.0-dev +PROJECT_NUMBER = 2026.10.0b1 # 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 fb02d702810..f0c339416a9 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.0-dev" +__version__ = "2026.10.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 24fc86c2c4819fe5aebf2c9854ffedb6cd08d46c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 10:48:46 -1000 Subject: [PATCH 027/435] [core] Deprecate TemplatableValue::is_static_string and get_static_string (#20280) --- esphome/core/automation.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index f5921168e02..d3f1fe59e59 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -405,10 +405,14 @@ template class TemplatableValue { /// Check if this holds a static string (const char* stored without allocation) /// The pointer is always directly readable (RAM or flash-mapped). /// Returns false for FLASH_STRING (PROGMEM on ESP8266, requires _P functions). + // Remove before 2027.4.0 + ESPDEPRECATED("Read the value with ref_or_copy_to() instead. Removed in 2027.4.0", "2026.10.0") bool is_static_string() const { return this->type_ == STATIC_STRING; } /// Get the static string pointer (only valid if is_static_string() returns true) /// The pointer is always directly readable — FLASH_STRING uses a separate type. + // Remove before 2027.4.0 + ESPDEPRECATED("Use ref_or_copy_to() instead. Removed in 2027.4.0", "2026.10.0") const char *get_static_string() const { return this->static_str_; } /// Check if the string value is empty without allocating. From 12c03d539ac7a712d2d79f2dbd6a5b7ac35e5d18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 10:54:56 -1000 Subject: [PATCH 028/435] [cc1101] Keep constant send_packet payloads in shared flash tables (#20264) --- esphome/components/cc1101/__init__.py | 24 +++++------ esphome/components/cc1101/cc1101.cpp | 28 +++++++++++-- esphome/components/cc1101/cc1101.h | 25 +++-------- tests/component_tests/cc1101/__init__.py | 0 .../cc1101/config/payload_tables.yaml | 25 +++++++++++ .../cc1101/test_cc1101_payload_tables.py | 41 +++++++++++++++++++ 6 files changed, 107 insertions(+), 36 deletions(-) create mode 100644 tests/component_tests/cc1101/__init__.py create mode 100644 tests/component_tests/cc1101/config/payload_tables.yaml create mode 100644 tests/component_tests/cc1101/test_cc1101_payload_tables.py diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 5de39a16cec..65d5d6d9f1d 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_VALUE, CONF_WAIT_TIME, ) -from esphome.core import ID CODEOWNERS = ["@lygris", "@gabest11"] DEPENDENCIES = ["spi"] @@ -384,7 +383,10 @@ def validate_raw_data(value): SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(CC1101Component), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + # TX FIFO size + cv.Required(CONF_DATA): cv.templatable( + cv.All(validate_raw_data, cv.Length(max=64)) + ), }, key=CONF_DATA, ) @@ -399,17 +401,13 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "cc1101_data", + ) return var diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index f7b90b91cf6..91a29d8d533 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -3,10 +3,16 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include + +#ifdef USE_ESP32 +#include +#endif namespace esphome::cc1101 { static const char *const TAG = "cc1101"; +static constexpr size_t TX_FIFO_SIZE = 64; static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) { int e_tmp; @@ -379,18 +385,32 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { this->disable(); } -CC1101Error CC1101Component::transmit_packet(const std::vector &packet) { +CC1101Error CC1101Component::transmit_packet(const uint8_t *data, size_t len) { if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { return CC1101Error::PARAMS; } + const bool variable = this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE); + // In variable length mode the FIFO also holds the length byte + if (len + (variable ? 1 : 0) > TX_FIFO_SIZE) { + ESP_LOGE(TAG, "Packet of %u bytes does not fit the TX FIFO", static_cast(len)); + return CC1101Error::PARAMS; + } // Write packet this->enter_idle_(); this->strobe_(Command::FTX); - if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { - this->write_(Register::FIFO, static_cast(packet.size())); + if (variable) { + this->write_(Register::FIFO, static_cast(len)); } - this->write_(Register::FIFO, packet.data(), packet.size()); +#ifdef USE_ESP32 + // SPI DMA cannot read flash or unaligned buffers; stage them here so the driver does not allocate a copy + alignas(4) uint8_t staged[TX_FIFO_SIZE]; + if (len != 0 && (!esp_ptr_dma_capable(data) || (reinterpret_cast(data) & 3) != 0)) { + std::memcpy(staged, data, len); + data = staged; + } +#endif + this->write_(Register::FIFO, data, len); // Calibrate PLL if (!this->enter_calibrated_(State::FSTXON, Command::FSTXON)) { diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 499f77a809c..d7065c60487 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -89,7 +89,10 @@ class CC1101Component final : public Component, void set_whitening(bool value); // Packet mode operations - CC1101Error transmit_packet(const std::vector &packet); + CC1101Error transmit_packet(const std::vector &packet) { + return this->transmit_packet(packet.data(), packet.size()); + } + CC1101Error transmit_packet(const uint8_t *data, size_t len); void register_listener(CC1101Listener *listener) { this->listeners_.push_back(listener); } Trigger, float, float, uint8_t> *get_packet_trigger() { return &this->packet_trigger_; } @@ -131,27 +134,11 @@ class CC1101Component final : public Component, // Action Wrappers template class SendPacketAction final : public Action, public Parented { - public: - void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } - void set_data_static(const uint8_t *data, size_t len) { - this->data_static_ = data; - this->data_static_len_ = len; - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->data_func_) { - auto data = this->data_func_(x...); - this->parent_->transmit_packet(data); - } else if (this->data_static_ != nullptr) { - std::vector data(this->data_static_, this->data_static_ + this->data_static_len_); - this->parent_->transmit_packet(data); - } + this->data_.visit([this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); } - - protected: - std::function(Ts...)> data_func_{}; - const uint8_t *data_static_{nullptr}; - size_t data_static_len_{0}; }; } // namespace esphome::cc1101 diff --git a/tests/component_tests/cc1101/__init__.py b/tests/component_tests/cc1101/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/cc1101/config/payload_tables.yaml b/tests/component_tests/cc1101/config/payload_tables.yaml new file mode 100644 index 00000000000..b478cd6bdb1 --- /dev/null +++ b/tests/component_tests/cc1101/config/payload_tables.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: test + on_boot: + then: + - cc1101.send_packet: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + - cc1101.send_packet: + data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + - cc1101.send_packet: !lambda return {0x09}; + +esp32: + board: esp32dev + +spi: + clk_pin: 18 + mosi_pin: 23 + miso_pin: 19 + +cc1101: + id: transceiver + cs_pin: 5 + gdo0_pin: 4 + frequency: 433.92MHz + packet_mode: true + packet_length: 8 diff --git a/tests/component_tests/cc1101/test_cc1101_payload_tables.py b/tests/component_tests/cc1101/test_cc1101_payload_tables.py new file mode 100644 index 00000000000..f2d3264a326 --- /dev/null +++ b/tests/component_tests/cc1101/test_cc1101_payload_tables.py @@ -0,0 +1,41 @@ +"""Tests for cc1101 constant payloads in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +from esphome.components.cc1101 import SEND_PACKET_ACTION_SCHEMA +import esphome.config_validation as cv + + +def test_constant_payloads_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal send_packet payloads share one table; lambdas stay templates.""" + main_cpp = generate_main(component_config_path("payload_tables.yaml")) + + tables = re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + assert [v for _, v in tables] == [ + "{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}" + ] + assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2 + assert "set_data_template(" in main_cpp + + +@pytest.mark.parametrize("size", [64, 65]) +@pytest.mark.parametrize("make", [lambda n: [0x01] * n, lambda n: "a" * n]) +def test_send_packet_tx_fifo_limit( + size: int, make: Callable[[int], list[int] | str] +) -> None: + """Payloads up to the 64 byte TX FIFO are accepted, longer ones rejected.""" + config = {"id": "transceiver", "data": make(size)} + if size <= 64: + assert len(SEND_PACKET_ACTION_SCHEMA(config)["data"]) == size + else: + with pytest.raises(cv.Invalid, match="at most 64"): + SEND_PACKET_ACTION_SCHEMA(config) From 7a1bfed81f9be769255a032cd0fe76cac5258f4c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:27:43 +1300 Subject: [PATCH 029/435] Bump version to 2026.11.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 16193713239..d9a33a23b70 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.0-dev +PROJECT_NUMBER = 2026.11.0-dev # 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 fb02d702810..7774722795d 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.0-dev" +__version__ = "2026.11.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6d3792e5c5d9fa9d8c50b8f59c68149ff467b1c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:22:07 +1300 Subject: [PATCH 030/435] [ci] Only run sync dependency versions workflow on PRs targeting dev (#20316) --- .github/workflows/sync-dependency-versions.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml index 5599691ed19..73a3f7dbaa9 100644 --- a/.github/workflows/sync-dependency-versions.yml +++ b/.github/workflows/sync-dependency-versions.yml @@ -15,6 +15,8 @@ on: # base branch checkout, so fork code never runs with the token. pull_request_target: types: [opened, synchronize, reopened] + branches: + - dev paths: - requirements_dev.txt - requirements_test.txt From 8f003dfa3b11172551ad7bf2765c9149f0f026fe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:31:24 -1000 Subject: [PATCH 031/435] Bump aioesphomeapi from 46.6.0 to 46.7.0 (#20317) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91a36ecbf70..5eff3a7d09b 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.6.0 +aioesphomeapi==46.7.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 883af036a999a1d1b6eef650d55c7ecfa18c1845 Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:57:46 +1000 Subject: [PATCH 032/435] [tas58xx] Improve TAS5805M audio performance and use GPIO1 for TAS5825M faults (#20319) --- esphome/components/tas58xx/model_tas5805m.cpp | 7 +++-- esphome/components/tas58xx/model_tas5825m.cpp | 27 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index de0a0c5d750..09b369435e9 100644 --- a/esphome/components/tas58xx/model_tas5805m.cpp +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -15,9 +15,12 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { {0x60, 0x01}, // ADR_PIN_CTRL: output {0x7D, 0x11}, {0x7E, 0xFF}, - {0x00, 0x01}, + {0x00, 0x01}, // Page 1 {0x51, 0x05}, - {0x00, 0x00}, + {0x00, 0x00}, // Page 0 + + // Additional register configuration + {0x53, 0x60}, // ANA_CTRL: for high audio performance use 175kHz bandwidth with Fsw=768kHz }; static const LogString *model_name() { return LOG_STR("TAS5805M"); } diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp index 3aa74c675e5..cf5861cb4a4 100644 --- a/esphome/components/tas58xx/model_tas5825m.cpp +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -4,13 +4,11 @@ namespace esphome::tas58xx { -// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page. -// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console. -// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet. - -// clang-format off +// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page. +// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k". +// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet. static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { - {0x03, 0x00}, // Deep Sleep + {0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from {0x7D, 0x11}, {0x7E, 0xFF}, {0x00, 0x01}, // Page 1 @@ -20,15 +18,16 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { {0x19, 0x80}, {0x00, 0x00}, // Page 0 {0x46, 0x11}, - {0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE - {0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase - {0x54, 0x00}, // AGAIN 0dB - {0x03, 0x02}, // Hi-Z - {0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output - {0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output - {0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults + // PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML + {0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz + {0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z + + // Additional register configuration + // GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ + {0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ + {0x60, 0x02}, // GPIO_CTRL: GPIO1 is output + {0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults }; -// clang-format on static const LogString *model_name() { return LOG_STR("TAS5825M"); } From 68a5e33182848d01f8fb581beef3f8e24b4268a7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:17:23 +0000 Subject: [PATCH 033/435] Bump aioesphomeapi from 46.7.0 to 46.8.0 (#20321) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5eff3a7d09b..87821677e30 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.7.0 +aioesphomeapi==46.8.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 6a6d263c0d2e1784dac3fd324e6a339b07b2bc1b Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 7 Oct 2026 21:17:16 -0500 Subject: [PATCH 034/435] [core] Wake the main loop on its own task notification index on ESP32 (#20326) Co-authored-by: Claude Opus 5.5 (1M context) --- esphome/components/esp32/__init__.py | 4 ++++ esphome/core/application.h | 4 ++-- esphome/core/lwip_fast_select.c | 10 +++++----- esphome/core/lwip_fast_select.h | 8 ++++---- esphome/core/main_task.h | 26 ++++++++++++++++++++++++++ esphome/core/wake/wake_freertos.h | 8 ++++---- sdkconfig.defaults | 1 + 7 files changed, 46 insertions(+), 15 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index df72c590a33..c825deaefd9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3074,6 +3074,10 @@ async def to_code(config): # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) + # Main loop wakes use notification index 1; index 0 stays free for ESP-IDF waits + # such as pthread_join(), which a stray main-loop wake would otherwise end early. + add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES", 2) + # Place non-ISR FreeRTOS functions into flash instead of IRAM # This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM. # In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH diff --git a/esphome/core/application.h b/esphome/core/application.h index 2f399cbdcd5..b91bc187e6f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -673,8 +673,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) // Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so // subsequent Socket::ready() checks in this iter observe the published state - // without a per-call memw. Wake is independent (xTaskNotifyGive/ - // ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. + // without a per-call memw. Wake is independent (esphome_main_task_notify/ + // esphome_main_task_wait), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. // BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data() // instead. std::atomic_thread_fence(std::memory_order_acquire); diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 2042c438044..8e68594a030 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -99,10 +99,10 @@ // ESP32-C3/C6/H2) cross-core visibility is not an issue. // // FreeRTOS task notification value: -// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks -// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake). +// Written by TCP/IP thread (esphome_main_task_notify in callback) and background tasks +// (esphome_main_task_notify in wake_main_loop). Read by main loop (esphome_main_task_wait). // Safe: FreeRTOS notification APIs are thread-safe by design (use internal -// critical sections). Multiple concurrent xTaskNotifyGive calls are safe — +// critical sections). Multiple concurrent notify calls are safe — // the notification count simply increments. // USE_LWIP_FAST_SELECT is set via -D build flag (not cg.add_define) so it is @@ -178,7 +178,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // s_original_callback is always valid here: hook_socket() sets it before swapping // the callback pointer, so this wrapper cannot run until it's initialized. s_original_callback(conn, evt, len); - // Wake the main loop task if sleeping in ulTaskNotifyTake(). + // Wake the main loop task if sleeping in esphome_main_task_wait(). // Only notify on receive events to avoid spurious wakeups from send-ready events. // NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions @@ -186,7 +186,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt if (evt == NETCONN_EVT_RCVPLUS) { #ifdef USE_OTA_PLATFORM_ESPHOME // Mark OTA pending-enable only for events on its listen socket. MUST happen - // before xTaskNotifyGive so the flags are visible when the main task wakes. + // before the main task is notified so the flags are visible when it wakes. if (conn == s_ota_listener_conn) { esphome_wake_ota_component_any_context(); } diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 4ba2606d76c..69798fd8eb4 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -33,9 +33,9 @@ struct lwip_sock *esphome_lwip_get_sock(int fd); /// per-call `memw` that volatile would emit on Xtensa under default /// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped /// and the volatile load provides ordering on its own. -/// Stale reads are harmless either way: the hooked event_callback -/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and -/// ulTaskNotifyTake never loses a wake. +/// Stale reads are harmless either way: the hooked event_callback calls +/// esphome_main_task_notify() on RCVPLUS, so the next iteration re-snapshots and +/// esphome_main_task_wait() never loses a wake. /// The offset and size are verified at compile time in lwip_fast_select.c. static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS @@ -46,7 +46,7 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { } /// Hook a socket's netconn callback to notify the main loop task on receive events. -/// Wraps the original event_callback with one that also calls xTaskNotifyGive(). +/// Wraps the original event_callback with one that also calls esphome_main_task_notify(). /// Must be called from the main loop after socket creation. /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index 3aa8669e445..836d13d8f77 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -19,12 +19,25 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; +#ifdef USE_ESP32 +/// ESP-IDF waits on index 0 from the main task (e.g. pthread_join() uses an unfiltered +/// xTaskNotifyWait), so main-loop wakes on index 0 would end those waits early. +enum { ESPHOME_MAIN_TASK_NOTIFY_INDEX = 1 }; +#if configTASK_NOTIFICATION_ARRAY_ENTRIES < 2 +#error "CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES must be at least 2" +#endif +#endif + /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. /// always_inline so callers placed in IRAM do not reference a flash-resident copy. __attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { +#ifdef USE_ESP32 + xTaskNotifyGiveIndexed(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX); +#else xTaskNotifyGive(task); +#endif } } @@ -33,10 +46,23 @@ __attribute__((always_inline)) static inline void esphome_main_task_notify_from_ BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { +#ifdef USE_ESP32 + vTaskNotifyGiveIndexedFromISR(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX, px_higher_priority_task_woken); +#else vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); +#endif } } +/// Block the main loop task until it is woken or the timeout expires. +__attribute__((always_inline)) static inline void esphome_main_task_wait(TickType_t ticks) { +#ifdef USE_ESP32 + ulTaskNotifyTakeIndexed(ESPHOME_MAIN_TASK_NOTIFY_INDEX, pdTRUE, ticks); +#else + ulTaskNotifyTake(pdTRUE, ticks); +#endif +} + #ifdef __cplusplus } #endif diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h index 167a422c614..16afa38fda7 100644 --- a/esphome/core/wake/wake_freertos.h +++ b/esphome/core/wake/wake_freertos.h @@ -43,15 +43,15 @@ namespace internal { inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: + // socket wake-ups. Every NETCONN_EVT_RCVPLUS calls esphome_main_task_notify(), so any notification + // that lands between wakes keeps the counter non-zero (next esphome_main_task_wait() returns + // immediately) or wakes a blocked wait directly. Additional wake sources: // wake_loop_threadsafe() from background tasks, and the ms timeout. if (ms == 0) [[unlikely]] { yield(); return; } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); + esphome_main_task_wait(pdMS_TO_TICKS(ms)); } } // namespace internal diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f4fe331df45..25c4cc4d36c 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -13,6 +13,7 @@ CONFIG_LIBC_NEWLIB=y # esp32 CONFIG_COMPILER_OPTIMIZATION_SIZE=y CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n From 42edfe0681cdd5023c7285dbd2fbfc53c0f5c40e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:18:47 +0200 Subject: [PATCH 035/435] [CI] Remove obsolete platform labels (#20301) --- .github/scripts/auto-label-pr/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 8b0c8215031..1d071b38662 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -53,7 +53,7 @@ module.exports = async ({ github, context }) => { }); const currentLabels = currentLabelsData.map(label => label.name); const managedLabels = currentLabels.filter(label => - label.startsWith('component: ') || MANAGED_LABELS.includes(label) + label.startsWith('component: ') || label.startsWith('platform: ') || MANAGED_LABELS.includes(label) ); // Check for mega-PR early - if present, skip most automatic labeling From 640ef1bb173c50ee4fa29fae3630b36a10cf9baa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 16:43:34 -1000 Subject: [PATCH 036/435] [canbus] Cover shared, string and trigger argument send payloads in the component tests (#20306) --- tests/components/canbus/common.yaml | 4 ++++ tests/components/mcp2515/common.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 3ba3564608a..57ee25bfc16 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -18,6 +18,7 @@ canbus: lambda: "return x[0] == 0x11;" then: logger.log: Truth + - canbus.send: !lambda return x; - can_id: 0b00000000000000000000001000000 can_id_mask: 0b11111000000000011111111000000 use_extended_id: true @@ -59,6 +60,9 @@ button: data: [0, 1, 2] - canbus.send: "abc" - canbus.send: [0, 1, 2] + # Identical on purpose: both sends share one constant payload + - canbus.send: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88] + - canbus.send: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88] - canbus.send: !lambda return {0, 1, 2}; # Test canbus.send with lambda that references a component (function pointer) - canbus.send: !lambda |- diff --git a/tests/components/mcp2515/common.yaml b/tests/components/mcp2515/common.yaml index 15639df5fe9..2e88f3e5c64 100644 --- a/tests/components/mcp2515/common.yaml +++ b/tests/components/mcp2515/common.yaml @@ -10,6 +10,11 @@ canbus: - lambda: |- std::string b(x.begin(), x.end()); ESP_LOGD("can_id 500", "%s", b.c_str()); + - canbus.send: + canbus_id: mcp2515_can + can_id: 501 + data: !lambda |- + return {static_cast(can_id), x.empty() ? uint8_t(0) : x[0]}; - can_id: 23 then: - if: @@ -36,3 +41,22 @@ canbus: break; // to be continued... } + +button: + - platform: template + name: MCP2515 Send + on_press: + # Identical on purpose: both sends share one constant payload + - canbus.send: + canbus_id: mcp2515_can + data: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88] + - canbus.send: + canbus_id: mcp2515_can + data: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88] + - canbus.send: + canbus_id: mcp2515_can + can_id: 0x123 + data: "abc" + - canbus.send: + canbus_id: mcp2515_can + data: !lambda return {0x01, 0x02}; From 1ee4940d4d85644da729efe4e36c0787e4ae096a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 16:43:44 -1000 Subject: [PATCH 037/435] [remote_base] Cover shared, empty and trigger argument abbwelcome payloads in the component test (#20307) --- .../remote_transmitter/common-buttons.yaml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index a41ba346aa1..bdb5f2eea25 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -5,6 +5,13 @@ number: min_value: 0 max_value: 255 step: 1 + on_value: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x01 + data: !lambda |- + return {(uint8_t) x, 0x20}; button: - platform: template @@ -260,6 +267,22 @@ button: destination_address: 0x5678 message_type: 0x01 data: [0x10, 0x20, 0x30] + - platform: template + name: ABBWelcome static same data + on_press: + remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x02 + data: [0x10, 0x20, 0x30] + - platform: template + name: ABBWelcome static empty data + on_press: + remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x03 + data: [] - platform: template name: ABBWelcome lambda on_press: From 31c488e5b059dd0e825fbf562d39e267ed89ad86 Mon Sep 17 00:00:00 2001 From: David Buezas Date: Thu, 8 Oct 2026 04:53:12 +0200 Subject: [PATCH 038/435] [cc1101] Keep the remote_receiver interrupt on a shared GDO0 pin in begin_tx (#20296) --- esphome/components/cc1101/cc1101.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 91a29d8d533..6a4a3a4d531 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -259,11 +259,17 @@ void CC1101Component::dump_config() { } void CC1101Component::begin_tx() { + // Read the mode before the PKTCTRL0 write below overwrites it in state_ + const bool packet_mode = this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO); // Ensure Packet Format is 3 (Async Serial) this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); if (this->gdo0_pin_ != nullptr) { - this->gdo0_pin_->detach_interrupt(); + // setup() only attaches the interrupt in packet mode. In async serial mode the pin may be + // shared with remote_receiver, and detaching here would remove the receiver's interrupt. + if (packet_mode) { + this->gdo0_pin_->detach_interrupt(); + } this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); } // Transition through IDLE to bypass CCA (Clear Channel Assessment) which can From 0f552a8eef8f8575c625c154a08c2f8c03d6de91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:14:40 -1000 Subject: [PATCH 039/435] Pin smp to 4.1.0 on Intel macOS so cbor2 installs from a wheel (#20302) --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 87821677e30..6e1dfa1ba51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,9 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 smpclient==7.3.0 +# smp 4.2+ needs cbor2 6+, which ships no Intel macOS wheels (arm64 only). +smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" +smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.3 # native esp-idf toolchain global cache dir From 8eba5a1c28178b9315f9bdc9e7701518bba800b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:23:01 -1000 Subject: [PATCH 040/435] [core] Warn on every run that Intel Mac support ends by 2027.6.0 (#20303) --- esphome/__main__.py | 36 +++++++++++++++++++++++++++----- tests/unit_tests/test_main.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6c984050f42..905339d2bcc 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -7,6 +7,7 @@ import importlib import logging import os from pathlib import Path +import platform import re import sys import time @@ -54,7 +55,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError, coroutine from esphome.enum import StrEnum -from esphome.helpers import get_bool_env, indent, is_ip_address +from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.stacktrace import LogLineProcessor from esphome.types import ConfigType @@ -1311,9 +1312,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: # platform's final-validate hook merges duplicates anyway. available: dict[str, None] = {} for ota_item in config.get(CONF_OTA, []): - platform = ota_item.get(CONF_PLATFORM) - if platform in (CONF_ESPHOME, CONF_WEB_SERVER): - available[platform] = None + ota_platform = ota_item.get(CONF_PLATFORM) + if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER): + available[ota_platform] = None if not available: raise EsphomeError( @@ -1945,7 +1946,10 @@ def run_multiple_configs( safe_print() cmd = command_builder(f) - rc = run_external_process(*cmd) + # The parent already logged the Intel macOS warning; children skip it. + rc = run_external_process( + *cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"} + ) if rc == 0: print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") @@ -2614,6 +2618,27 @@ def _warn_if_source_tree_mismatch() -> None: ) +_INTEL_MACOS_REMOVAL = "2027.6.0" +_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED" + + +def _warn_if_intel_macos() -> None: + """Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL.""" + if ( + not IS_MACOS + or platform.machine() != "x86_64" + or _INTEL_MACOS_WARNED_ENV in os.environ + ): + return + _LOGGER.warning( + "Support for Intel Macs will end in ESPHome %s or earlier. The Python " + "packages ESPHome depends on have stopped publishing Intel macOS builds, " + "so future releases will not install on this machine. On an Apple " + "Silicon Mac, switch to a native arm64 Python.", + _INTEL_MACOS_REMOVAL, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2633,6 +2658,7 @@ def run_esphome(argv): setup_log(log_level=args.log_level) _warn_if_source_tree_mismatch() + _warn_if_intel_macos() if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4edf6d5fc31..37d8e7486f4 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6705,6 +6705,45 @@ def test_check_permissions_unreadable_port() -> None: check_permissions("/dev/ttyUSB99") +@pytest.mark.parametrize( + ("is_macos", "machine", "warned", "warns"), + [ + pytest.param(True, "x86_64", False, True, id="intel_mac"), + pytest.param(True, "x86_64", True, False, id="intel_mac_child"), + pytest.param(True, "arm64", False, False, id="apple_silicon"), + pytest.param(False, "x86_64", False, False, id="linux_x86_64"), + ], +) +def test_warn_if_intel_macos( + is_macos: bool, + machine: str, + warned: bool, + warns: bool, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Only Intel Python on macOS gets the warning, and only once per invocation.""" + monkeypatch.setattr(main, "IS_MACOS", is_macos) + monkeypatch.setattr(main.platform, "machine", lambda: machine) + if warned: + monkeypatch.setenv(main._INTEL_MACOS_WARNED_ENV, "1") + else: + monkeypatch.delenv(main._INTEL_MACOS_WARNED_ENV, raising=False) + + with caplog.at_level(logging.WARNING): + main._warn_if_intel_macos() + + assert (main._INTEL_MACOS_REMOVAL in caplog.text) is warns + + +def test_run_multiple_configs_marks_children_warned(tmp_path: Path) -> None: + """Multi-config children get the env marker so they skip the Intel warning.""" + with patch.object(main, "run_external_process", return_value=0) as mock_run: + main.run_multiple_configs([tmp_path / "a.yaml"], lambda f: ["esphome", str(f)]) + + assert mock_run.call_args.kwargs["env"][main._INTEL_MACOS_WARNED_ENV] == "1" + + def _make_checkout(root: Path) -> Path: """Create a directory that looks like an esphome checkout.""" (root / "esphome").mkdir(parents=True) From 7aeed8455e47b8f0cf804492081b02488ffa190e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:24:27 -1000 Subject: [PATCH 041/435] [api] Add a progmem option for bytes fields kept in flash (#20318) --- esphome/components/api/api.proto | 4 +- esphome/components/api/api_connection.h | 3 -- esphome/components/api/api_options.proto | 5 ++ esphome/components/api/api_pb2.cpp | 2 +- esphome/components/api/api_pb2_dump.cpp | 17 ++++-- esphome/components/api/api_wizard.cpp | 21 +------- esphome/components/api/api_wizard.h | 4 -- esphome/components/api/proto.h | 16 ++++++ script/api_protobuf/api_protobuf.py | 46 ++++++++++++++-- tests/components/api/test_wizard.cpp | 6 +-- .../api/test_api_protobuf_generator.py | 53 +++++++++++++++++++ 11 files changed, 138 insertions(+), 39 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2648b5d0d88..0d2ac36937c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -468,11 +468,9 @@ message DeviceWizardResponse { option (id) = 157; option (source) = SOURCE_SERVER; option (ifdef) = "USE_API_WIZARD"; - // Not logged: the data is in flash, which ESP8266 cannot read for a dump - option (log) = false; // The data is in flash, and the device sends it from there - bytes data = 1 [(pointer_to_buffer) = true]; + bytes data = 1 [(pointer_to_buffer) = true, (progmem) = true]; } // Sets the entity id of a wizard input (see WizardInputField.key). Clients send diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4d927b28857..b7f0bda0ed7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -423,9 +423,6 @@ class APIConnection final : public APIServerConnectionBase { bool send_ping_response_(); bool send_device_info_response_(); bool send_device_capabilities_response_(); -#ifdef USE_API_WIZARD - bool send_device_wizard_response_(); -#endif #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 66295b3d535..8e8fa99833d 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions { // to true when the field arrives, so an all-default submessage can be told apart // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). optional bool track_presence = 50020 [default=false]; + + // progmem: The pointer_to_buffer bytes field points into flash (PROGMEM). + // The generated encode and dump copy it with progmem_memcpy, as ESP8266 + // cannot read flash with ordinary loads. + optional bool progmem = 50021 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 67a5d9792e5..646a8f4211e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -352,7 +352,7 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #ifdef USE_API_WIZARD uint8_t *DeviceWizardResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); return pos; } uint32_t DeviceWizardResponse::calc_size_msg(const void *self) { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 966ba8f2bf5..196a4249cc6 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -114,15 +114,26 @@ template static void dump_field(DumpBuffer &out, const char *field_n out.append("\n"); } +// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer +static constexpr size_t DUMP_BYTES_MAX = 160; + // Helper for bytes fields - uses stack buffer to avoid heap allocation -// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer // field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms) static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) { - char hex_buf[format_hex_pretty_size(160)]; + char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)]; append_field_prefix(out, field_name, indent); format_hex_pretty_to(hex_buf, data, len); out.append(hex_buf).append("\n"); } + +// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first +static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, + int indent = 2) { + uint8_t data_buf[DUMP_BYTES_MAX]; + len = std::min(len, sizeof(data_buf)); + progmem_memcpy(data_buf, data, len); + dump_bytes_field(out, field_name, data_buf, len, indent); +} #pragma GCC diagnostic pop template<> const char *proto_enum_to_string(enums::DisconnectReason value) { @@ -1119,7 +1130,7 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_WIZARD const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse")); - dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); + dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); return out.c_str(); } #endif diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index ec270ab4111..986017a138d 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -13,17 +13,6 @@ namespace esphome::api { static const char *const TAG = "api.wizard"; -uint8_t *wizard_encode_response(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { - const auto &msg = *static_cast(self); - if (msg.data_len == 0) - return pos; - pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited - pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len); - PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len); - progmem_memcpy(pos, msg.data, msg.data_len); - return pos + msg.data_len; -} - #ifdef USE_API_WIZARD_INPUTS static bool wizard_entity_id_valid(const char *entity_id, size_t length) { return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; @@ -49,17 +38,11 @@ const char *wizard_set_input(const WizardInputSetRequest &msg) { } #endif // USE_API_WIZARD_INPUTS -bool APIConnection::send_device_wizard_response_() { +void APIConnection::on_device_wizard_request() { DeviceWizardResponse resp; resp.data = API_WIZARD_DATA; resp.data_len = API_WIZARD_DATA_SIZE; - // Not send_message: the data is in flash, so wizard_encode_response copies it out - return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE, - &wizard_encode_response, &resp); -} - -void APIConnection::on_device_wizard_request() { - if (!this->send_device_wizard_response_()) { + if (!this->send_message(resp)) { this->on_fatal_error(); } } diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 948b9445f22..7d5a566b550 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -22,10 +22,6 @@ static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256; /// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long. extern const uint8_t API_WIZARD_DATA[] PROGMEM; -/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read -/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere. -uint8_t *wizard_encode_response(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); - #ifdef USE_API_WIZARD_INPUTS /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 5f2a90d5472..0fdda5067ea 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -3,6 +3,7 @@ #include "api_pb2_defines.h" #include "api_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -465,6 +466,21 @@ class ProtoEncode { uint32_t field_id, const uint8_t *data, size_t len) { return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len); } + /// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy. + [[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + if (len == 0) + return pos; + return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len); + } + [[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited + pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len); + PROTO_ENCODE_CHECK_BOUNDS(pos, len); + progmem_memcpy(pos, data, len); + return pos + len; + } [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f644859d013..701765bb419 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -536,6 +536,16 @@ def create_field_type_info( f"track_presence on field '{field.name}' has no effect; it requires " "a non-repeated message field in a message that is decoded" ) + if get_field_opt(field, pb.progmem, False) and ( + field.label == FieldDescriptorProto.LABEL_REPEATED + or field.type != 12 + or not get_field_opt(field, pb.pointer_to_buffer, False) + or get_field_opt(field, pb.fixed_array_size) is not None + ): + raise ValueError( + f"progmem on field '{field.name}' requires a non-repeated bytes field " + "with pointer_to_buffer" + ) if field.label == FieldDescriptorProto.LABEL_REPEATED: # Check if this is a packed_buffer field (zero-copy packed repeated) if get_field_opt(field, pb.packed_buffer, False): @@ -1158,6 +1168,11 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): reference_type = "const uint8_t*" const_reference_type = "const uint8_t*" + @property + def progmem(self) -> bool: + """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" + return get_field_opt(self._field, pb.progmem, False) + @property def public_content(self) -> list[str]: # Use uint16_t for length - max packet size is well below 65535 @@ -1168,6 +1183,14 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): @property def encode_content(self) -> str: + if self.progmem: + return _encode_call( + "encode_progmem_bytes", + str(self.number), + f"this->{self.field_name}", + f"this->{self.field_name}_len", + force=self.force, + ) if result := self._encode_bytes_with_precomputed_tag( f"this->{self.field_name}", f"this->{self.field_name}_len" ): @@ -1194,8 +1217,9 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): @property def dump_content(self) -> str: + dump_fn = "dump_progmem_bytes_field" if self.progmem else "dump_bytes_field" return ( - f'dump_bytes_field(out, ESPHOME_PSTR("{self.name}"), ' + f'{dump_fn}(out, ESPHOME_PSTR("{self.name}"), ' f"this->{self.field_name}, this->{self.field_name}_len);" ) @@ -2609,6 +2633,11 @@ def build_message_type( ): fixed_vector_fields.append((field.name, field.number)) + if needs_decode and get_field_opt(field, pb.progmem, False): + raise ValueError( + f"progmem on field '{field.name}' of {desc.name} requires a message " + "that is only encoded; received data is never in flash" + ) ti = create_field_type_info(field, needs_decode, needs_encode) # Skip field declarations for fields that are in the base class @@ -3319,15 +3348,26 @@ template static void dump_field(DumpBuffer &out, const char *field_n out.append("\\n"); } +// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer +static constexpr size_t DUMP_BYTES_MAX = 160; + // Helper for bytes fields - uses stack buffer to avoid heap allocation -// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer // field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms) static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) { - char hex_buf[format_hex_pretty_size(160)]; + char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)]; append_field_prefix(out, field_name, indent); format_hex_pretty_to(hex_buf, data, len); out.append(hex_buf).append("\\n"); } + +// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first +static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, + int indent = 2) { + uint8_t data_buf[DUMP_BYTES_MAX]; + len = std::min(len, sizeof(data_buf)); + progmem_memcpy(data_buf, data, len); + dump_bytes_field(out, field_name, data_buf, len, indent); +} #pragma GCC diagnostic pop """ diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 2e6b38edb99..033c8b627e5 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -68,7 +68,7 @@ TEST(DeviceWizard, ResponseSendsTheDataUnchanged) { Bytes expected{0x0a, 0xc8, 0x01}; expected.insert(expected.end(), API_WIZARD_DATA, API_WIZARD_DATA + API_WIZARD_DATA_SIZE); - EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected); } TEST(DeviceWizard, ShortDataHasAOneByteLength) { @@ -77,13 +77,13 @@ TEST(DeviceWizard, ShortDataHasAOneByteLength) { resp.data_len = 3; Bytes expected{0x0a, 0x03, API_WIZARD_DATA[0], API_WIZARD_DATA[1], API_WIZARD_DATA[2]}; - EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected); } TEST(DeviceWizard, NoDataEncodesNothing) { DeviceWizardResponse resp; EXPECT_EQ(resp.calculate_size(), 0u); - EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response).empty()); + EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg).empty()); } TEST(DeviceWizard, CapabilitiesAnnounceTheWizard) { diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 2e4eb67a241..5e29b06ef07 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -360,3 +360,56 @@ def test_repeated_strings_generate_in_every_direction(source: int) -> None: assert "nullptr" not in header if source != SOURCE_SERVER: assert "case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED):" in cpp + + +def _progmem_field( + *, pointer_to_buffer: bool = True, force: bool = False, repeated: bool = False +) -> descriptor_pb2.FieldDescriptorProto: + field = _field(12, force=force, repeated=repeated) + field.options.Extensions[pb.progmem] = True + if pointer_to_buffer: + field.options.Extensions[pb.pointer_to_buffer] = True + return field + + +@pytest.mark.parametrize( + ("force", "encode_fn"), + [(False, "encode_progmem_bytes("), (True, "encode_progmem_bytes_force(")], +) +def test_progmem_bytes_field_copies_from_flash(force: bool, encode_fn: str) -> None: + """A (progmem) field encodes and dumps through the progmem_memcpy helpers.""" + ti = create_field_type_info(_progmem_field(force=force), needs_decode=False) + assert encode_fn in ti.encode_content + assert "encode_bytes" not in ti.encode_content + assert "dump_progmem_bytes_field(" in ti.dump_content + + +@pytest.mark.parametrize( + "field", + [ + pytest.param(_progmem_field(pointer_to_buffer=False), id="no_pointer"), + pytest.param(_progmem_field(repeated=True), id="repeated"), + ], +) +def test_progmem_rejected_where_it_cannot_apply( + field: descriptor_pb2.FieldDescriptorProto, +) -> None: + """(progmem) on a field that would not copy from flash fails instead of silently using memcpy.""" + with pytest.raises(ValueError, match="progmem on field 'value'"): + create_field_type_info(field, needs_decode=False) + + +def test_progmem_rejected_in_a_decoded_message() -> None: + """Received data is never in flash, so a decoded message cannot use (progmem).""" + desc = descriptor_pb2.DescriptorProto(name="Received") + desc.field.add().CopyFrom(_progmem_field()) + with pytest.raises(ValueError, match="progmem on field 'value' of Received"): + build_message_type(desc, {}, {"Received": SOURCE_CLIENT}) + + +def test_progmem_rejected_on_non_bytes_field() -> None: + """(progmem) only applies to bytes fields.""" + field = _field(9) + field.options.Extensions[pb.progmem] = True + with pytest.raises(ValueError, match="progmem on field 'value'"): + create_field_type_info(field, needs_decode=False) From 0a7763d9029133d37e440709e9e041abf62973ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 18:05:18 -1000 Subject: [PATCH 042/435] [esp8266] Normalize misspelled boards and fall back to PlatformIO for unknown ones (#20322) --- esphome/components/esp8266/__init__.py | 31 ++++++++++- .../esp8266/test_toolchain_validation.py | 53 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index e4d8dfc4928..d0d870c2894 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -114,9 +114,37 @@ _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) _resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO) +# Known boards keyed by spelling with case and "-" / "_" folded away +_FOLDED_BOARDS = {board.lower().replace("-", "_"): board for board in BOARDS} + + +def _resolve_board(config: ConfigType) -> ConfigType: + """Fix a misspelled board, else build an unknown one with PlatformIO.""" + board = config[CONF_BOARD] + if board in BOARDS: + return config + if canonical := _FOLDED_BOARDS.get(board.lower().replace("-", "_")): + _LOGGER.warning( + "Board '%s' is not a known ESP8266 board; using '%s'", board, canonical + ) + return {**config, CONF_BOARD: canonical} + if CORE.toolchain is None and CONF_TOOLCHAIN not in config: + return {**config, CONF_TOOLCHAIN: Toolchain.PLATFORMIO} + return config + + def _warn_platformio_toolchain(config: ConfigType) -> ConfigType: # Remove before 2027.4.0 - if CORE.using_toolchain_platformio: + if not CORE.using_toolchain_platformio: + return config + if config[CONF_BOARD] not in BOARDS: + _LOGGER.warning( + "Board '%s' is not supported by the native 'arduino' toolchain, so it " + "builds with the deprecated 'platformio' toolchain, which will be " + "removed in ESPHome 2027.4.0", + config[CONF_BOARD], + ) + else: _LOGGER.warning( "The 'platformio' toolchain for ESP8266 is deprecated and will be " "removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the " @@ -329,6 +357,7 @@ CONFIG_SCHEMA = cv.All( ): _validate_toolchain, } ), + _resolve_board, _resolve_toolchain, _warn_platformio_toolchain, _validate_native_toolchain, diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index e7f443c2d23..1092c168ad2 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -12,10 +12,12 @@ import pytest from esphome.components import esp8266 from esphome.components.esp8266 import ( ARDUINO_FRAMEWORK_SCHEMA, + _resolve_board, _resolve_toolchain, _validate_native_toolchain, _warn_platformio_toolchain, ) +from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266 import esphome.config_validation as cv from esphome.const import ( CONF_BOARD, @@ -118,6 +120,57 @@ def test_unsupported_board_rejected() -> None: _validate_native_toolchain(_config(board="not_a_board")) +def test_known_board_passes_unchanged() -> None: + config = {CONF_BOARD: "esp01_1m"} + assert _resolve_board(config) is config + + +@pytest.mark.parametrize( + ("board", "expected"), + [("ESP01-1M", "esp01_1m"), ("SPARKFUNBLYNK", "sparkfunBlynk")], +) +def test_misspelled_board_normalized( + board: str, expected: str, caplog: pytest.LogCaptureFixture +) -> None: + assert _resolve_board({CONF_BOARD: board})[CONF_BOARD] == expected + assert f"using '{expected}'" in caplog.text + + +@pytest.mark.parametrize( + ("board", "expected_board", "expected_toolchain"), + [ + ("esp01-1m", "esp01_1m", Toolchain.ARDUINO), + ("my_custom_board", "my_custom_board", Toolchain.PLATFORMIO), + ], +) +def test_config_schema_resolves_board_before_toolchain( + board: str, + expected_board: str, + expected_toolchain: Toolchain, + caplog: pytest.LogCaptureFixture, +) -> None: + """The full chain normalizes or falls back, and warns once.""" + CORE.toolchain = None + config = esp8266.CONFIG_SCHEMA({CONF_BOARD: board}) + assert config[CONF_BOARD] == expected_board + assert CORE.data[KEY_ESP8266][KEY_BOARD] == expected_board + assert CORE.toolchain == expected_toolchain + assert len(caplog.records) == 1 + + +@pytest.mark.parametrize( + ("cli_toolchain", "yaml"), + [(Toolchain.ARDUINO, {}), (None, {CONF_TOOLCHAIN: Toolchain.ARDUINO})], +) +def test_unknown_board_keeps_explicit_toolchain( + cli_toolchain: Toolchain | None, yaml: ConfigType +) -> None: + """A CLI or YAML toolchain is not overridden.""" + CORE.toolchain = cli_toolchain + config = {CONF_BOARD: "my_custom_board", **yaml} + assert _resolve_board(config) is config + + def test_yaml_toolchain_key_resolves() -> None: """The documented `toolchain: arduino` YAML key selects the native path.""" CORE.toolchain = None From 73bbd75d3fc747e92aff3c6b7647c269157793bf Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:29:56 +1300 Subject: [PATCH 043/435] [core] Let cv.rename_key pass non-dict input through (#20331) --- esphome/config_validation.py | 5 +++++ tests/unit_tests/test_config_validation.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 579da9a315e..79d7068838a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2872,9 +2872,14 @@ def rename_key( When ``removed_in`` is set, a deprecation warning is logged if the old key is present. Pass ``component`` (the platform/component name) alongside ``removed_in`` so the warning identifies where it originates. + + Input that is not a dictionary is returned unchanged, so the schema that + follows reports it as a normal configuration error. """ def validator(config: dict) -> dict: + if not isinstance(config, dict): + return config config = config.copy() if old_key in config: has_at_most_one_key(old_key, new_key)(config) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 230a8e1f9ef..742f07c8dec 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -3120,6 +3120,11 @@ def test_rename_key_absent() -> None: assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} +@pytest.mark.parametrize("value", ["an_id", 1, None, ["old"]]) +def test_rename_key_non_dict_passes_through(value: object) -> None: + assert cv.rename_key("old", "new")(value) == value + + def test_rename_key_no_removed_in_is_silent( caplog: pytest.LogCaptureFixture, ) -> None: From 23ba80c9c7b292db258b3d18f5615a9269cb22e1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:03:30 -1000 Subject: [PATCH 044/435] Bump bundled esphome-device-builder to 1.22.0 (#20334) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9f29062e093..0282011f504 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.22.0 RUN \ platformio settings set enable_telemetry No \ From 57eeca63410dbbae848e49cf6c9992c5b5c31949 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:15:43 +1300 Subject: [PATCH 045/435] [combination] Use cv.rename_key for the deprecated coeffecient key (#20159) Co-authored-by: J. Nick Koston --- esphome/components/combination/sensor.py | 32 ++++--------------- .../validate-deprecated.esp32-idf.yaml | 15 +++++++++ 2 files changed, 22 insertions(+), 25 deletions(-) create mode 100644 tests/components/combination/validate-deprecated.esp32-idf.yaml diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index 34e9b99ce84..608ebac4d6a 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -1,5 +1,3 @@ -import logging - import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -18,8 +16,6 @@ from esphome.const import ( from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@Cat-Ion", "@kahrendt"] combination_ns = cg.esphome_ns.namespace("combination") @@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema( ) -def _migrate_coeffecient(config: ConfigType) -> ConfigType: - """Migrate deprecated 'coeffecient' spelling to 'coefficient'.""" - if CONF_COEFFECIENT in config: - if CONF_COEFFICIENT in config: - raise cv.Invalid( - f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'" - ) - _LOGGER.warning( - "'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0", - CONF_COEFFECIENT, - CONF_COEFFICIENT, - ) - config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT) - elif CONF_COEFFICIENT not in config: - raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option") - return config - - LINEAR_SOURCE_SCHEMA = cv.All( + cv.rename_key( + CONF_COEFFECIENT, + CONF_COEFFICIENT, + removed_in="2026.12.0", + component="combination", + ), cv.Schema( { cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor), - cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_), - cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_), + cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_), } ), - _migrate_coeffecient, ) SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema( diff --git a/tests/components/combination/validate-deprecated.esp32-idf.yaml b/tests/components/combination/validate-deprecated.esp32-idf.yaml new file mode 100644 index 00000000000..829d76445d5 --- /dev/null +++ b/tests/components/combination/validate-deprecated.esp32-idf.yaml @@ -0,0 +1,15 @@ +sensor: + - platform: template + id: template_temperature1 + lambda: return 0.6; + - platform: template + id: template_temperature2 + lambda: return 0.8; + - platform: combination + type: linear + name: Linearly combined temperatures + sources: + - source: template_temperature1 + coeffecient: 0.5 + - source: template_temperature2 + coefficient: 1.5 From 3c634ecfd8de35dbd930246344ed6fa1fd586a0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 00:48:08 -1000 Subject: [PATCH 046/435] [noise] Bump noise-c to 0.1.31 and libsodium to 1.10021.12 (#20345) --- esphome/components/noise/__init__.py | 4 ++-- esphome/idf_component.yml | 4 ++-- platformio.ini | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d9880fe6f9c..66043236b1a 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -13,8 +13,8 @@ CODEOWNERS = ["@esphome/core"] # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its manifests. -NOISE_C_VERSION = "0.1.30" -LIBSODIUM_VERSION = "1.10021.11" +NOISE_C_VERSION = "0.1.31" +LIBSODIUM_VERSION = "1.10021.12" noise_ns = cg.esphome_ns.namespace("noise") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f532e262497..6bae823acb3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -113,11 +113,11 @@ dependencies: # noise (api, ota). Arduino below IDF 6.0 bundles espressif/libsodium, which # clashes with esphome/libsodium, so those builds use the PlatformIO libraries. esphome/noise-c: - version: 0.1.30 + version: 0.1.31 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" # Declared alongside noise-c so the library converter skips wireguard's copy esphome/libsodium: - version: 1.10021.11 + version: 1.10021.12 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" diff --git a/platformio.ini b/platformio.ini index 82463b89532..56a12d8b31c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -79,7 +79,7 @@ lib_deps = ; noise (api, ota) -- on the ESP-IDF framework these come from the ; component registry instead (see esphome/idf_component.yml), so they ; are not in [common]. - esphome/noise-c@0.1.30 ; noise (api, ota) + 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 @@ -247,7 +247,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base esphome/wireguard@0.4.8 ; wireguard - esphome/noise-c@0.1.30 ; noise (api, ota) + esphome/noise-c@0.1.31 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -645,7 +645,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.30 ; used by noise (api, ota) + esphome/noise-c@0.1.31 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From f5e4c0356f99b7a9ec0f3be39aa1be312e0126c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 05:08:38 -1000 Subject: [PATCH 047/435] [core] Move zstd_module from the api wizard to esphome.helpers (#20342) --- esphome/components/api/wizard.py | 16 ++++++---------- esphome/helpers.py | 10 ++++++++++ requirements.txt | 2 +- tests/component_tests/api/test_wizard.py | 23 ++++------------------- tests/unit_tests/test_helpers.py | 17 +++++++++++++++++ 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 314b907e4d0..befed39972c 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -6,7 +6,6 @@ Home Assistant shows the wizard when the device is added. See api_wizard.h for t from collections.abc import Callable import importlib import json -from types import ModuleType from typing import Any from esphome import automation @@ -27,7 +26,12 @@ from esphome.const import ( ) from esphome.core import CORE, ID import esphome.final_validate as fv -from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash +from esphome.helpers import ( + fnv1_hash, + fnv1_hash_object_id, + fnv1a_32bit_hash, + zstd_module, +) from esphome.types import ConfigType API_DOMAIN = "api" @@ -351,14 +355,6 @@ automation.register_apply_condition( ) -def zstd_module() -> ModuleType: - """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" - try: - return importlib.import_module("compression.zstd") - except ImportError: - return importlib.import_module("backports.zstd") - - def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: """An entity of the device that the page shows, keyed as ListEntitiesResponse keys it.""" _, declaration = _wizard_input_declaration(config, conf[CONF_ID]) diff --git a/esphome/helpers.py b/esphome/helpers.py index 5ec46be67e4..1f9b1407971 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable, MutableMapping from contextlib import suppress +import importlib import ipaddress import logging import os @@ -10,6 +11,7 @@ import platform import re import stat import sys +from types import ModuleType from typing import TYPE_CHECKING, TextIO from esphome.const import __version__ as ESPHOME_VERSION @@ -827,3 +829,11 @@ def docs_url(path: str) -> str: path = path.removeprefix("/") return docs_format.format(path=path) + + +def zstd_module() -> ModuleType: + """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" + try: + return importlib.import_module("compression.zstd") + except ImportError: + return importlib.import_module("backports.zstd") diff --git a/requirements.txt b/requirements.txt index 6e1dfa1ba51..8deafbad86f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # api wizard; compression.zstd is in the standard library from 3.14 +backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 276f42d624e..112092482b2 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -14,7 +14,7 @@ from esphome.components.api import wizard from esphome.components.homeassistant.switch import SUPPORTED_DOMAINS as SWITCH_DOMAINS from esphome.config import load_config from esphome.core import CORE -from esphome.helpers import fnv1_hash, fnv1a_32bit_hash +from esphome.helpers import fnv1_hash, fnv1a_32bit_hash, zstd_module from tests.component_tests.helpers import get_define_value ESP32_HEADER = """ @@ -613,7 +613,7 @@ def test_text_select_and_button_default_to_their_domains( tmp_path: Path, generate_main: Callable[[str | Path], str] ) -> None: main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) - document = json.loads(wizard.zstd_module().decompress(blob_in(main_cpp))) + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) filters = { entry["key"]: entry.get("entity_filters") for page in document["pages"] @@ -733,7 +733,7 @@ def test_the_blob_is_the_exact_json_document( ) blob = blob_in(main_cpp) - text = wizard.zstd_module().decompress(blob).decode("utf-8") + text = zstd_module().decompress(blob).decode("utf-8") expected = { "version": 1, "pages": [ @@ -788,7 +788,7 @@ def test_the_blob_is_deterministic_and_the_same_on_every_platform( ) # Compressing the same document again gives the same bytes document = wizard.wizard_document(CORE.config["api"]["wizard"], CORE.config) - again = wizard.zstd_module().compress( + again = zstd_module().compress( json.dumps( document, separators=(",", ":"), sort_keys=True, ensure_ascii=False ).encode("utf-8"), @@ -859,21 +859,6 @@ def test_an_entity_without_a_name_cannot_be_in_the_wizard(tmp_path: Path) -> Non assert any("has no name of its own" in error for error in errors), errors -def test_zstd_falls_back_to_the_backport(monkeypatch: pytest.MonkeyPatch) -> None: - """Before Python 3.14 the standard library has no zstd, so the backport is used.""" - backport = object() - - def import_module(name: str) -> object: - if name == "compression.zstd": - raise ImportError(name) - assert name == "backports.zstd" - return backport - - monkeypatch.setattr(wizard.importlib, "import_module", import_module) - - assert wizard.zstd_module() is backport - - def test_a_wizard_too_big_for_one_message_is_rejected(tmp_path: Path) -> None: # Random text does not compress, so this needs more than the limit even compressed rng = random.Random(1) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 8dae1e87b20..764f160cbc0 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1285,3 +1285,20 @@ def test_get_usable_cpu_count_sources() -> None: mock_os_unknown = types.SimpleNamespace(cpu_count=lambda: None) with patch("esphome.helpers.os", mock_os_unknown): assert helpers.get_usable_cpu_count() == 1 + + +def test_zstd_module_falls_back_to_the_backport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Before Python 3.14 the standard library has no zstd, so the backport is used.""" + backport = object() + + def import_module(name: str) -> object: + if name == "compression.zstd": + raise ImportError(name) + assert name == "backports.zstd" + return backport + + monkeypatch.setattr(helpers.importlib, "import_module", import_module) + + assert helpers.zstd_module() is backport From 9066ad6cd002e45f34f2b3e38980a27cf3315aa0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 05:13:03 -1000 Subject: [PATCH 048/435] [modbus_client] Cover shared constant and trigger argument write values in the component test (#20327) --- tests/components/modbus_client/common.yaml | 55 ++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index ce2965e449d..25ff8f5d360 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -153,3 +153,58 @@ button: then: # `values` here is the READ-BACK block, not the written block above - lambda: 'ESP_LOGI("modbus_client.test", "rw read0=%u n=%u", values[0], (unsigned) values.size());' + # Identical on purpose: each pair shares one constant value list + - platform: template + name: "Modbus Client Constant Writes" + on_press: + - modbus_client.write_multiple_registers: + address: 0x01 + start_address: 0x0210 + values: [0x1234, 0xABCD, 1] + - modbus_client.write_multiple_registers: + address: 0x01 + start_address: 0x0220 + values: [0x1234, 0xABCD, 1] + - modbus_client.write_multiple_coils: + address: 0x01 + start_address: 0x0020 + values: [true, false, true, true, false, false, true, false, true] + - modbus_client.write_multiple_coils: + address: 0x01 + start_address: 0x0030 + values: [true, false, true, true, false, false, true, false, true] + - modbus_client.read_write_multiple_registers: + address: 0x01 + write_address: 0x0310 + values: [0x0102, 0xFFFF] + read_address: 0x0410 + read_count: 1 + - modbus_client.read_write_multiple_registers: + address: 0x01 + write_address: 0x0320 + values: [0x0102, 0xFFFF] + read_address: 0x0420 + read_count: 1 + +number: + - platform: template + name: "Modbus Client Write Value" + optimistic: true + min_value: 0 + max_value: 100 + step: 1 + on_value: + - modbus_client.write_multiple_registers: + address: 0x01 + start_address: 0x0230 + values: !lambda "return {static_cast(x)};" + - modbus_client.write_multiple_coils: + address: 0x01 + start_address: 0x0040 + values: !lambda "return {x > 50};" + - modbus_client.read_write_multiple_registers: + address: 0x01 + write_address: 0x0330 + values: !lambda "return {static_cast(x)};" + read_address: 0x0430 + read_count: 1 From 79813a1eac31cdc1b7ec9a25ff04092384fdc8aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 05:14:02 -1000 Subject: [PATCH 049/435] [canbus] Keep constant send payloads in shared flash tables (#20309) --- esphome/components/canbus/__init__.py | 46 ++++++++++--------- esphome/components/canbus/canbus.cpp | 7 ++- esphome/components/canbus/canbus.h | 38 +++++---------- tests/component_tests/canbus/__init__.py | 0 .../canbus/config/payload_tables.yaml | 21 +++++++++ .../canbus/test_canbus_payload_tables.py | 41 +++++++++++++++++ 6 files changed, 100 insertions(+), 53 deletions(-) create mode 100644 tests/component_tests/canbus/__init__.py create mode 100644 tests/component_tests/canbus/config/payload_tables.yaml create mode 100644 tests/component_tests/canbus/test_canbus_payload_tables.py diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index b7de235dd18..b70bac72656 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -144,20 +144,26 @@ async def register_canbus(var: MockObj, config: ConfigType) -> None: # Actions +CANBUS_SEND_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(CONF_CANBUS_ID): cv.use_id(CanbusComponent), + cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), + cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, + cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST, default=False): cv.boolean, + # One classic CAN frame + cv.Required(CONF_DATA): cv.templatable( + cv.All(validate_raw_data, cv.Length(max=8)) + ), + }, + validate_id, + key=CONF_DATA, +) + + @automation.register_action( "canbus.send", canbus_ns.class_("CanbusSendAction", automation.Action), - cv.maybe_simple_value( - { - cv.GenerateID(CONF_CANBUS_ID): cv.use_id(CanbusComponent), - cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, - cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST, default=False): cv.boolean, - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), - }, - validate_id, - key=CONF_DATA, - ), + CANBUS_SEND_ACTION_SCHEMA, synchronous=True, ) async def canbus_action_to_code( @@ -177,15 +183,11 @@ async def canbus_action_to_code( var.set_remote_transmission_request(config[CONF_REMOTE_TRANSMISSION_REQUEST]) ) - data = config[CONF_DATA] - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - if isinstance(data, bytes): - data = [int(x) for x in data] - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "canbus_data", + ) return var diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index c5ba59a645a..1032a389c68 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -22,10 +22,11 @@ void Canbus::dump_config() { } canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, - const std::vector &data) { + const uint8_t *data, size_t len) { struct CanFrame can_message; - uint8_t size = static_cast(data.size()); + // Clamp before narrowing, so a lambda payload of 256 bytes or more cannot wrap to a short length + const uint8_t size = static_cast(std::min(len, CAN_MAX_DATA_LENGTH)); if (use_extended_id) { ESP_LOGD(TAG, "send extended id=0x%08" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), size); @@ -33,8 +34,6 @@ canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remo ESP_LOGD(TAG, "send standard id=0x%03" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), size); } - if (size > CAN_MAX_DATA_LENGTH) - size = CAN_MAX_DATA_LENGTH; can_message.can_data_length_code = size; can_message.can_id = can_id; can_message.use_extended_id = use_extended_id; diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 1bc4d6e3455..8685c026b0b 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -69,8 +69,12 @@ class Canbus : public Component { float get_setup_priority() const override { return setup_priority::HARDWARE; } void loop() override; + canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, + size_t len); canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, - const std::vector &data); + const std::vector &data) { + return this->send_data(can_id, use_extended_id, remote_transmission_request, data.data(), data.size()); + } canbus::Error send_data(uint32_t can_id, bool use_extended_id, const std::vector &data) { // for backwards compatibility only return this->send_data(can_id, use_extended_id, false, data); @@ -107,18 +111,7 @@ class Canbus : public Component { }; template class CanbusSendAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - // Store pointer to static data in flash (no RAM copy) - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } @@ -132,26 +125,17 @@ template class CanbusSendAction final : public Action, pu auto can_id = this->can_id_.has_value() ? *this->can_id_ : this->parent_->can_id_; auto use_extended_id = this->use_extended_id_.has_value() ? *this->use_extended_id_ : this->parent_->use_extended_id_; - std::vector data; - if (this->len_ >= 0) { - // Static mode: copy from flash to vector - data.assign(this->data_.data, this->data_.data + this->len_); - } else { - // Template mode: call function - data = this->data_.func(x...); - } - this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data); + this->data_.template visit( + [&](const uint8_t *data, size_t len) { + this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len); + }, + x...); } protected: optional can_id_{}; optional use_extended_id_{}; bool remote_transmission_request_{false}; - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; }; class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { diff --git a/tests/component_tests/canbus/__init__.py b/tests/component_tests/canbus/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/canbus/config/payload_tables.yaml b/tests/component_tests/canbus/config/payload_tables.yaml new file mode 100644 index 00000000000..abbf0bab8ab --- /dev/null +++ b/tests/component_tests/canbus/config/payload_tables.yaml @@ -0,0 +1,21 @@ +--- +esphome: + name: test + on_boot: + then: + - canbus.send: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + - canbus.send: + can_id: 0x100 + data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + - canbus.send: !lambda return {0x09}; + +esp32: + board: esp32dev + +canbus: + - platform: esp32_can + id: can_bus + rx_pin: 4 + tx_pin: 5 + can_id: 4 + bit_rate: 50kbps diff --git a/tests/component_tests/canbus/test_canbus_payload_tables.py b/tests/component_tests/canbus/test_canbus_payload_tables.py new file mode 100644 index 00000000000..958ec082e45 --- /dev/null +++ b/tests/component_tests/canbus/test_canbus_payload_tables.py @@ -0,0 +1,41 @@ +"""Tests for canbus constant payloads in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +from esphome.components.canbus import CANBUS_SEND_ACTION_SCHEMA +import esphome.config_validation as cv + + +def test_constant_payloads_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal send payloads share one table; lambdas stay templates.""" + main_cpp = generate_main(component_config_path("payload_tables.yaml")) + + tables = re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + assert [v for _, v in tables] == [ + "{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}" + ] + assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2 + assert "set_data_template(" in main_cpp + + +@pytest.mark.parametrize("size", [8, 9]) +@pytest.mark.parametrize("make", [lambda n: [0x01] * n, lambda n: "a" * n]) +def test_send_one_frame_limit( + size: int, make: Callable[[int], list[int] | str] +) -> None: + """Payloads up to one 8 byte CAN frame are accepted, longer ones rejected.""" + config = {"canbus_id": "can_bus", "data": make(size)} + if size <= 8: + assert len(CANBUS_SEND_ACTION_SCHEMA(config)["data"]) == size + else: + with pytest.raises(cv.Invalid, match="at most 8"): + CANBUS_SEND_ACTION_SCHEMA(config) From f224cb37d6f529d8bb91de80850efa2cd1d2a953 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 05:32:50 -1000 Subject: [PATCH 050/435] [esp32_ble_server] Cover string, typed number, repeated and trigger argument set_value payloads in the component test (#20308) --- .../bytebuffer/test_wrap_values.cpp | 34 ++++++++++++++++ tests/components/esp32_ble_server/common.yaml | 40 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/components/bytebuffer/test_wrap_values.cpp diff --git a/tests/components/bytebuffer/test_wrap_values.cpp b/tests/components/bytebuffer/test_wrap_values.cpp new file mode 100644 index 00000000000..9a470d8a088 --- /dev/null +++ b/tests/components/bytebuffer/test_wrap_values.cpp @@ -0,0 +1,34 @@ +#ifdef USE_HOST +#include + +#include +#include + +#include "esphome/components/bytebuffer/bytebuffer.h" + +namespace esphome::bytebuffer { + +// The expressions esp32_ble_server emits for typed constant values, and the bytes they produce. +// Doubles are written as float literals, so they carry float precision. +TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { + EXPECT_EQ(ByteBuffer::wrap(uint8_t(18), LITTLE).get_data(), (std::vector{0x12})); + EXPECT_EQ(ByteBuffer::wrap(uint16_t(18), LITTLE).get_data(), (std::vector{0x12, 0x00})); + EXPECT_EQ(ByteBuffer::wrap(uint16_t(4660), BIG).get_data(), (std::vector{0x12, 0x34})); + EXPECT_EQ(ByteBuffer::wrap(uint32_t(305419896), LITTLE).get_data(), (std::vector{0x78, 0x56, 0x34, 0x12})); + EXPECT_EQ(ByteBuffer::wrap(uint64_t(1311768467294899695ULL), BIG).get_data(), + (std::vector{0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef})); + EXPECT_EQ(ByteBuffer::wrap(int8_t(-5), LITTLE).get_data(), (std::vector{0xfb})); + EXPECT_EQ(ByteBuffer::wrap(int16_t(-2), BIG).get_data(), (std::vector{0xff, 0xfe})); + EXPECT_EQ(ByteBuffer::wrap(int32_t(-2), LITTLE).get_data(), (std::vector{0xfe, 0xff, 0xff, 0xff})); + EXPECT_EQ(ByteBuffer::wrap(int64_t(-2), LITTLE).get_data(), + (std::vector{0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff})); + EXPECT_EQ(ByteBuffer::wrap(float(123.1f), BIG).get_data(), (std::vector{0x42, 0xf6, 0x33, 0x33})); + EXPECT_EQ(ByteBuffer::wrap(float(0.1f), LITTLE).get_data(), (std::vector{0xcd, 0xcc, 0xcc, 0x3d})); + EXPECT_EQ(ByteBuffer::wrap(double(0.1f), LITTLE).get_data(), + (std::vector{0x00, 0x00, 0x00, 0xa0, 0x99, 0x99, 0xb9, 0x3f})); + EXPECT_EQ(ByteBuffer::wrap(double(2.5f), BIG).get_data(), + (std::vector{0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})); +} + +} // namespace esphome::bytebuffer +#endif diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index c617a73f871..b513c43234b 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -77,6 +77,46 @@ esp32_ble_server: id: test_change_descriptor value: data: [0x01, 0x02, 0x03] + # Identical constants, strings and typed numbers + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: + data: [0xfc, 0xef, 0xfe, 0x86] + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: "Changed" + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: + data: -2 + type: int32_t + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: + data: 0.1 + type: double + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: + data: 123.1 + type: float + endianness: BIG + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: "Changed" + # Lambdas that read the trigger argument + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: !lambda return x; + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: !lambda return x; # Regression test for #17142: the set_value action used from a trigger that passes # its argument by reference (climate on_control supplies ClimateCall&) previously From 7910d02b716b836b9eafad055361f4c22b4a4ff7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 05:34:34 -1000 Subject: [PATCH 051/435] [web_server] Emit the resource tables with cg.extern_progmem_array (#20300) --- esphome/components/web_server/__init__.py | 14 ++++---- .../web_server/config/include.css | 1 + .../web_server/config/include.js | 1 + .../web_server/config/includes.yaml | 13 ++++++++ .../web_server/test_progmem_resources.py | 33 +++++++++++++++++++ 5 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/web_server/config/include.css create mode 100644 tests/component_tests/web_server/config/include.js create mode 100644 tests/component_tests/web_server/config/includes.yaml create mode 100644 tests/component_tests/web_server/test_progmem_resources.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 6bc4d7ab5c9..5e77c7496ad 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -364,12 +364,14 @@ def add_resource_as_progmem( content_encoded = content.encode("utf-8") if compress: content_encoded = gzip.compress(content_encoded) - content_encoded_size = len(content_encoded) - bytes_as_int = ", ".join(str(x) for x in content_encoded) - uint8_t = f"constexpr uint8_t ESPHOME_WEBSERVER_{resource_name}[{content_encoded_size}] PROGMEM = {{{bytes_as_int}}}" - size_t = f"constexpr size_t ESPHOME_WEBSERVER_{resource_name}_SIZE = {content_encoded_size}" - cg.add_global(cg.RawExpression(uint8_t)) - cg.add_global(cg.RawExpression(size_t)) + cg.extern_progmem_array( + f"ESPHOME_WEBSERVER_{resource_name}", cg.uint8, list(content_encoded) + ) + cg.add_global( + cg.RawExpression( + f"constexpr size_t ESPHOME_WEBSERVER_{resource_name}_SIZE = {len(content_encoded)}" + ) + ) @coroutine_with_priority(CoroPriority.WEB) diff --git a/tests/component_tests/web_server/config/include.css b/tests/component_tests/web_server/config/include.css new file mode 100644 index 00000000000..991912d894b --- /dev/null +++ b/tests/component_tests/web_server/config/include.css @@ -0,0 +1 @@ +body { color: red; } diff --git a/tests/component_tests/web_server/config/include.js b/tests/component_tests/web_server/config/include.js new file mode 100644 index 00000000000..d914c6066c4 --- /dev/null +++ b/tests/component_tests/web_server/config/include.js @@ -0,0 +1 @@ +console.log("hi"); diff --git a/tests/component_tests/web_server/config/includes.yaml b/tests/component_tests/web_server/config/includes.yaml new file mode 100644 index 00000000000..0b80652dde4 --- /dev/null +++ b/tests/component_tests/web_server/config/includes.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + css_include: include.css + js_include: include.js diff --git a/tests/component_tests/web_server/test_progmem_resources.py b/tests/component_tests/web_server/test_progmem_resources.py new file mode 100644 index 00000000000..95875c22675 --- /dev/null +++ b/tests/component_tests/web_server/test_progmem_resources.py @@ -0,0 +1,33 @@ +"""Tests for the web_server resources emitted as flash tables.""" + +from collections.abc import Callable +import gzip +from pathlib import Path +import re + + +def _table(main_cpp: str, name: str) -> bytes: + # Anchored so a static table does not match + match = re.search( + rf"^const uint8_t ESPHOME_WEBSERVER_{name}\[\] PROGMEM = \{{([^}}]*)\}};", + main_cpp, + re.MULTILINE, + ) + assert match is not None + return bytes(int(byte) for byte in match.group(1).split(",")) + + +def test_resources_are_externally_linked_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """web_server.cpp reads these through extern declarations, so they must not be static.""" + main_cpp = generate_main(component_config_path("includes.yaml")) + + assert b"" in _table(main_cpp, "INDEX_HTML") + assert gzip.decompress(_table(main_cpp, "CSS_INCLUDE")) == b"body { color: red; }\n" + assert gzip.decompress(_table(main_cpp, "JS_INCLUDE")) == b'console.log("hi");\n' + css_size = len(_table(main_cpp, "CSS_INCLUDE")) + assert ( + f"constexpr size_t ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE = {css_size};" in main_cpp + ) From e51e0e683fab1b2584e481fe058c371aa8e6b7cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:06:45 -1000 Subject: [PATCH 052/435] [esp8266] Force-include the throw stubs into the core and libraries on the native toolchain (#20336) --- esphome/build_gen/arduino8266.py | 67 ++++++++++++------- esphome/build_helpers/ninja_gen.py | 6 +- esphome/components/esp8266/__init__.py | 9 ++- esphome/components/esp8266/const.py | 2 + esphome/components/esp8266/throw_stubs.h | 3 +- .../unit_tests/build_gen/test_arduino8266.py | 45 +++++++++++-- 6 files changed, 93 insertions(+), 39 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 5b14af5a1d7..44369136a01 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -54,6 +54,7 @@ from esphome.components.esp8266.const import ( KEY_ESP8266, KEY_FLASH_SIZE, KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, ) from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_project_cxx_compile_flags @@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: core_exclude |= _CORE_EXCLUDE_WAVEFORM - archives = [] - # variant_dir existence was already enforced with the include dirs - variant_sources = collect_sources(variant_dir) - if variant_sources: - objs = compile_edges(lines, variant_sources, variant_dir, "variant") - lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") - archives.append("libFrameworkArduinoVariant.a") - - core_objs = compile_edges( - lines, collect_sources(core_dir, core_exclude), core_dir, "core" - ) - if not core_objs: - # An empty archive would link into a wall of undefined references - # (app_entry, the exception vectors) far from the cause - raise EsphomeError( - f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" - ) - lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") - archives.append("libFrameworkArduino.a") - - lib_archives, direct_objs = library_edges(lines, libraries) - archives += lib_archives - - # One source of truth with the PlatformIO path: esp8266/__init__ pins - # build_src_flags (the throw_stubs force-include); -include paths - # resolve against the source root + # esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on + # the PlatformIO path; -include paths resolve against the source root src_other: list[str] = [] src_includes: list[str] = [] src_it = iter( @@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: src_includes.append(tok[len("-include") :]) else: src_other.append(_shell_token(tok)) + # The throw stubs abort: none anywhere with exceptions on, else the core and + # libraries get them too (src keeps them after its pch include) + if config.exceptions: + src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER] include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes] + framework_flags = "" + if THROW_STUBS_HEADER in src_includes: + stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)] + lines.append(f"frameworkflags = {stubs_flag}") + framework_flags = "$frameworkflags" + archives = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = collect_sources(variant_dir) + if variant_sources: + objs = compile_edges( + lines, variant_sources, variant_dir, "variant", flags=framework_flags + ) + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = compile_edges( + lines, + collect_sources(core_dir, core_exclude), + core_dir, + "core", + flags=framework_flags, + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + lib_archives, direct_objs = library_edges(lines, libraries, framework_flags) + archives += lib_archives + # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) lines.append(f"srcflags = {' '.join(src_other + include_flags)}") diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index 8e72b36b3fd..a2ee244a29f 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -207,10 +207,12 @@ def compile_edges( def library_edges( - lines: list[str], libraries: list[ArduinoLibrary] + lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = "" ) -> tuple[list[str], list[str]]: """Emit every library's compile and archive edges. + ``extra_flags`` (raw ninja text) precede each library's own flags. + Returns the archive names and the objects that link directly. A library's own include dirs lead its compile lines, as PlatformIO searched them first: the include path is one global list, so another library's @@ -232,7 +234,7 @@ def library_edges( lib.sources, common_parent(lib.sources), f"lib/{lib.name}", - flags=" ".join(_shell_token(f) for f in lib.flags), + flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])), own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs), ) if not lib.lib_archive: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d0d870c2894..57fac2dc98a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,6 +53,7 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + THROW_STUBS_HEADER, enable_serial, enable_serial1, esp8266_ns, @@ -477,11 +478,9 @@ async def to_code(config: ConfigType) -> None: # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h. Unconditional: the native build generator reads - # the same option, keeping one source of truth. - cg.add_platformio_option( - "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" - ) + # See throw_stubs.h. The native build generator reads this option, also + # passes it to the core and libraries, and drops it with exceptions on. + cg.add_platformio_option("build_src_flags", f"-include {THROW_STUBS_HEADER}") # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index bf4dd1a3584..fb50b6a5ca2 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -17,6 +17,8 @@ KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator KEY_SCANF_FLOAT = "scanf_float" +# Force-included std::__throw_* overrides, relative to the src dir +THROW_STUBS_HEADER = "esphome/components/esp8266/throw_stubs.h" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h index a650935a5e2..c18814d4d80 100644 --- a/esphome/components/esp8266/throw_stubs.h +++ b/esphome/components/esp8266/throw_stubs.h @@ -15,7 +15,8 @@ * unused, dead-strip the load, and drop the string entirely -- no LTO needed. * Behavior is identical to today: a bare abort() (the message was never * printed). This header MUST be force-included before , so it is - * wired up via build_src_flags "-include ..." in this component's __init__.py. + * wired up via build_src_flags "-include ..." in this component's __init__.py; + * the native toolchain also force-includes it into the core and libraries. * * Note: this defines functions in namespace std (technically UB). It is safe * here because the definitions match the existing abort() behavior exactly. diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 066746d36d8..2ea4ffabe6e 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -34,7 +34,12 @@ from esphome.components.esp8266.boards import ( board_ld_script, ) from esphome.components.esp8266.build_surgery import RATETABLE_RULE -from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT +from esphome.components.esp8266.const import ( + KEY_BOARD, + KEY_ESP8266, + KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, +) import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -360,18 +365,22 @@ def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: # Assembly and C sources compile through their own rules assert "cont.S.o: aspp" in content assert "abi.c.o: c" in content - # throw_stubs is force-included for ESPHome sources only, via one shared - # srcflags variable rather than a copy of the flags line per edge + # throw_stubs reaches src through srcflags (after the pch include) and the + # core through frameworkflags: one shared variable each, not a copy per edge src_lines = [line for line in content.splitlines() if "obj/src/" in line] assert any("main.cpp.o: cxx" in line for line in src_lines) - assert content.count("throw_stubs.h") == 1 + assert content.count("throw_stubs.h") == 2 assert "srcflags = -include" in content flags_lines = [ line for line in content.splitlines() if line.startswith(" flags = ") ] assert flags_lines # C++ src edges consume the precompiled header; C/assembly keep srcflags - assert set(flags_lines) == {" flags = $srcflags", " flags = $srccxxflags"} + assert set(flags_lines) == { + " flags = $srcflags", + " flags = $srccxxflags", + " flags = $frameworkflags", + } def test_write_project_pch(tmp_path: Path) -> None: @@ -715,6 +724,8 @@ def test_write_project_libraries_and_variant( assert "libHeadersOnly.a" not in content assert "Library HeadersOnly has no source files" in caplog.text assert " flags = -DMYLIB=1" in content + # With exceptions on, nothing gets the stubs + assert "throw_stubs.h" not in content # A library's own include dirs lead its compile lines assert " own_includes = -I" in content assert "$own_includes $cxxflags $flags" in content @@ -729,6 +740,30 @@ def test_write_project_libraries_and_variant( assert f"ccache = {_shq('/cc/ccache')}" in content +def test_write_project_throw_stubs_reach_core_and_libraries(tmp_path: Path) -> None: + """Without exceptions the core and libraries take the throw stubs too.""" + paths = _make_framework(tmp_path) + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + ) + content = _write_ninja(paths, libraries=[library]) + stubs = CORE.relative_src_path() / THROW_STUBS_HEADER + assert f"frameworkflags = -include {_shq(str(stubs))}" in content + # Before the library's own flags; core edges take the variable alone + assert " flags = $frameworkflags -DMYLIB=1" in content + lines = content.splitlines() + core_edge = next( + i for i, line in enumerate(lines) if "core_esp8266_main.cpp.o: cxx" in line + ) + assert lines[core_edge + 1] == " flags = $frameworkflags" + + def test_get_flash_ld_path(tmp_path: Path) -> None: paths = InstalledPaths( From 1362e52aa7b1b0d0d9e79b251f7368dfd98d5261 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:09:39 -1000 Subject: [PATCH 053/435] [esp8266] Fix Arduino core regressions that keep data in RAM and link the unused unwinder (#20337) --- esphome/arduino8266/framework.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index b731b746751..48fd72d1da7 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple): # Arduino core version -> its build in esphome-libs/arduino-esp8266 FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { Version(3, 1, 2): FrameworkRelease( - "3.1.2-esphome.1", - "e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582", - 37189311, + "3.1.2-esphome.2", + "9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7", + 37192128, ), } From 70f64374d4f46fa93362f7910271516dbb70c436 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:11:51 -1000 Subject: [PATCH 054/435] [esp8266] Use toolchain 10.3.0-esphome.3, which trims newlib RAM (#20335) --- esphome/arduino8266/framework.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 48fd72d1da7..c94ea1df9cd 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -58,31 +58,31 @@ FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf" # gcc 10.3, the toolchain Arduino core 3.x builds with; the build # generator's compile flags are tuned to it. -TOOLCHAIN_VERSION = "10.3.0-esphome.2" +TOOLCHAIN_VERSION = "10.3.0-esphome.3" _TOOLCHAIN_RELEASES = ( "https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/" ) # Registry system tag -> (sha256, size) of that host's archive TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = { "darwin_arm64": ( - "849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201", - 60830321, + "ad0ea929238d9b527c43e0d20257acc2a5680bdb0d2b1cf8f2c99169786217e9", + 60747260, ), "darwin_x86_64": ( - "ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897", - 64149487, + "997090cac80c8eb59604ec52b73fb3568e5f2d9b2971f83c3f2a5c545c9c949c", + 64065752, ), "linux_aarch64": ( - "60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6", - 67573397, + "403f14d0d5755682fcd6eaea1e4c457a1d8cad30690e52fb0c804d8db266b908", + 67487067, ), "linux_x86_64": ( - "1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4", - 68431336, + "64f21fa3ba736b363dc8c234dbbb39e005e574e33de08a78e9a00c55a64ae706", + 68343668, ), "windows_amd64": ( - "af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b", - 67664137, + "1e62abf8df041ce5158f5770dffef955f2f981ce7f7adee88fcd5a8197705aa6", + 67536405, ), } From 3055343b6d0da6a19e8d45df5453379a161195fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:15:11 -1000 Subject: [PATCH 055/435] [esp8266] Use toolchain 10.3.0-esphome.3, which trims newlib RAM (#20335) From b1c145dd5f3aacd1e41423abbcff288c0dadf820 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 12:33:46 -0400 Subject: [PATCH 056/435] [sendspin] Update to sendspin-cpp v0.9.3 (#20347) --- esphome/components/sendspin/__init__.py | 16 +- esphome/components/sendspin/image/__init__.py | 16 +- .../sendspin/image/sendspin_image.cpp | 4 +- .../sendspin/image/sendspin_image.h | 4 +- .../media_player/sendspin_media_player.cpp | 2 +- .../media_player/sendspin_media_player.h | 2 +- .../sendspin/media_source/__init__.py | 7 + .../media_source/sendspin_media_source.cpp | 14 +- .../media_source/sendspin_media_source.h | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 232 +++++++++++++----- esphome/components/sendspin/sendspin_hub.h | 63 ++--- .../sendspin/sensor/sendspin_sensor.cpp | 2 +- .../sendspin/sensor/sendspin_sensor.h | 2 +- .../sendspin/switch/sendspin_switch.cpp | 4 +- .../sendspin/switch/sendspin_switch.h | 4 +- .../text_sensor/sendspin_text_sensor.cpp | 2 +- .../text_sensor/sendspin_text_sensor.h | 2 +- esphome/idf_component.yml | 5 +- .../sendspin/test_framework.py | 27 ++ tests/component_tests/sendspin/test_image.py | 8 + .../sendspin/test_media_source.py | 10 + 21 files changed, 303 insertions(+), 125 deletions(-) create mode 100644 tests/component_tests/sendspin/test_framework.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 42dae464ede..2fb8d4a17ec 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -76,7 +76,6 @@ OPUS_SAMPLE_RATE = 48000 SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") -IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") @@ -211,6 +210,8 @@ CONFIG_SCHEMA = cv.All( } ), cv.only_on_esp32, + # sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 cannot use. + cv.only_with_framework("esp-idf"), _request_high_performance_networking, ) @@ -265,7 +266,9 @@ async def to_code(config: ConfigType) -> None: cg.add(setter(value)) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") + # esp_websocket_client links esp_tls even for ws:// connections. + esp32.request_tls() cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -276,8 +279,9 @@ async def to_code(config: ConfigType) -> None: data = _get_data() - # The color role is not yet wired up in ESPHome; disable it in the library for now. + # The color and source roles are not yet wired up in ESPHome; disable them in the library for now. esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False) + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False) # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). @@ -351,7 +355,8 @@ async def to_code(config: ConfigType) -> None: ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), - ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), + # The released YAML key name is kept for compatibility. + ("initial_output_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), ("psram_stack", psram_stack), ] if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None: @@ -366,6 +371,9 @@ async def to_code(config: ConfigType) -> None: else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) + if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False) + if data.visualizer_support: cg.add_define("USE_SENDSPIN_VISUALIZER", True) else: diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index e26e8204aaf..a5336d5e052 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -1,5 +1,7 @@ """Sendspin image platform.""" +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image @@ -21,7 +23,6 @@ from esphome.types import ConfigType from .. import ( CONF_DISPLAY_OFFSET, CONF_SENDSPIN_ID, - IMAGE_FORMAT_BMP, IMAGE_FORMAT_JPEG, IMAGE_FORMAT_PNG, IMAGE_SOURCE_ALBUM, @@ -57,9 +58,16 @@ _FORMAT_TO_SENDSPIN_ENUM = { "JPEG": IMAGE_FORMAT_JPEG, "JPG": IMAGE_FORMAT_JPEG, "PNG": IMAGE_FORMAT_PNG, - "BMP": IMAGE_FORMAT_BMP, } + +# Remove before 2027.4.0 +def _reject_bmp(value: Any) -> Any: + if isinstance(value, str) and value.upper() == "BMP": + raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.") + return value + + # The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it # would never receive artwork while still paying for two frame buffers, so it is not offered here. IMAGE_SOURCES = { @@ -121,7 +129,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), # Narrow runtime_image's format list to what the library can request, so the # accepted set and the enum map below cannot drift apart. - cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_FORMAT): cv.All( + _reject_bmp, cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True) + ), cv.Required(CONF_RESIZE): cv.dimensions, cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 558a292d5b0..8605048f331 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -1,6 +1,6 @@ #include "sendspin_image.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/core/log.h" @@ -125,7 +125,7 @@ bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const } // A decode that asked for other dimensions had the buffer taken away from it, so it painted - // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // nothing (or stopped partway). JPEG reports that as an error above; PNG carries on // regardless, so the frame is dropped here. return this->decode_sink_.decoded_into(target); } diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h index 2f6f4e8a4d9..43dd4ed66b3 100644 --- a/esphome/components/sendspin/image/sendspin_image.h +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/components/image/image.h" #include "esphome/components/runtime_image/runtime_image.h" @@ -29,7 +29,7 @@ class ArtworkDecodeSink : public runtime_image::RuntimeImage { /// @brief True when the decode ended with the given buffer still in place. /// /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions - /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// leaves the sink holding nothing. The JPEG decoder reports that as a decode error, but /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } }; diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index 59ead1bb539..ec5d5b2cbc0 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_player.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/core/application.h" #include "esphome/core/log.h" diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index ff764731898..bc07254efe4 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/components/media_player/media_player.h" #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 743d0bb62cf..31c454fb71c 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -11,7 +11,9 @@ from esphome.const import ( from esphome.types import ConfigType from .. import ( + CODEC_FLAC, CODEC_OPUS, + CODEC_PCM, CODECS, CONF_CODECS, CONF_DECODE_MEMORY, @@ -58,6 +60,11 @@ def _resolve_codecs(config: ConfigType) -> ConfigType: f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", path=[CONF_CODECS], ) + if CODEC_FLAC not in codecs and CODEC_PCM not in codecs: + raise cv.Invalid( + f"Codecs must include '{CODEC_FLAC}' or '{CODEC_PCM}'", + path=[CONF_CODECS], + ) return config diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index c3fb1fe1cbd..f1706a260bd 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_source.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/audio/audio.h" #include "esphome/core/log.h" @@ -24,7 +24,7 @@ void SendspinMediaSource::setup() { // Push cached states to player role. They may have been set before setup() ran. this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f)); this->player_role_->update_muted(this->cached_muted_); - this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_); + this->player_role_->set_output_delay_adjustable(this->static_delay_adjustable_); } void SendspinMediaSource::dump_config() { @@ -35,7 +35,7 @@ void SendspinMediaSource::dump_config() { void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) { this->static_delay_adjustable_ = adjustable; if (this->player_role_) { - this->player_role_->set_static_delay_adjustable(adjustable); + this->player_role_->set_output_delay_adjustable(adjustable); } } @@ -102,8 +102,8 @@ void SendspinMediaSource::handle_command(media_source::MediaSourceCommand comman if (!this->pending_start_) { // Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before // play_uri - ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE"); - this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE); + ESP_LOGD(TAG, "Received STOP command, leaving the Sendspin group"); + this->parent_->leave_group(); } break; } @@ -185,8 +185,6 @@ size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_ // THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) void SendspinMediaSource::on_stream_start() { - this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - if (!this->pending_start_) { // Dedup rapid on_stream_start() calls this->pending_start_ = true; @@ -211,4 +209,4 @@ void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(mute } // namespace esphome::sendspin_ -#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER +#endif // USE_ESP_IDF && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 1c5cb625bf4..f0800dbcb1c 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 15d358d50fb..0348850225e 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -1,6 +1,6 @@ #include "sendspin_hub.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/components/network/util.h" #ifdef USE_ETHERNET @@ -17,26 +17,98 @@ #include +#include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +namespace { + +// Zeroes through a volatile pointer so dead-store elimination cannot drop it. +void secure_wipe(void *data, size_t len) { + volatile auto *p = static_cast(data); + for (size_t i = 0; i < len; ++i) { + p[i] = 0; + } +} + +// Call once per key, from setup(). +ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) { + return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name)); +} + +// Loads straight into the returned buffer, which the library wipes after use. A failed load can +// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers +// this buffer: the preference backend keeps its own copy of a pending write until it syncs. +std::optional> read_blob(ESPPreferenceObject &pref, size_t size) { + std::vector out(size); + if (!pref.load(out.data(), out.size())) { + secure_wipe(out.data(), out.size()); + return std::nullopt; + } + return out; +} + +// The library always writes a key's fixed size; any other length is misuse and is not stored. +bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) { + if (len != size) { + ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size); + return false; + } + if (!pref.save(data, len)) { + ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len); + return false; + } + return true; +} + +// "rec_" to n; nullopt for any other key or a slot with no storage here. +std::optional parse_record_slot(const std::string &key) { + const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX; + const size_t prefix_len = std::strlen(prefix); + if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) { + return std::nullopt; + } + size_t slot = 0; + for (size_t i = prefix_len; i < key.size(); i++) { + const char c = key[i]; + if (c < '0' || c > '9') { + return std::nullopt; + } + slot = slot * 10 + static_cast(c - '0'); + if (slot >= SENDSPIN_RECORD_SLOTS) { + return std::nullopt; + } + } + return slot; +} + +} // namespace + #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; -static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"}; #endif void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); - // Set up persistence (preferences must be initialized before providers are added to the client) - this->last_played_server_pref_ = - global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); -#ifdef USE_SENDSPIN_PLAYER - this->static_delay_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_static_delay")); -#endif + // Set up persistence (preferences must be initialized before providers are added to the client). These key names + // are frozen; see PREFERENCE KEYS in sendspin_hub.h. + namespace keys = sendspin::persistence_keys; + this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE); + this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE); + this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE); + // Released firmware's name, so a calibrated delay survives. + this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE); + for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) { + this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE); + } + this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS); // Wire providers and client listener this->client_->set_listener(this); @@ -62,6 +134,9 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif + // Any server may play without pairing, as before rc1. + this->client_->set_unpaired_access_enabled(true); + #ifndef USE_SENDSPIN_SWITCH this->enabled_ = true; #endif @@ -83,17 +158,35 @@ void SendspinHub::loop() { #endif } +// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up +// before this hub, so it shuts down after it and the goodbyes can still go out. +void SendspinHub::on_shutdown() { + // Keeps loop() from starting the client again. + this->enabled_ = false; + if (this->is_client_running()) { + this->client_->stop(); + } +} + void SendspinHub::dump_config() { + // client_id exists only once start() has run. + const char *client_id = "(unavailable)"; + if (this->client_ != nullptr && !this->client_->client_id().empty()) { + client_id = this->client_->client_id().c_str(); + } char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " MAC address: %s\n" " Manufacturer: %s\n" " Model: %s\n" " Firmware version: %s\n" - " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), - this->firmware_version_, YESNO(this->task_stack_in_psram_)); + " Task stack in PSRAM: %s\n" + " Unpaired access: %s", + client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -154,22 +247,33 @@ void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) } // THREAD CONTEXT: Main loop (invoked from Sendspin components) -void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_client_running()) { - this->client_->update_state(state); +void SendspinHub::leave_group() { + if (this->is_client_running() && + this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) { + this->client_->leave(); } } -const char *SendspinHub::get_client_id_into_buffer(std::span buf) { - // The server matches client_id against the L2 source MAC of the device's multicast traffic. +const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { + // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the // eFuse base MAC when ethernet is the active interface. #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) { - return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + } else { + get_mac_address_pretty_into_buffer(buf); } +#else + get_mac_address_pretty_into_buffer(buf); #endif - return get_mac_address_pretty_into_buffer(buf); + // The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase. + for (char &c : buf) { + if (c >= 'A' && c <= 'F') { + c += 'a' - 'A'; + } + } + return buf.data(); } const char *SendspinHub::get_product_name_() const { @@ -180,12 +284,14 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); + config.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf); config.name = App.get_friendly_name(); config.product_name = this->get_product_name_(); config.manufacturer = this->manufacturer_; config.software_version = this->firmware_version_; config.httpd_psram_stack = this->task_stack_in_psram_; + config.protocol_task_psram_stack = this->task_stack_in_psram_; + config.max_pairing_records = SENDSPIN_RECORD_SLOTS; return config; } @@ -217,33 +323,56 @@ void SendspinHub::on_release_high_performance() { // --- SendspinNetworkProvider override --- -// THREAD CONTEXT: Main loop (polled by client_->loop()) +// THREAD CONTEXT: Main loop (polled by start() and client_->loop()) bool SendspinHub::is_network_ready() { return network::is_connected(); } // --- SendspinPersistenceProvider overrides --- +// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop()) -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -bool SendspinHub::save_last_server_hash(uint32_t hash) { - LastPlayedServerPref pref{.server_id_hash = hash}; - bool ok = this->last_played_server_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); - } else { - ESP_LOGW(TAG, "Failed to persist last played server hash"); +std::pair SendspinHub::pref_for_key_(const std::string &key) { + namespace keys = sendspin::persistence_keys; + if (auto slot = parse_record_slot(key); slot.has_value()) { + return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE}; } - return ok; + if (key == keys::RECORD_ORDER) { + return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS}; + } + if (key == keys::KEYPAIR) { + return {&this->keypair_pref_, keys::KEYPAIR_SIZE}; + } + if (key == keys::PAIRING_PSK) { + return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE}; + } + if (key == keys::LAST_PLAYED) { + return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE}; + } + if (key == keys::OUTPUT_DELAY) { + return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE}; + } + return {nullptr, 0}; } -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -std::optional SendspinHub::load_last_server_hash() { - LastPlayedServerPref pref{}; - if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); - return pref.server_id_hash; +std::optional> SendspinHub::load_blob(const std::string &key) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str()); + return std::nullopt; } - return std::nullopt; + return read_blob(*pref, size); } +bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str()); + return false; + } + return write_blob(*pref, key.c_str(), size, data, len); +} + +// Writes every component's queued preferences, so a failure may belong to another component. +bool SendspinHub::commit() { return global_preferences->sync(); } + // --- Sendspin role specific methods/overrides --- #ifdef USE_SENDSPIN_ARTWORK @@ -285,11 +414,7 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman } // THREAD CONTEXT: Main loop (invoked from the sendspin.switch action) -void SendspinHub::switch_client() { - // Clear any EXTERNAL_SOURCE state so the switch command is followed - this->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); -} +void SendspinHub::switch_client() { this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); } // THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { @@ -332,31 +457,8 @@ sendspin::PlayerRole *SendspinHub::get_player_role() { } return nullptr; } - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -bool SendspinHub::save_static_delay(uint16_t delay_ms) { - StaticDelayPref pref{.delay_ms = delay_ms}; - bool ok = this->static_delay_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms); - } else { - ESP_LOGW(TAG, "Failed to persist static delay"); - } - return ok; -} - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -std::optional SendspinHub::load_static_delay() { - StaticDelayPref pref{}; - if (this->static_delay_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms); - return pref.delay_ms; - } - return std::nullopt; -} - #endif } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index ea09dafe689..43c56feca7a 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/core/automation.h" #include "esphome/core/component.h" @@ -31,9 +31,15 @@ #include #endif +#include +#include +#include #include #include #include +#include +#include +#include namespace esphome::sendspin_ { @@ -44,22 +50,16 @@ namespace esphome::sendspin_ { /// one step later than hub so they can assume hub's setup() has already completed. namespace sendspin_priority { // AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active -// interface's MAC for client_id. +// interface's MAC address for the device info. inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI; inline constexpr float CHILD = HUB - 1.0f; } // namespace sendspin_priority -/// @brief Persistent storage structure for last played server hash. -struct LastPlayedServerPref { - uint32_t server_id_hash; -}; +// PREFERENCE KEYS: each library persistence key has its own preference, hashed from "sendspin_" + name. Renaming +// one erases it. -#ifdef USE_SENDSPIN_PLAYER -/// @brief Persistent storage structure for player static delay. -struct StaticDelayPref { - uint16_t delay_ms; -}; -#endif +/// Pairing-record slots, handed to the client as max_pairing_records. +inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; /// @brief Thin adapter over sendspin::SendspinClient. /// @@ -93,6 +93,7 @@ class SendspinHub final : public Component, float get_setup_priority() const override { return sendspin_priority::HUB; } void setup() override; void loop() override; + void on_shutdown() override; void dump_config() override; /// @brief Connects the underlying client to the given Sendspin server. @@ -113,14 +114,11 @@ class SendspinHub final : public Component, /// - `USER_REQUEST`: user explicitly requested disconnect. void disconnect_from_server(sendspin::SendspinGoodbyeReason reason); - /// @brief Updates the client's reported playback state on the server. + /// @brief Leaves the current group so another source can use the speaker. /// + /// Sends `client/leave` while the group is playing. A stopped group is left alone, so the device stays grouped. /// No-op if the hub's client is not running. Must be called from the main loop thread. - /// @param state New client state: - /// - `SYNCHRONIZED`: client is synchronized and playing from the server. - /// - `ERROR`: client encountered a playback error. - /// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source. - void update_state(sendspin::SendspinClientState state); + void leave_group(); // --- Configuration setters (called from codegen) --- @@ -219,12 +217,16 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// The preference a library key is stored in and its blob size, or {nullptr, 0} for a key with + /// no storage here. + std::pair pref_for_key_(const std::string &key); + /// @brief Returns the product name reported to the server: the configured model, or the device name. const char *get_product_name_() const; - /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. + /// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). - static const char *get_client_id_into_buffer(std::span buf); + static const char *get_mac_address_into_buffer(std::span buf); #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. @@ -242,8 +244,10 @@ class SendspinHub final : public Component, bool is_network_ready() override; // --- SendspinPersistenceProvider overrides --- - bool save_last_server_hash(uint32_t hash) override; - std::optional load_last_server_hash() override; + // The library calls commit() after writing pairing secrets, so they reach flash right away. + std::optional> load_blob(const std::string &key) override; + bool save_blob(const std::string &key, const uint8_t *data, size_t len) override; + bool commit() override; // --- Sendspin role specific methods/overrides/member variables --- @@ -291,16 +295,17 @@ class SendspinHub final : public Component, #ifdef USE_SENDSPIN_PLAYER sendspin::PlayerRoleListener *player_listener_{nullptr}; sendspin::PlayerRoleConfig player_config_{}; - - // Part of SendspinPersistenceProvider overrides - ESPPreferenceObject static_delay_pref_; - std::optional load_static_delay() override; - bool save_static_delay(uint16_t delay_ms) override; #endif // --- Core member variables --- - ESPPreferenceObject last_played_server_pref_; + // Built once in setup(): make_preference() allocates a backend that is never freed. + ESPPreferenceObject keypair_pref_; + ESPPreferenceObject pairing_psk_pref_; + ESPPreferenceObject last_played_pref_; + ESPPreferenceObject output_delay_pref_; + std::array record_slot_prefs_; + ESPPreferenceObject record_order_pref_; std::unique_ptr client_; @@ -349,4 +354,4 @@ class SendspinPollingChild : public PollingComponent, public Parented diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index 5b29fff55f8..77bb8355986 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/sensor/sensor.h" diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index 0bf029d4c77..af4d0538543 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,6 +1,6 @@ #include "sendspin_switch.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/core/log.h" @@ -23,4 +23,4 @@ void SendspinSwitch::write_state(bool state) { } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index 253d952b220..f9173c362dd 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -21,4 +21,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 554e01cf880..69dd32e7857 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -1,6 +1,6 @@ #include "sendspin_text_sensor.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) #include diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index d38f360d94d..9fe1c9381ef 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6bae823acb3..ed1202fdb96 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -101,8 +101,11 @@ dependencies: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 + # sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below) sendspin/sendspin-cpp: - version: 0.8.0 + version: 0.9.3 + rules: + - if: "$ESPHOME_ARDUINO_COMPONENT == 0" lvgl/lvgl: version: 9.5.0 fastled/FastLED: diff --git a/tests/component_tests/sendspin/test_framework.py b/tests/component_tests/sendspin/test_framework.py new file mode 100644 index 00000000000..0add0d0f0a1 --- /dev/null +++ b/tests/component_tests/sendspin/test_framework.py @@ -0,0 +1,27 @@ +"""Validation tests for the frameworks the sendspin hub builds on. + +sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 +cannot use, so the hub is ESP-IDF only. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +HUB_CONFIG = {"id": "sendspin_hub_id"} + + +def test_arduino_rejected(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_ARDUINO) + + with pytest.raises(cv.Invalid, match="esp-idf"): + CONFIG_SCHEMA(HUB_CONFIG) + + +def test_esp_idf_accepted(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + assert CONFIG_SCHEMA(HUB_CONFIG)["id"].id == "sendspin_hub_id" diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py index be3b7d6684d..dc9e4978c6b 100644 --- a/tests/component_tests/sendspin/test_image.py +++ b/tests/component_tests/sendspin/test_image.py @@ -53,6 +53,14 @@ def test_jpeg_alias_maps_to_one_enum( assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG +def test_bmp_rejected(set_core_config: SetCoreConfigCallable) -> None: + """The Sendspin protocol only allows JPEG and PNG artwork.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="BMP artwork is no longer supported"): + CONFIG_SCHEMA(_slot_config(format="BMP")) + + def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" set_core_config(PlatformFramework.ESP32_IDF) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py index 6c2f79198dd..db576b37afd 100644 --- a/tests/component_tests/sendspin/test_media_source.py +++ b/tests/component_tests/sendspin/test_media_source.py @@ -88,3 +88,13 @@ def test_opus_at_wrong_sample_rate_rejected( with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) + + +def test_opus_only_codec_list_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Every server supports FLAC or PCM, so sendspin-cpp requires one of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="must include 'flac' or 'pcm'"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"])) From 78bf74eafbef0fff6688a52fce58e7690ab80843 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 13:19:14 -0400 Subject: [PATCH 057/435] [sendspin] Add the static pairing code, pairing window and unpaired access option (#20348) --- esphome/components/sendspin/__init__.py | 119 ++++++++++++++++-- esphome/components/sendspin/sendspin_hub.cpp | 56 ++++++++- esphome/components/sendspin/sendspin_hub.h | 50 ++++++++ .../component_tests/sendspin/test_pairing.py | 77 ++++++++++++ .../sendspin/test_unpaired_access.py | 75 +++++++++++ tests/components/sendspin/common-hub.yaml | 24 ++++ tests/components/sendspin/common-pairing.yaml | 14 +++ .../sendspin/test-pairing.esp32-idf.yaml | 2 + 8 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/sendspin/test_pairing.py create mode 100644 tests/component_tests/sendspin/test_unpaired_access.py create mode 100644 tests/components/sendspin/common-pairing.yaml create mode 100644 tests/components/sendspin/test-pairing.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 2fb8d4a17ec..fcc53dde4ba 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,6 @@ from dataclasses import dataclass, field +import logging +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,6 +27,8 @@ from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + # mdns for autodiscovery AUTO_LOAD = ["mdns"] CODEOWNERS = ["@kahrendt"] @@ -46,6 +50,31 @@ CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" CONF_CODECS = "codecs" +CONF_STATIC_PAIRING_CODE = "static_pairing_code" +CONF_UNPAIRED_ACCESS = "unpaired_access" +CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" +CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" +CONF_ON_PAIRING_FAILED = "on_pairing_failed" + +# A static pairing code is exactly 8 decimal digits. +STATIC_PAIRING_CODE_DIGITS = 8 + + +def _validate_static_pairing_code(value: Any) -> str: + # string_strict so leading zeros survive and `!secret` works. + value = cv.string_strict(value) + if len(value) != STATIC_PAIRING_CODE_DIGITS or not ( + value.isascii() and value.isdigit() + ): + raise cv.Invalid( + f"{CONF_STATIC_PAIRING_CODE} must be exactly " + f"{STATIC_PAIRING_CODE_DIGITS} decimal digits " + '(quote the value so leading zeros are preserved, e.g. "01234567")' + ) + return value + + # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -108,6 +137,26 @@ SendspinHub = sendspin_ns.class_( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN_PAIRING_WINDOW, "add_on_open_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_SUCCEEDED, + "add_on_pairing_succeeded_callback", + [(cg.std_string, "server_id")], + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_FAILED, + "add_on_pairing_failed_callback", + [(cg.std_string, "server_id"), (cg.StringRef, "reason")], + ), +) + + @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -207,6 +256,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, + cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( + _validate_static_pairing_code + ), + cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, + cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), + cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } ), cv.only_on_esp32, @@ -216,21 +277,42 @@ CONFIG_SCHEMA = cv.All( ) +def _has_pairing_method(config: ConfigType) -> bool: + """Whether the config gives a server any way to pair with the device.""" + return CONF_STATIC_PAIRING_CODE in config + + +def _final_validate(config: ConfigType) -> ConfigType: + if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): + _LOGGER.warning( + "'%s' is off but nothing lets a server pair (%s), so no server can play on this " + "device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _request_controller_role(config: ConfigType) -> ConfigType: """Request the controller role for the sendspin.switch action.""" request_controller_support() return config +# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. +SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) +) + SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( - automation.maybe_simple_id( - cv.Schema( - { - cv.GenerateID(): cv.use_id(SendspinHub), - } - ) - ), - _request_controller_role, + SENDSPIN_HUB_ACTION_SCHEMA, _request_controller_role ) @@ -241,6 +323,19 @@ automation.register_apply_action( ) +automation.register_apply_action( + "sendspin.confirm_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("confirm_pairing_window()"), +) + +automation.register_apply_action( + "sendspin.cancel_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("cancel_pairing_window()"), +) + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -265,6 +360,14 @@ async def to_code(config: ConfigType) -> None: if value: cg.add(setter(value)) + if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: + cg.add(var.set_static_pairing_code(code)) + + if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: + cg.add(var.set_default_unpaired_access(unpaired_access)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") # esp_websocket_client links esp_tls even for ws:// connections. diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 0348850225e..927e0e49ad2 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -26,6 +26,26 @@ static const char *const TAG = "sendspin.hub"; namespace { +// The reason passed to on_pairing_failed automations, in the protocol's own spelling. +StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) { + using sendspin::SendspinPairAbortReason; + switch (reason) { + case SendspinPairAbortReason::ATTEMPT_TIMEOUT: + return StringRef::from_lit("attempt_timeout"); + case SendspinPairAbortReason::CONCURRENT_ATTEMPT: + return StringRef::from_lit("concurrent_attempt"); + case SendspinPairAbortReason::METHOD_NOT_SUPPORTED: + return StringRef::from_lit("method_not_supported"); + case SendspinPairAbortReason::PAIRING_CODE_MISMATCH: + return StringRef::from_lit("pairing_code_mismatch"); + case SendspinPairAbortReason::USER_CANCELLED: + return StringRef::from_lit("user_cancelled"); + case SendspinPairAbortReason::UNKNOWN: + break; + } + return StringRef::from_lit("unknown"); +} + // Zeroes through a volatile pointer so dead-store elimination cannot drop it. void secure_wipe(void *data, size_t len) { volatile auto *p = static_cast(data); @@ -134,8 +154,7 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - // Any server may play without pairing, as before rc1. - this->client_->set_unpaired_access_enabled(true); + this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); #ifndef USE_SENDSPIN_SWITCH this->enabled_ = true; @@ -183,9 +202,11 @@ void SendspinHub::dump_config() { " Model: %s\n" " Firmware version: %s\n" " Task stack in PSRAM: %s\n" + " Static pairing code: %s\n" " Unpaired access: %s", client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->static_pairing_code_ != nullptr), YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); #ifdef USE_SENDSPIN_ARTWORK @@ -254,6 +275,20 @@ void SendspinHub::leave_group() { } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.confirm_pairing_window action) +void SendspinHub::confirm_pairing_window() { + if (this->is_client_running()) { + this->client_->confirm_pairing_window(); + } +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.cancel_pairing_window action) +void SendspinHub::cancel_pairing_window() { + if (this->is_client_running()) { + this->client_->cancel_pairing_window(); + } +} + const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the @@ -293,6 +328,11 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { config.protocol_task_psram_stack = this->task_stack_in_psram_; config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + if (this->static_pairing_code_ != nullptr) { + config.static_pairing_code_locations = {"operator"}; + config.static_pairing_code = this->static_pairing_code_; + } + return config; } @@ -321,6 +361,18 @@ void SendspinHub::on_release_high_performance() { #endif } +void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks_.call(); } + +void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } + +void SendspinHub::on_pairing_succeeded(const std::string &server_id) { + this->pairing_succeeded_callbacks_.call(server_id); +} + +void SendspinHub::on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) { + this->pairing_failed_callbacks_.call(server_id, pair_abort_reason_to_string(reason)); +} + // --- SendspinNetworkProvider override --- // THREAD CONTEXT: Main loop (polled by start() and client_->loop()) diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 43c56feca7a..7c3708c374d 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "esphome/core/version.h" #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE @@ -120,12 +121,39 @@ class SendspinHub final : public Component, /// No-op if the hub's client is not running. Must be called from the main loop thread. void leave_group(); + /// @brief Confirms a pairing attempt on the device. With no attempt waiting, it opens the pairing window for the + /// next one. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void confirm_pairing_window(); + + /// @brief Closes an open pairing window, so a waiting pairing attempt is not confirmed. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void cancel_pairing_window(); + // --- Configuration setters (called from codegen) --- template void add_group_update_callback(F &&callback) { this->group_update_callbacks_.add(std::forward(callback)); } + template void add_on_open_pairing_window_callback(F &&callback) { + this->open_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_close_pairing_window_callback(F &&callback) { + this->close_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_succeeded_callback(F &&callback) { + this->pairing_succeeded_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_failed_callback(F &&callback) { + this->pairing_failed_callbacks_.add(std::forward(callback)); + } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or @@ -136,6 +164,9 @@ class SendspinHub final : public Component, /// called this once. Must be called from the main loop thread. void set_enabled(bool enabled); + /// Turns unpaired (Sentinel) access on or off from setup(); see SendspinClient::set_unpaired_access_enabled(). + void set_default_unpaired_access(bool enabled) { this->default_unpaired_access_ = enabled; } + /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -152,6 +183,9 @@ class SendspinHub final : public Component, void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } #endif + /// The static pairing code, used on every boot. + void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -240,6 +274,14 @@ class SendspinHub final : public Component, void on_release_high_performance() override; + void on_open_pairing_window() override; + + void on_close_pairing_window() override; + + void on_pairing_succeeded(const std::string &server_id) override; + + void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; + // --- SendspinNetworkProvider override --- bool is_network_ready() override; @@ -312,6 +354,14 @@ class SendspinHub final : public Component, // Callback fan-out to child components CallbackManager group_update_callbacks_{}; + // Lazy: each pairing callback is fed by an optional YAML surface. + LazyCallbackManager open_pairing_window_callbacks_{}; + LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager pairing_succeeded_callbacks_{}; + LazyCallbackManager pairing_failed_callbacks_{}; + + const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE bool mdns_advertised_{false}; // Last state requested from mdns diff --git a/tests/component_tests/sendspin/test_pairing.py b/tests/component_tests/sendspin/test_pairing.py new file mode 100644 index 00000000000..dcfce8cece8 --- /dev/null +++ b/tests/component_tests/sendspin/test_pairing.py @@ -0,0 +1,77 @@ +"""Validation tests for the sendspin hub's pairing options. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_STATIC_PAIRING_CODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _hub_config(**overrides: Any) -> ConfigType: + """Build a minimal valid hub config, allowing field overrides.""" + config: ConfigType = {"id": "sendspin_hub_id"} + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_hub_config()) + + assert CONF_STATIC_PAIRING_CODE not in config + + +@pytest.mark.parametrize("code", ["01234567", "00000000", "99999999"]) +def test_static_pairing_code_accepted( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Eight decimal digits, leading zeros included, pass through unchanged.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert ( + CONFIG_SCHEMA(_hub_config(static_pairing_code=code))[CONF_STATIC_PAIRING_CODE] + == code + ) + + +@pytest.mark.parametrize( + "code", + [ + "0123456", # too short + "012345678", # too long + "0123456a", # not all decimal digits + "0123 567", # whitespace is not a digit + "01234567", # full-width digits are not ASCII + "", + ], +) +def test_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Anything that is not exactly eight decimal digits is refused here, since the + library would otherwise refuse to start the client.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="exactly 8 decimal digits"): + CONFIG_SCHEMA(_hub_config(static_pairing_code=code)) + + +def test_unquoted_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """An unquoted YAML code arrives as an int, having already lost its leading + zeros, so it is refused rather than silently pairing with the wrong value.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567)) diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py new file mode 100644 index 00000000000..a66de471ab5 --- /dev/null +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -0,0 +1,75 @@ +"""Validation tests for the hub's unpaired access policy.""" + +import logging + +import pytest + +from esphome.components.sendspin import ( + CONF_UNPAIRED_ACCESS, + CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, +) +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "sendspin_hub_id" +NO_PAIRING_WARNING = "nothing lets a server pair" + + +def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None: + """Codegen leaves the hub's built-in default of on in place when the key is not set.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + +@pytest.mark.parametrize("value", [True, False]) +def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: value}) + + assert config[CONF_UNPAIRED_ACCESS] is value + + +def test_off_without_pairing_method_warns( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """With unpaired access off and no way to pair, no server could ever play.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +@pytest.mark.parametrize("method", ["static_pairing_code"]) +def test_off_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + method: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False} + if method == "static_pairing_code": + hub_config["static_pairing_code"] = "01234567" + config = HUB_CONFIG_SCHEMA(hub_config) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_on_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index bd6747ee07b..147227b2f1d 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -7,3 +7,27 @@ sendspin: manufacturer: Test Manufacturer model: Test Model firmware_version: 1.2.3 + on_open_pairing_window: + then: + - logger.log: "Pairing window requested" + - sendspin.confirm_pairing_window: + # Calling cancel_pairing_window here only compiles the action; a real config cancels from a user input. + on_close_pairing_window: + then: + - logger.log: "Pairing window prompt dismissed" + - sendspin.cancel_pairing_window: + on_pairing_succeeded: + then: + - logger.log: + format: "Paired with server %s" + args: ["server_id.c_str()"] + on_pairing_failed: + then: + - logger.log: + format: "Pairing with server %s failed: %s" + args: ["server_id.c_str()", "reason.c_str()"] + - if: + condition: + lambda: 'return reason == "user_cancelled";' + then: + - logger.log: "Pairing was cancelled" diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml new file mode 100644 index 00000000000..cd9cd82bec8 --- /dev/null +++ b/tests/components/sendspin/common-pairing.yaml @@ -0,0 +1,14 @@ +# The hub on its own, with no other sendspin platforms, to build the pairing options. +# +# Deliberately does not include common.yaml: that file pulls in the shared hub package, and +# the point here is a separate hub configuration. sendspin depends on network, so the interface +# has to be declared locally instead. +wifi: + ap: + +# Also the static-pairing-code build. +sendspin: + id: sendspin_hub_id + static_pairing_code: "01234567" + # Covers the hub's unpaired access option. + unpaired_access: false diff --git a/tests/components/sendspin/test-pairing.esp32-idf.yaml b/tests/components/sendspin/test-pairing.esp32-idf.yaml new file mode 100644 index 00000000000..5862e841df3 --- /dev/null +++ b/tests/components/sendspin/test-pairing.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-pairing.yaml From 052f5a4cc66747d68c584d8fc250664244b492d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 07:24:17 -1000 Subject: [PATCH 058/435] [core] Declare component log tags with ESPHOME_LOG_TAG (#20291) --- esphome/components/a01nyub/a01nyub.cpp | 2 +- esphome/components/a02yyuw/a02yyuw.cpp | 2 +- esphome/components/a4988/a4988.cpp | 2 +- .../absolute_humidity/absolute_humidity.cpp | 2 +- esphome/components/ac_dimmer/ac_dimmer.cpp | 2 +- .../components/ac_dimmer/hw_timer_esp_idf.cpp | 2 +- .../adalight/adalight_light_effect.cpp | 2 +- esphome/components/adc/adc_sensor_common.cpp | 2 +- esphome/components/adc/adc_sensor_esp32.cpp | 2 +- esphome/components/adc/adc_sensor_esp8266.cpp | 2 +- .../components/adc/adc_sensor_libretiny.cpp | 2 +- esphome/components/adc/adc_sensor_rp2.cpp | 2 +- esphome/components/adc/adc_sensor_zephyr.cpp | 2 +- esphome/components/adc128s102/adc128s102.cpp | 2 +- .../adc128s102/sensor/adc128s102_sensor.cpp | 2 +- .../addressable_light_display.cpp | 2 +- esphome/components/ade7880/ade7880.cpp | 2 +- .../components/ade7953_base/ade7953_base.cpp | 2 +- .../components/ade7953_i2c/ade7953_i2c.cpp | 2 +- .../components/ade7953_spi/ade7953_spi.cpp | 2 +- esphome/components/ads1115/ads1115.cpp | 2 +- .../ads1115/sensor/ads1115_sensor.cpp | 2 +- esphome/components/ads1118/ads1118.cpp | 2 +- .../ads1118/sensor/ads1118_sensor.cpp | 2 +- esphome/components/ags10/ags10.cpp | 2 +- esphome/components/aht10/aht10.cpp | 2 +- esphome/components/aic3204/aic3204.cpp | 2 +- .../airthings_ble/airthings_listener.cpp | 2 +- .../airthings_wave_base.cpp | 2 +- .../airthings_wave_mini.cpp | 2 +- .../airthings_wave_plus.cpp | 2 +- .../alarm_control_panel.cpp | 2 +- .../alarm_control_panel_call.cpp | 2 +- esphome/components/alpha3/alpha3.cpp | 2 +- esphome/components/am2315c/am2315c.cpp | 2 +- esphome/components/am2320/am2320.cpp | 2 +- esphome/components/am43/cover/am43_cover.cpp | 2 +- .../components/am43/sensor/am43_sensor.cpp | 2 +- .../analog_threshold_binary_sensor.cpp | 2 +- esphome/components/anova/anova.cpp | 2 +- esphome/components/apds9306/apds9306.cpp | 2 +- esphome/components/apds9960/apds9960.cpp | 2 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_frame_helper.cpp | 2 +- .../components/api/api_frame_helper_noise.cpp | 2 +- .../api/api_frame_helper_plaintext.cpp | 2 +- .../api/api_outgoing_connection.cpp | 2 +- esphome/components/api/api_pb2_service.cpp | 2 +- esphome/components/api/api_server.cpp | 2 +- esphome/components/api/api_wizard.cpp | 2 +- esphome/components/api/proto.cpp | 2 +- esphome/components/aqi/aqi_sensor.cpp | 2 +- esphome/components/as3935/as3935.cpp | 2 +- esphome/components/as3935_i2c/as3935_i2c.cpp | 2 +- esphome/components/as3935_spi/as3935_spi.cpp | 2 +- esphome/components/as5600/as5600.cpp | 2 +- .../as5600/sensor/as5600_sensor.cpp | 2 +- esphome/components/as7341/as7341.cpp | 2 +- .../components/async_tcp/async_tcp_socket.cpp | 2 +- esphome/components/at581x/at581x.cpp | 2 +- .../atc_mithermometer/atc_mithermometer.cpp | 2 +- esphome/components/atm90e26/atm90e26.cpp | 2 +- esphome/components/atm90e32/atm90e32.cpp | 2 +- .../atm90e32/button/atm90e32_button.cpp | 2 +- esphome/components/audio/audio_decoder.cpp | 2 +- esphome/components/audio/audio_reader.cpp | 2 +- .../media_source/audio_file_media_source.cpp | 2 +- .../audio_http/audio_http_media_source.cpp | 2 +- .../touchscreen/axs15231_touchscreen.cpp | 2 +- esphome/components/b_parasite/b_parasite.cpp | 2 +- esphome/components/ballu/ballu.cpp | 2 +- .../bang_bang/bang_bang_climate.cpp | 2 +- .../beken_spi_led_strip/led_strip.cpp | 2 +- esphome/components/bh1750/bh1750.cpp | 2 +- esphome/components/bh1900nux/bh1900nux.cpp | 2 +- esphome/components/binary/fan/binary_fan.cpp | 2 +- .../components/binary_sensor/automation.cpp | 2 +- .../binary_sensor/binary_sensor.cpp | 2 +- esphome/components/binary_sensor/filter.cpp | 2 +- .../binary_sensor_map/binary_sensor_map.cpp | 2 +- esphome/components/bk72xx_ble/bdk_scan.cpp | 2 +- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 2 +- .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 2 +- esphome/components/bl0906/bl0906.cpp | 2 +- esphome/components/bl0939/bl0939.cpp | 2 +- esphome/components/bl0940/bl0940.cpp | 2 +- .../button/calibration_reset_button.cpp | 2 +- .../bl0940/number/calibration_number.cpp | 2 +- esphome/components/bl0942/bl0942.cpp | 2 +- esphome/components/ble_client/ble_client.cpp | 2 +- .../ble_client/output/ble_binary_output.cpp | 2 +- .../ble_client/sensor/ble_rssi_sensor.cpp | 2 +- .../ble_client/sensor/ble_sensor.cpp | 2 +- .../ble_client/switch/ble_switch.cpp | 2 +- .../text_sensor/ble_text_sensor.cpp | 2 +- .../components/ble_device_base/ble_device.cpp | 2 +- esphome/components/ble_nus/ble_nus.cpp | 2 +- .../ble_presence/ble_presence_device.cpp | 2 +- .../components/ble_rssi/ble_rssi_sensor.cpp | 2 +- .../components/ble_scanner/ble_scanner.cpp | 2 +- .../bluetooth_connection.cpp | 2 +- .../bluetooth_connection_bluedroid.cpp | 2 +- .../bluetooth_connection_hub.cpp | 2 +- .../bluetooth_connection_rp2.cpp | 2 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 2 +- esphome/components/bm8563/bm8563.cpp | 2 +- .../components/bme280_base/bme280_base.cpp | 2 +- esphome/components/bme680/bme680.cpp | 2 +- .../components/bme680_bsec/bme680_bsec.cpp | 2 +- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 2 +- .../bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp | 2 +- esphome/components/bmi160/bmi160.cpp | 2 +- esphome/components/bmi270/bmi270.cpp | 2 +- esphome/components/bmp085/bmp085.cpp | 2 +- .../components/bmp280_base/bmp280_base.cpp | 2 +- .../components/bmp3xx_base/bmp3xx_base.cpp | 2 +- esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp | 2 +- esphome/components/bmp3xx_spi/bmp3xx_spi.cpp | 2 +- .../components/bmp581_base/bmp581_base.cpp | 2 +- esphome/components/bmp581_spi/bmp581_spi.cpp | 2 +- esphome/components/bp1658cj/bp1658cj.cpp | 2 +- esphome/components/bp5758d/bp5758d.cpp | 2 +- .../bthome_mithermometer/bthome_ble.cpp | 2 +- esphome/components/button/button.cpp | 2 +- .../esp32_camera_jpeg_encoder.cpp | 2 +- esphome/components/canbus/canbus.cpp | 2 +- esphome/components/cap1188/cap1188.cpp | 2 +- .../captive_portal/captive_portal.cpp | 2 +- .../captive_portal/dns_server_esp32_idf.cpp | 2 +- esphome/components/cc1101/cc1101.cpp | 2 +- esphome/components/ccs811/ccs811.cpp | 2 +- esphome/components/cd74hc4067/cd74hc4067.cpp | 2 +- .../bridge/cdc_acm_uart_bridge.cpp | 2 +- esphome/components/ch422g/ch422g.cpp | 2 +- esphome/components/ch423/ch423.cpp | 2 +- esphome/components/climate/climate.cpp | 2 +- esphome/components/climate_ir/climate_ir.cpp | 2 +- .../climate_ir_lg/climate_ir_lg.cpp | 2 +- esphome/components/cm1106/cm1106.cpp | 2 +- .../components/combination/combination.cpp | 2 +- esphome/components/coolix/coolix.cpp | 2 +- .../copy/binary_sensor/copy_binary_sensor.cpp | 2 +- .../components/copy/button/copy_button.cpp | 2 +- esphome/components/copy/cover/copy_cover.cpp | 2 +- esphome/components/copy/fan/copy_fan.cpp | 2 +- esphome/components/copy/lock/copy_lock.cpp | 2 +- .../components/copy/number/copy_number.cpp | 2 +- .../components/copy/select/copy_select.cpp | 2 +- .../components/copy/sensor/copy_sensor.cpp | 2 +- .../components/copy/switch/copy_switch.cpp | 2 +- esphome/components/copy/text/copy_text.cpp | 2 +- .../copy/text_sensor/copy_text_sensor.cpp | 2 +- esphome/components/counter/counter_sensor.cpp | 2 +- esphome/components/cover/cover.cpp | 2 +- esphome/components/cs5460a/cs5460a.cpp | 2 +- esphome/components/cse7761/cse7761.cpp | 2 +- esphome/components/cse7766/cse7766.cpp | 2 +- .../cst226/binary_sensor/cstt6_button.cpp | 2 +- .../cst226/touchscreen/cst226_touchscreen.cpp | 2 +- .../cst328/binary_sensor/cst328_button.cpp | 2 +- .../cst328/touchscreen/cst328_touchscreen.cpp | 2 +- .../components/ct_clamp/ct_clamp_sensor.cpp | 2 +- .../current_based/current_based_cover.cpp | 2 +- esphome/components/d01/d01.cpp | 2 +- esphome/components/dac7678/dac7678_output.cpp | 2 +- esphome/components/daikin/daikin.cpp | 2 +- esphome/components/daikin_arc/daikin_arc.cpp | 2 +- esphome/components/daikin_brc/daikin_brc.cpp | 2 +- .../components/dallas_temp/dallas_temp.cpp | 2 +- esphome/components/daly_bms/daly_bms.cpp | 2 +- esphome/components/datetime/date_entity.cpp | 2 +- .../components/datetime/datetime_entity.cpp | 2 +- esphome/components/datetime/time_entity.cpp | 2 +- esphome/components/debug/debug_component.cpp | 2 +- esphome/components/debug/debug_esp32.cpp | 2 +- esphome/components/debug/debug_esp8266.cpp | 2 +- esphome/components/debug/debug_libretiny.cpp | 2 +- esphome/components/debug/debug_rp2.cpp | 2 +- esphome/components/debug/debug_zephyr.cpp | 2 +- .../deep_sleep/deep_sleep_bk72xx.cpp | 2 +- .../deep_sleep/deep_sleep_component.cpp | 2 +- .../deep_sleep/deep_sleep_esp32.cpp | 2 +- .../deep_sleep/deep_sleep_esp8266.cpp | 2 +- .../deep_sleep/deep_sleep_zephyr.cpp | 2 +- esphome/components/delonghi/delonghi.cpp | 2 +- esphome/components/dew_point/dew_point.cpp | 2 +- esphome/components/dfplayer/dfplayer.cpp | 2 +- .../components/dfrobot_sen0395/commands.cpp | 2 +- .../dfrobot_sen0395/dfrobot_sen0395.cpp | 2 +- esphome/components/dht/dht.cpp | 2 +- esphome/components/dht12/dht12.cpp | 2 +- esphome/components/display/display.cpp | 2 +- esphome/components/display/display_buffer.cpp | 2 +- esphome/components/display/rect.cpp | 2 +- esphome/components/dlms_meter/dlms_meter.cpp | 2 +- esphome/components/dps310/dps310.cpp | 2 +- esphome/components/ds1307/ds1307.cpp | 2 +- esphome/components/ds1603l/ds1603l.cpp | 2 +- esphome/components/ds2484/ds2484.cpp | 2 +- esphome/components/ds248x/ds248x.cpp | 2 +- .../components/ds248x/ds248x_one_wire_bus.cpp | 2 +- .../duty_cycle/duty_cycle_sensor.cpp | 2 +- .../components/duty_time/duty_time_sensor.cpp | 2 +- esphome/components/e131/e131.cpp | 2 +- .../e131/e131_addressable_light_effect.cpp | 2 +- esphome/components/e131/e131_packet.cpp | 2 +- esphome/components/ee895/ee895.cpp | 2 +- .../ektf2232/touchscreen/ektf2232.cpp | 2 +- esphome/components/emc2101/emc2101.cpp | 2 +- .../emc2101/sensor/emc2101_sensor.cpp | 2 +- esphome/components/emmeti/emmeti.cpp | 2 +- esphome/components/emontx/emontx.cpp | 2 +- .../emontx/sensor/emontx_sensor.cpp | 2 +- esphome/components/endstop/endstop_cover.cpp | 2 +- .../components/ens160_base/ens160_base.cpp | 2 +- esphome/components/ens160_i2c/ens160_i2c.cpp | 2 +- esphome/components/ens160_spi/ens160_spi.cpp | 2 +- esphome/components/ens210/ens210.cpp | 2 +- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- .../components/epaper_spi/epaper_spi_4bpp.cpp | 2 +- .../epaper_spi/epaper_spi_inkplate2.cpp | 2 +- .../epaper_spi/epaper_spi_inkplate6color.cpp | 2 +- .../epaper_spi/epaper_spi_jd79660.cpp | 2 +- .../components/epaper_spi/epaper_spi_mono.cpp | 2 +- .../epaper_spi/epaper_spi_spectra_e6.cpp | 2 +- .../epaper_spi/epaper_spi_ssd1677.cpp | 2 +- .../epaper_spi/epaper_spi_ssd1677_gray4.cpp | 2 +- .../epaper_spi/epaper_spi_ssd1683.cpp | 2 +- .../epaper_spi/epaper_spi_t133a01.cpp | 2 +- .../epaper_spi/epaper_spi_uc8179.cpp | 2 +- .../epaper_spi/epaper_waveshare.cpp | 2 +- .../components/epaper_spi/epaper_weact_3c.cpp | 2 +- esphome/components/es7210/es7210.cpp | 2 +- esphome/components/es7243e/es7243e.cpp | 2 +- esphome/components/es8156/es8156.cpp | 2 +- esphome/components/es8311/es8311.cpp | 2 +- esphome/components/es8388/es8388.cpp | 2 +- esphome/components/esp32/crash_handler.cpp | 2 +- esphome/components/esp32/gpio.cpp | 2 +- esphome/components/esp32/helpers.cpp | 2 +- esphome/components/esp32/preferences.cpp | 2 +- esphome/components/esp32_ble/ble.cpp | 2 +- .../components/esp32_ble/ble_advertising.cpp | 2 +- .../esp32_ble_beacon/esp32_ble_beacon.cpp | 2 +- .../esp32_ble_client/ble_characteristic.cpp | 2 +- .../esp32_ble_client/ble_client_base.cpp | 2 +- .../esp32_ble_client/ble_service.cpp | 2 +- .../esp32_ble_server/ble_characteristic.cpp | 2 +- .../esp32_ble_server/ble_descriptor.cpp | 2 +- .../esp32_ble_server/ble_server.cpp | 2 +- .../esp32_ble_server/ble_service.cpp | 2 +- .../esp32_ble_tracker/bluedroid_stubs.cpp | 2 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 2 +- .../components/esp32_camera/esp32_camera.cpp | 2 +- .../camera_web_server.cpp | 2 +- esphome/components/esp32_can/esp32_can.cpp | 2 +- esphome/components/esp32_dac/esp32_dac.cpp | 2 +- .../esp32_hosted/esp_now_hosted.cpp | 2 +- .../update/esp32_hosted_update.cpp | 2 +- .../esp32_rmt_led_strip/led_strip.cpp | 2 +- .../components/esp32_touch/esp32_touch.cpp | 2 +- esphome/components/esp8266/crash_handler.cpp | 2 +- esphome/components/esp8266/gpio.cpp | 2 +- esphome/components/esp8266/preferences.cpp | 2 +- .../components/esp8266_pwm/esp8266_pwm.cpp | 2 +- esphome/components/esp_ldo/esp_ldo.cpp | 2 +- esphome/components/espectre/espectre.cpp | 2 +- .../espectre/sensor/espectre_diagnostics.cpp | 2 +- .../components/esphome/ota/ota_esphome.cpp | 2 +- .../ota/ota_esphome_inflate_session.cpp | 2 +- .../esphome/ota/ota_esphome_noise.cpp | 2 +- .../components/espnow/espnow_component.cpp | 2 +- .../packet_transport/espnow_transport.cpp | 2 +- .../ethernet/ethernet_component_esp32.cpp | 2 +- .../ethernet/ethernet_component_rp2.cpp | 2 +- .../ethernet_info_text_sensor.cpp | 2 +- esphome/components/event/event.cpp | 2 +- .../exponential_moving_average_sensor.cpp | 2 +- .../exposure_notifications.cpp | 2 +- esphome/components/ezo_pmp/ezo_pmp.cpp | 2 +- .../button/factory_reset_button.cpp | 2 +- .../factory_reset/factory_reset.cpp | 2 +- .../switch/factory_reset_switch.cpp | 2 +- esphome/components/fan/fan.cpp | 2 +- .../components/fastled_base/fastled_light.cpp | 2 +- .../components/feedback/feedback_cover.cpp | 2 +- .../fingerprint_grow/fingerprint_grow.cpp | 2 +- esphome/components/font/font.cpp | 2 +- esphome/components/fs3000/fs3000.cpp | 2 +- .../ft5x06/touchscreen/ft5x06_touchscreen.cpp | 2 +- esphome/components/ft63x6/ft63x6.cpp | 2 +- .../fujitsu_general/fujitsu_general.cpp | 2 +- esphome/components/gcja5/gcja5.cpp | 2 +- esphome/components/gdk101/gdk101.cpp | 2 +- esphome/components/gl_r01_i2c/gl_r01_i2c.cpp | 2 +- .../components/gp2y1010au0f/gp2y1010au0f.cpp | 2 +- esphome/components/gp8403/gp8403.cpp | 2 +- .../gp8403/output/gp8403_output.cpp | 2 +- .../gpio/binary_sensor/gpio_binary_sensor.cpp | 2 +- .../gpio/one_wire/gpio_one_wire.cpp | 2 +- .../gpio/output/gpio_binary_output.cpp | 2 +- .../components/gpio/switch/gpio_switch.cpp | 2 +- esphome/components/gps/gps.cpp | 2 +- esphome/components/gps/time/gps_time.cpp | 2 +- esphome/components/graph/graph.cpp | 4 +- .../graphical_display_menu.cpp | 2 +- esphome/components/gree/gree.cpp | 2 +- .../components/gree/switch/gree_switch.cpp | 2 +- .../grove_gas_mc_v2/grove_gas_mc_v2.cpp | 2 +- .../grove_tb6612fng/grove_tb6612fng.cpp | 2 +- .../growatt_solar/growatt_solar.cpp | 2 +- .../gsl3670/gsl3670_touchscreen.cpp | 2 +- .../gt911/binary_sensor/gt911_button.cpp | 2 +- .../gt911/touchscreen/gt911_touchscreen.cpp | 2 +- esphome/components/haier/haier_base.cpp | 2 +- esphome/components/haier/hon_climate.cpp | 2 +- .../components/haier/smartair2_climate.cpp | 2 +- .../havells_solar/havells_solar.cpp | 2 +- .../components/hbridge/fan/hbridge_fan.cpp | 2 +- .../hbridge/switch/hbridge_switch.cpp | 2 +- esphome/components/hc8/hc8.cpp | 2 +- esphome/components/hdc1080/hdc1080.cpp | 2 +- esphome/components/hdc2010/hdc2010.cpp | 2 +- esphome/components/hdc2080/hdc2080.cpp | 2 +- esphome/components/hdc302x/hdc302x.cpp | 2 +- esphome/components/he60r/he60r.cpp | 2 +- esphome/components/heatpumpir/heatpumpir.cpp | 2 +- .../hitachi_ac344/hitachi_ac344.cpp | 2 +- .../hitachi_ac424/hitachi_ac424.cpp | 2 +- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 2 +- esphome/components/hlw8012/hlw8012.cpp | 2 +- esphome/components/hlw8032/hlw8032.cpp | 2 +- esphome/components/hm3301/hm3301.cpp | 2 +- esphome/components/hmc5883l/hmc5883l.cpp | 2 +- .../hoermann_hcp_binary_sensor.cpp | 2 +- .../hoermann_hcp/cover/hoermann_hcp_cover.cpp | 2 +- .../components/hoermann_hcp/hoermann_hcp.cpp | 2 +- .../hoermann_hcp/light/hoermann_hcp_light.cpp | 2 +- .../text_sensor/hoermann_hcp_text_sensor.cpp | 2 +- .../homeassistant_binary_sensor.cpp | 2 +- .../button/homeassistant_button.cpp | 2 +- .../number/homeassistant_number.cpp | 2 +- .../select/homeassistant_select.cpp | 2 +- .../sensor/homeassistant_sensor.cpp | 2 +- .../switch/homeassistant_switch.cpp | 2 +- .../homeassistant/text/homeassistant_text.cpp | 2 +- .../text_sensor/homeassistant_text_sensor.cpp | 2 +- .../homeassistant/time/homeassistant_time.cpp | 2 +- .../honeywell_hih_i2c/honeywell_hih.cpp | 2 +- .../components/honeywellabp/honeywellabp.cpp | 2 +- .../honeywellabp2_i2c/honeywellabp2.cpp | 2 +- esphome/components/host/gpio.cpp | 2 +- esphome/components/host/helpers.cpp | 2 +- esphome/components/host/preferences.cpp | 2 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 2 +- esphome/components/hte501/hte501.cpp | 2 +- .../components/http_request/http_request.cpp | 2 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_host.cpp | 2 +- .../http_request/http_request_idf.cpp | 2 +- .../http_request/ota/ota_http_request.cpp | 2 +- .../update/http_request_update.cpp | 2 +- esphome/components/htu21d/htu21d.cpp | 2 +- esphome/components/htu31d/htu31d.cpp | 2 +- esphome/components/hub75/hub75.cpp | 2 +- esphome/components/hx711/hx711.cpp | 2 +- .../components/hydreon_rgxx/hydreon_rgxx.cpp | 2 +- esphome/components/hyt271/hyt271.cpp | 2 +- esphome/components/i2c/i2c.cpp | 2 +- esphome/components/i2c/i2c_bus_arduino.cpp | 2 +- esphome/components/i2c/i2c_bus_esp_idf.cpp | 2 +- esphome/components/i2c/i2c_bus_host.cpp | 2 +- esphome/components/i2c/i2c_bus_zephyr.cpp | 2 +- esphome/components/i2c_device/i2c_device.cpp | 2 +- .../microphone/i2s_audio_microphone.cpp | 2 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 2 +- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../speaker/i2s_audio_speaker_standard.cpp | 2 +- .../i2s_audio/speaker/spdif_encoder.cpp | 2 +- esphome/components/iaqcore/iaqcore.cpp | 2 +- esphome/components/icnt86/icnt86.cpp | 2 +- .../components/improv_base/improv_base.cpp | 2 +- .../improv_ble/improv_ble_component.cpp | 2 +- .../improv_serial/improv_serial_component.cpp | 2 +- esphome/components/ina219/ina219.cpp | 2 +- esphome/components/ina226/ina226.cpp | 2 +- esphome/components/ina260/ina260.cpp | 2 +- .../components/ina2xx_base/ina2xx_base.cpp | 2 +- esphome/components/ina2xx_i2c/ina2xx_i2c.cpp | 2 +- esphome/components/ina2xx_spi/ina2xx_spi.cpp | 2 +- esphome/components/ina3221/ina3221.cpp | 2 +- esphome/components/infrared/infrared.cpp | 2 +- .../inkbird_ibsth1_mini.cpp | 2 +- esphome/components/inkplate/inkplate.cpp | 2 +- .../integration/integration_sensor.cpp | 2 +- .../internal_temperature_bk72xx.cpp | 2 +- .../internal_temperature_common.cpp | 2 +- .../internal_temperature_esp32.cpp | 2 +- .../internal_temperature_rp2.cpp | 2 +- .../internal_temperature_zephyr.cpp | 2 +- esphome/components/ir_rf_base/ir_rf_base.cpp | 2 +- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 2 +- esphome/components/it8951/it8951.cpp | 2 +- esphome/components/jsn_sr04t/jsn_sr04t.cpp | 2 +- esphome/components/json/json_util.cpp | 2 +- .../components/kamstrup_kmp/kamstrup_kmp.cpp | 2 +- .../key_collector/key_collector.cpp | 2 +- esphome/components/kmeteriso/kmeteriso.cpp | 2 +- esphome/components/kuntze/kuntze.cpp | 2 +- esphome/components/lc709203f/lc709203f.cpp | 2 +- esphome/components/lcd_base/lcd_display.cpp | 2 +- .../components/lcd_gpio/gpio_lcd_display.cpp | 2 +- esphome/components/lcd_menu/lcd_menu.cpp | 2 +- .../lcd_pcf8574/pcf8574_display.cpp | 2 +- esphome/components/ld2410/ld2410.cpp | 2 +- esphome/components/ld2412/ld2412.cpp | 2 +- .../binary_sensor/ld2420_binary_sensor.cpp | 2 +- .../ld2420/button/reconfig_buttons.cpp | 2 +- esphome/components/ld2420/ld2420.cpp | 2 +- .../ld2420/number/gate_config_number.cpp | 2 +- .../ld2420/select/operating_mode_select.cpp | 2 +- .../ld2420/sensor/ld2420_sensor.cpp | 2 +- .../ld2420/text_sensor/ld2420_text_sensor.cpp | 2 +- esphome/components/ld2450/ld2450.cpp | 2 +- esphome/components/ld6002b/ld6002b.cpp | 2 +- esphome/components/ledc/ledc_output.cpp | 2 +- esphome/components/libretiny/gpio_arduino.cpp | 2 +- esphome/components/libretiny/lt_component.cpp | 2 +- esphome/components/libretiny/preferences.cpp | 2 +- .../libretiny_pwm/libretiny_pwm.cpp | 2 +- .../components/light/addressable_light.cpp | 2 +- esphome/components/light/automation.cpp | 2 +- esphome/components/light/light_call.cpp | 2 +- esphome/components/light/light_state.cpp | 2 +- .../components/lightwaverf/lightwaverf.cpp | 2 +- .../touchscreen/lilygo_t5_47_touchscreen.cpp | 2 +- esphome/components/lm75b/lm75b.cpp | 2 +- esphome/components/ln882h_ble/ln882h_ble.cpp | 2 +- .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 2 +- esphome/components/lock/lock.cpp | 2 +- esphome/components/logger/logger.cpp | 2 +- esphome/components/logger/logger_esp32.cpp | 2 +- esphome/components/logger/logger_esp8266.cpp | 2 +- .../components/logger/logger_libretiny.cpp | 2 +- esphome/components/logger/logger_rp2.cpp | 2 +- esphome/components/logger/logger_zephyr.cpp | 2 +- esphome/components/lps22/lps22.cpp | 2 +- esphome/components/lsm6ds/lsm6ds.cpp | 2 +- esphome/components/ltr390/ltr390.cpp | 2 +- esphome/components/ltr501/ltr501.cpp | 2 +- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 2 +- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- .../light/m5stack_8angle_light.cpp | 2 +- .../m5stack_8angle/m5stack_8angle.cpp | 2 +- .../matrix_keypad/matrix_keypad.cpp | 2 +- esphome/components/max17043/max17043.cpp | 2 +- esphome/components/max31855/max31855.cpp | 2 +- esphome/components/max31856/max31856.cpp | 2 +- esphome/components/max31865/max31865.cpp | 2 +- esphome/components/max44009/max44009.cpp | 2 +- esphome/components/max6675/max6675.cpp | 2 +- esphome/components/max6956/max6956.cpp | 2 +- .../max6956/output/max6956_led_output.cpp | 2 +- esphome/components/max7219/max7219.cpp | 2 +- .../components/max7219digit/max7219digit.cpp | 2 +- esphome/components/max9611/max9611.cpp | 2 +- esphome/components/mcp23008/mcp23008.cpp | 2 +- esphome/components/mcp23016/mcp23016.cpp | 2 +- esphome/components/mcp23017/mcp23017.cpp | 2 +- esphome/components/mcp23s08/mcp23s08.cpp | 2 +- esphome/components/mcp23s17/mcp23s17.cpp | 2 +- .../mcp23x08_base/mcp23x08_base.cpp | 2 +- .../mcp23x17_base/mcp23x17_base.cpp | 2 +- esphome/components/mcp2515/mcp2515.cpp | 2 +- esphome/components/mcp3008/mcp3008.cpp | 2 +- .../mcp3008/sensor/mcp3008_sensor.cpp | 2 +- esphome/components/mcp3204/mcp3204.cpp | 2 +- .../mcp3204/sensor/mcp3204_sensor.cpp | 2 +- esphome/components/mcp3221/mcp3221_sensor.cpp | 2 +- esphome/components/mcp4461/mcp4461.cpp | 2 +- .../mcp4461/output/mcp4461_output.cpp | 2 +- esphome/components/mcp4725/mcp4725.cpp | 2 +- esphome/components/mcp4728/mcp4728.cpp | 2 +- esphome/components/mcp47a1/mcp47a1.cpp | 2 +- esphome/components/mcp9600/mcp9600.cpp | 2 +- esphome/components/mcp9808/mcp9808.cpp | 2 +- esphome/components/mdns/mdns_component.cpp | 2 +- esphome/components/mdns/mdns_esp32.cpp | 2 +- .../components/media_player/media_player.cpp | 2 +- esphome/components/mhz19/mhz19.cpp | 2 +- .../micro_wake_word/micro_wake_word.cpp | 2 +- .../components/micro_wake_word/model_data.cpp | 2 +- .../micro_wake_word/streaming_model.cpp | 2 +- .../micronova/button/micronova_button.cpp | 2 +- esphome/components/micronova/micronova.cpp | 2 +- .../micronova/number/micronova_number.cpp | 2 +- .../micronova/sensor/micronova_sensor.cpp | 2 +- .../micronova/switch/micronova_switch.cpp | 2 +- .../text_sensor/micronova_text_sensor.cpp | 2 +- esphome/components/mics_4514/mics_4514.cpp | 2 +- esphome/components/midea_ir/midea_ir.cpp | 2 +- esphome/components/mitsubishi/mitsubishi.cpp | 2 +- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 2 +- .../mitsubishi_cn105_climate.cpp | 2 +- .../mitsubishi_cn105_component.cpp | 2 +- .../mixer/speaker/mixer_speaker.cpp | 2 +- .../mk2pvrouter_binary_sensor.cpp | 2 +- .../components/mk2pvrouter/mk2pvrouter.cpp | 2 +- .../mk2pvrouter/sensor/mk2pvrouter_sensor.cpp | 2 +- .../text_sensor/mk2pvrouter_text_sensor.cpp | 2 +- .../components/mlx90393/sensor_mlx90393.cpp | 2 +- esphome/components/mlx90614/mlx90614.cpp | 2 +- esphome/components/mmc5603/mmc5603.cpp | 2 +- esphome/components/mmc5983/mmc5983.cpp | 2 +- esphome/components/modbus/modbus.cpp | 2 +- esphome/components/modbus/modbus_helpers.cpp | 2 +- .../binary_sensor/modbus_binarysensor.cpp | 2 +- .../modbus_controller/modbus_controller.cpp | 2 +- .../number/modbus_number.cpp | 2 +- .../output/modbus_output.cpp | 2 +- .../select/modbus_select.cpp | 2 +- .../sensor/modbus_sensor.cpp | 2 +- .../switch/modbus_switch.cpp | 2 +- .../text_sensor/modbus_textsensor.cpp | 2 +- .../modbus_server/modbus_server.cpp | 2 +- esphome/components/mopeka_ble/mopeka_ble.cpp | 2 +- .../mopeka_pro_check/mopeka_pro_check.cpp | 2 +- .../mopeka_std_check/mopeka_std_check.cpp | 2 +- .../binary_sensor/motion_binary_sensor.cpp | 2 +- .../components/motion/event/motion_event.cpp | 2 +- .../components/motion/motion_component.cpp | 2 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 2 +- esphome/components/mpr121/mpr121.cpp | 2 +- esphome/components/mpu6050/mpu6050.cpp | 2 +- esphome/components/mpu6886/mpu6886.cpp | 2 +- .../components/mqtt/custom_mqtt_device.cpp | 2 +- .../mqtt/mqtt_alarm_control_panel.cpp | 2 +- .../components/mqtt/mqtt_backend_esp32.cpp | 2 +- .../components/mqtt/mqtt_binary_sensor.cpp | 2 +- esphome/components/mqtt/mqtt_button.cpp | 2 +- esphome/components/mqtt/mqtt_client.cpp | 2 +- esphome/components/mqtt/mqtt_climate.cpp | 2 +- esphome/components/mqtt/mqtt_component.cpp | 2 +- esphome/components/mqtt/mqtt_cover.cpp | 2 +- esphome/components/mqtt/mqtt_date.cpp | 2 +- esphome/components/mqtt/mqtt_datetime.cpp | 2 +- esphome/components/mqtt/mqtt_event.cpp | 2 +- esphome/components/mqtt/mqtt_fan.cpp | 2 +- esphome/components/mqtt/mqtt_light.cpp | 2 +- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_number.cpp | 2 +- esphome/components/mqtt/mqtt_select.cpp | 2 +- esphome/components/mqtt/mqtt_sensor.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_text.cpp | 2 +- esphome/components/mqtt/mqtt_text_sensor.cpp | 2 +- esphome/components/mqtt/mqtt_time.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- esphome/components/mqtt/mqtt_valve.cpp | 2 +- .../sensor/mqtt_subscribe_sensor.cpp | 2 +- .../mqtt_subscribe_text_sensor.cpp | 2 +- esphome/components/ms5611/ms5611.cpp | 2 +- esphome/components/ms8607/ms8607.cpp | 2 +- esphome/components/msa3xx/msa3xx.cpp | 2 +- esphome/components/my9231/my9231.cpp | 2 +- esphome/components/nau7802/nau7802.cpp | 2 +- .../components/network/network_component.cpp | 2 +- .../binary_sensor/nextion_binarysensor.cpp | 2 +- esphome/components/nextion/nextion.cpp | 2 +- .../components/nextion/nextion_commands.cpp | 2 +- esphome/components/nextion/nextion_upload.cpp | 2 +- .../nextion/nextion_upload_arduino.cpp | 2 +- .../nextion/nextion_upload_esp32.cpp | 2 +- .../nextion/sensor/nextion_sensor.cpp | 2 +- .../nextion/switch/nextion_switch.cpp | 2 +- .../text_sensor/nextion_textsensor.cpp | 2 +- .../nfc/binary_sensor/nfc_binary_sensor.cpp | 2 +- esphome/components/nfc/ndef_message.cpp | 2 +- esphome/components/nfc/ndef_record.cpp | 2 +- esphome/components/nfc/ndef_record_text.cpp | 2 +- esphome/components/nfc/ndef_record_uri.cpp | 2 +- esphome/components/nfc/nfc.cpp | 2 +- esphome/components/nfc/nfc_helpers.cpp | 2 +- esphome/components/nfc/nfc_tag.cpp | 2 +- esphome/components/noblex/noblex.cpp | 2 +- esphome/components/noise/noise.cpp | 2 +- esphome/components/noise/noise_handshake.cpp | 2 +- esphome/components/npi19/npi19.cpp | 2 +- esphome/components/nrf52/dfu.cpp | 2 +- esphome/components/ntc/ntc.cpp | 2 +- esphome/components/number/automation.cpp | 2 +- esphome/components/number/number.cpp | 2 +- esphome/components/number/number_call.cpp | 2 +- esphome/components/number/number_traits.cpp | 2 +- .../number/sensor/number_sensor.cpp | 2 +- esphome/components/one_wire/one_wire.cpp | 2 +- esphome/components/one_wire/one_wire_bus.cpp | 2 +- .../online_image/download_buffer.cpp | 2 +- .../components/online_image/online_image.cpp | 2 +- esphome/components/opentherm/hub.cpp | 2 +- .../opentherm/number/opentherm_number.cpp | 2 +- esphome/components/opentherm/opentherm.cpp | 2 +- .../opentherm/output/opentherm_output.cpp | 2 +- .../opentherm/switch/opentherm_switch.cpp | 2 +- esphome/components/openthread/openthread.cpp | 2 +- .../components/openthread/openthread_esp.cpp | 2 +- .../openthread/openthread_zephyr.cpp | 2 +- .../openthread_info_sensor.cpp | 2 +- .../openthread_info_text_sensor.cpp | 2 +- esphome/components/opt3001/opt3001.cpp | 2 +- .../ota/ota_backend_arduino_libretiny.cpp | 2 +- .../ota/ota_backend_arduino_rp2.cpp | 2 +- .../components/ota/ota_backend_esp8266.cpp | 2 +- .../components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_host.cpp | 2 +- .../components/ota/ota_bootloader_esp_idf.cpp | 2 +- .../components/ota/ota_partitions_esp_idf.cpp | 2 +- .../components/ota/ota_signature_esp_idf.cpp | 2 +- .../output/button/output_button.cpp | 2 +- esphome/components/output/float_output.cpp | 2 +- .../components/output/lock/output_lock.cpp | 2 +- .../output/switch/output_switch.cpp | 2 +- .../packet_transport/packet_transport.cpp | 2 +- .../components/partition/light_partition.cpp | 2 +- esphome/components/pca6416a/pca6416a.cpp | 2 +- esphome/components/pca9554/pca9554.cpp | 2 +- esphome/components/pca9685/pca9685_output.cpp | 2 +- esphome/components/pcd8544/pcd_8544.cpp | 2 +- esphome/components/pcf85063/pcf85063.cpp | 2 +- esphome/components/pcf8563/pcf8563.cpp | 2 +- esphome/components/pcf8574/pcf8574.cpp | 2 +- esphome/components/pcm5122/pcm5122.cpp | 2 +- esphome/components/pcm5122/pcm5122_gpio.cpp | 2 +- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 2 +- esphome/components/pid/pid_autotuner.cpp | 2 +- esphome/components/pid/pid_climate.cpp | 2 +- .../pid/sensor/pid_climate_sensor.cpp | 2 +- .../pipsolar/output/pipsolar_output.cpp | 2 +- esphome/components/pipsolar/pipsolar.cpp | 2 +- .../pipsolar/switch/pipsolar_switch.cpp | 2 +- esphome/components/pixoo/pixoo.cpp | 2 +- esphome/components/pm1006/pm1006.cpp | 2 +- esphome/components/pm2005/pm2005.cpp | 2 +- esphome/components/pmsa003i/pmsa003i.cpp | 2 +- esphome/components/pmsx003/pmsx003.cpp | 2 +- esphome/components/pmwcs3/pmwcs3.cpp | 2 +- esphome/components/pn532/pn532.cpp | 2 +- .../components/pn532/pn532_mifare_classic.cpp | 2 +- .../pn532/pn532_mifare_ultralight.cpp | 2 +- esphome/components/pn532_i2c/pn532_i2c.cpp | 2 +- esphome/components/pn532_spi/pn532_spi.cpp | 2 +- esphome/components/pn7150/pn7150.cpp | 2 +- esphome/components/pn7150_i2c/pn7150_i2c.cpp | 2 +- esphome/components/pn7160/pn7160.cpp | 2 +- esphome/components/pn7160_i2c/pn7160_i2c.cpp | 2 +- esphome/components/pn7160_spi/pn7160_spi.cpp | 2 +- esphome/components/pn71xx/pn71xx.cpp | 2 +- .../pn71xx/pn71xx_mifare_classic.cpp | 2 +- .../pn71xx/pn71xx_mifare_ultralight.cpp | 2 +- .../components/power_supply/power_supply.cpp | 2 +- .../components/provisioning/provisioning.cpp | 2 +- esphome/components/psram/psram.cpp | 2 +- .../pulse_counter/pulse_counter_sensor.cpp | 2 +- .../pulse_meter/pulse_meter_sensor.cpp | 2 +- .../components/pulse_width/pulse_width.cpp | 2 +- .../display/pvvx_display.cpp | 2 +- .../pvvx_mithermometer/pvvx_mithermometer.cpp | 2 +- esphome/components/pylontech/pylontech.cpp | 2 +- .../pylontech/sensor/pylontech_sensor.cpp | 2 +- .../text_sensor/pylontech_text_sensor.cpp | 2 +- esphome/components/pzem004t/pzem004t.cpp | 2 +- esphome/components/pzem6l24/pzem6l24.cpp | 2 +- esphome/components/pzemac/pzemac.cpp | 2 +- esphome/components/pzemdc/pzemdc.cpp | 2 +- esphome/components/qmc5883l/qmc5883l.cpp | 2 +- esphome/components/qmi8658/qmi8658.cpp | 2 +- esphome/components/qmp6988/qmp6988.cpp | 2 +- esphome/components/qr_code/qr_code.cpp | 2 +- esphome/components/qwiic_pir/qwiic_pir.cpp | 2 +- .../radio_frequency/radio_frequency.cpp | 2 +- .../radon_eye_ble/radon_eye_listener.cpp | 2 +- .../radon_eye_rd200/radon_eye_rd200.cpp | 2 +- esphome/components/rc522/rc522.cpp | 2 +- esphome/components/rc522_i2c/rc522_i2c.cpp | 2 +- esphome/components/rc522_spi/rc522_spi.cpp | 2 +- esphome/components/rd03d/rd03d.cpp | 2 +- esphome/components/rdm6300/rdm6300.cpp | 2 +- .../remote_base/abbwelcome_protocol.cpp | 2 +- .../components/remote_base/aeha_protocol.cpp | 2 +- .../components/remote_base/beo4_protocol.cpp | 2 +- .../remote_base/brennenstuhl_protocol.cpp | 2 +- .../remote_base/byronsx_protocol.cpp | 2 +- .../remote_base/canalsat_protocol.cpp | 4 +- .../remote_base/coolix_protocol.cpp | 2 +- .../components/remote_base/dish_protocol.cpp | 2 +- .../components/remote_base/dooya_protocol.cpp | 2 +- .../remote_base/drayton_protocol.cpp | 2 +- .../components/remote_base/dyson_protocol.cpp | 2 +- .../components/remote_base/gobox_protocol.cpp | 2 +- .../components/remote_base/haier_protocol.cpp | 2 +- .../remote_base/hob2hood_protocol.cpp | 2 +- .../components/remote_base/jvc_protocol.cpp | 2 +- .../remote_base/keeloq_protocol.cpp | 2 +- .../components/remote_base/lg_protocol.cpp | 2 +- .../remote_base/magiquest_protocol.cpp | 2 +- .../components/remote_base/midea_protocol.cpp | 2 +- .../remote_base/mirage_protocol.cpp | 2 +- .../components/remote_base/nec_protocol.cpp | 2 +- .../components/remote_base/nexa_protocol.cpp | 2 +- .../remote_base/panasonic_protocol.cpp | 2 +- .../remote_base/pioneer_protocol.cpp | 2 +- .../remote_base/pronto_protocol.cpp | 2 +- .../components/remote_base/raw_protocol.cpp | 2 +- .../components/remote_base/rc5_protocol.cpp | 2 +- .../components/remote_base/rc6_protocol.cpp | 2 +- .../remote_base/rc_switch_protocol.cpp | 2 +- .../components/remote_base/remote_base.cpp | 2 +- .../remote_base/roomba_protocol.cpp | 2 +- .../remote_base/samsung36_protocol.cpp | 2 +- .../remote_base/samsung_protocol.cpp | 2 +- .../components/remote_base/sony_protocol.cpp | 2 +- .../remote_base/symphony_protocol.cpp | 2 +- .../remote_base/toshiba_ac_protocol.cpp | 2 +- .../components/remote_base/toto_protocol.cpp | 2 +- .../remote_receiver/remote_receiver.cpp | 2 +- .../remote_receiver/remote_receiver_rmt.cpp | 2 +- .../remote_transmitter/remote_transmitter.cpp | 2 +- .../remote_transmitter_bk72xx.cpp | 2 +- .../remote_transmitter_libretiny_isr.cpp | 2 +- .../remote_transmitter_rmt.cpp | 2 +- .../remote_transmitter_rtl87xx.cpp | 2 +- .../microphone/resampler_microphone.cpp | 2 +- .../resampler/speaker/resampler_speaker.cpp | 2 +- .../resistance/resistance_sensor.cpp | 2 +- .../restart/button/restart_button.cpp | 2 +- .../restart/switch/restart_switch.cpp | 2 +- esphome/components/rf_bridge/rf_bridge.cpp | 2 +- .../components/ring_buffer/ring_buffer.cpp | 2 +- .../rotary_encoder/rotary_encoder.cpp | 2 +- .../router/speaker/router_speaker.cpp | 2 +- esphome/components/rp2/crash_handler.cpp | 2 +- esphome/components/rp2/gpio.cpp | 2 +- esphome/components/rp2/preferences.cpp | 2 +- esphome/components/rp2040_ble/rp2040_ble.cpp | 2 +- .../rp2040_pio_led_strip/led_strip.cpp | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.cpp | 2 +- .../rp2_ble_tracker/rp2_ble_tracker.cpp | 2 +- esphome/components/rtttl/rtttl.cpp | 2 +- .../components/runtime_image/bmp_decoder.cpp | 2 +- .../runtime_image/image_decoder.cpp | 2 +- .../components/runtime_image/jpeg_decoder.cpp | 2 +- .../components/runtime_image/png_decoder.cpp | 2 +- .../components/runtime_image/qoi_decoder.cpp | 2 +- .../runtime_image/runtime_image.cpp | 2 +- esphome/components/ruuvi_ble/ruuvi_ble.cpp | 2 +- esphome/components/ruuvitag/ruuvitag.cpp | 2 +- esphome/components/rx8025t/rx8025t.cpp | 2 +- esphome/components/rx8130/rx8130.cpp | 2 +- .../safe_mode/button/safe_mode_button.cpp | 2 +- esphome/components/safe_mode/safe_mode.cpp | 2 +- .../safe_mode/switch/safe_mode_switch.cpp | 2 +- esphome/components/scd30/scd30.cpp | 2 +- esphome/components/scd4x/scd4x.cpp | 2 +- esphome/components/script/script.cpp | 2 +- esphome/components/sdm_meter/sdm_meter.cpp | 2 +- esphome/components/sdp3x/sdp3x.cpp | 2 +- esphome/components/sds011/sds011.cpp | 2 +- .../seeed_mr24hpc1/seeed_mr24hpc1.cpp | 2 +- .../seeed_mr60bha2/seeed_mr60bha2.cpp | 2 +- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 2 +- .../components/selec_meter/selec_meter.cpp | 2 +- esphome/components/select/select.cpp | 2 +- esphome/components/select/select_call.cpp | 2 +- esphome/components/sen0321/sen0321.cpp | 2 +- esphome/components/sen21231/sen21231.cpp | 2 +- esphome/components/sen5x/sen5x.cpp | 2 +- esphome/components/sen6x/sen6x.cpp | 2 +- .../sendspin/image/sendspin_image.cpp | 2 +- .../media_player/sendspin_media_player.cpp | 2 +- .../media_source/sendspin_media_source.cpp | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 2 +- .../sendspin/sensor/sendspin_sensor.cpp | 2 +- .../sendspin/switch/sendspin_switch.cpp | 2 +- .../text_sensor/sendspin_text_sensor.cpp | 2 +- esphome/components/senseair/senseair.cpp | 2 +- .../sensirion_common/i2c_sensirion.cpp | 2 +- esphome/components/sensor/filter.cpp | 2 +- esphome/components/sensor/sensor.cpp | 2 +- .../components/serial_proxy/serial_proxy.cpp | 2 +- esphome/components/servo/servo.cpp | 2 +- esphome/components/sfa30/sfa30.cpp | 2 +- esphome/components/sfa40/sfa40.cpp | 2 +- esphome/components/sgp30/sgp30.cpp | 2 +- esphome/components/sgp4x/sgp4x.cpp | 2 +- .../shelly_dimmer/shelly_dimmer.cpp | 2 +- .../components/shelly_dimmer/stm32flash.cpp | 2 +- esphome/components/sht3xd/sht3xd.cpp | 2 +- esphome/components/sht4x/sht4x.cpp | 2 +- esphome/components/shtcx/shtcx.cpp | 2 +- .../shutdown/button/shutdown_button.cpp | 2 +- .../shutdown/switch/shutdown_switch.cpp | 2 +- .../sigma_delta_output/sigma_delta_output.cpp | 2 +- esphome/components/sim800l/sim800l.cpp | 2 +- .../components/slow_pwm/slow_pwm_output.cpp | 2 +- .../components/sm10bit_base/sm10bit_base.cpp | 2 +- esphome/components/sm16716/sm16716.cpp | 2 +- esphome/components/sm2135/sm2135.cpp | 2 +- esphome/components/sm2235/sm2235.cpp | 2 +- esphome/components/sm2335/sm2335.cpp | 2 +- esphome/components/sm300d2/sm300d2.cpp | 2 +- esphome/components/sml/sensor/sml_sensor.cpp | 2 +- esphome/components/sml/sml.cpp | 2 +- .../sml/text_sensor/sml_text_sensor.cpp | 2 +- esphome/components/smt100/smt100.cpp | 2 +- esphome/components/sn74hc165/sn74hc165.cpp | 2 +- esphome/components/sn74hc595/sn74hc595.cpp | 2 +- .../snapshot/display/snapshot_display.cpp | 2 +- esphome/components/snapshot/snapshot.cpp | 2 +- esphome/components/sntp/sntp_component.cpp | 2 +- esphome/components/socket/ipv4_resolve.cpp | 2 +- .../components/socket/lwip_raw_tcp_impl.cpp | 2 +- esphome/components/sonoff_d1/sonoff_d1.cpp | 2 +- .../components/sound_level/sound_level.cpp | 2 +- esphome/components/spa06_base/spa06_base.cpp | 2 +- esphome/components/spa06_i2c/spa06_i2c.cpp | 2 +- esphome/components/spa06_spi/spa06_spi.cpp | 2 +- .../speaker/media_player/audio_pipeline.cpp | 2 +- .../media_player/speaker_media_player.cpp | 2 +- .../speaker_source_media_player.cpp | 2 +- esphome/components/speed/fan/speed_fan.cpp | 2 +- esphome/components/spi/spi.cpp | 2 +- esphome/components/spi/spi_arduino.cpp | 2 +- esphome/components/spi/spi_esp_idf.cpp | 2 +- esphome/components/spi_device/spi_device.cpp | 2 +- .../components/split_buffer/split_buffer.cpp | 2 +- esphome/components/sprinkler/sprinkler.cpp | 2 +- esphome/components/sps30/sps30.cpp | 2 +- .../components/ssd1306_base/ssd1306_base.cpp | 2 +- .../components/ssd1306_i2c/ssd1306_i2c.cpp | 2 +- .../components/ssd1306_spi/ssd1306_spi.cpp | 2 +- .../components/ssd1322_base/ssd1322_base.cpp | 2 +- .../components/ssd1322_spi/ssd1322_spi.cpp | 2 +- .../components/ssd1325_base/ssd1325_base.cpp | 2 +- .../components/ssd1325_spi/ssd1325_spi.cpp | 2 +- .../components/ssd1327_base/ssd1327_base.cpp | 2 +- .../components/ssd1327_i2c/ssd1327_i2c.cpp | 2 +- .../components/ssd1327_spi/ssd1327_spi.cpp | 2 +- .../components/ssd1331_base/ssd1331_base.cpp | 2 +- .../components/ssd1331_spi/ssd1331_spi.cpp | 2 +- .../components/ssd1351_base/ssd1351_base.cpp | 2 +- .../components/ssd1351_spi/ssd1351_spi.cpp | 2 +- .../st7123/touchscreen/st7123_touchscreen.cpp | 2 +- .../components/st7567_base/st7567_base.cpp | 2 +- esphome/components/st7567_i2c/st7567_i2c.cpp | 2 +- esphome/components/st7567_spi/st7567_spi.cpp | 2 +- esphome/components/st7920/st7920.cpp | 2 +- esphome/components/statsd/statsd.cpp | 2 +- .../status/status_binary_sensor.cpp | 2 +- .../status_led/light/status_led_light.cpp | 2 +- esphome/components/status_led/status_led.cpp | 2 +- esphome/components/stcc4/stcc4.cpp | 2 +- esphome/components/stepper/stepper.cpp | 2 +- esphome/components/sts3x/sts3x.cpp | 2 +- esphome/components/stts22h/stts22h.cpp | 2 +- esphome/components/sun/sensor/sun_sensor.cpp | 2 +- esphome/components/sun/sun.cpp | 2 +- .../sun/text_sensor/sun_text_sensor.cpp | 2 +- esphome/components/sun_gtil2/sun_gtil2.cpp | 2 +- .../binary_sensor/switch_binary_sensor.cpp | 2 +- esphome/components/switch/switch.cpp | 2 +- .../packet_transport/sx126x_transport.cpp | 2 +- esphome/components/sx126x/sx126x.cpp | 2 +- .../packet_transport/sx127x_transport.cpp | 2 +- esphome/components/sx127x/sx127x.cpp | 2 +- .../sx1509/output/sx1509_float_output.cpp | 2 +- esphome/components/sx1509/sx1509.cpp | 2 +- esphome/components/sx1509/sx1509_gpio_pin.cpp | 2 +- esphome/components/sy6970/sy6970.cpp | 2 +- .../systa_bus/sensor/systa_bus_sensor.cpp | 2 +- esphome/components/systa_bus/systa_bus.cpp | 2 +- esphome/components/t6615/t6615.cpp | 2 +- esphome/components/tas2780/tas2780.cpp | 2 +- esphome/components/tas58xx/tas58xx.cpp | 2 +- esphome/components/tc74/tc74.cpp | 2 +- esphome/components/tca9548a/tca9548a.cpp | 2 +- esphome/components/tca9555/tca9555.cpp | 2 +- esphome/components/tcl112/tcl112.cpp | 2 +- esphome/components/tcp_uart/tcp_uart.cpp | 2 +- esphome/components/tcs34725/tcs34725.cpp | 2 +- esphome/components/tee501/tee501.cpp | 2 +- .../teleinfo/sensor/teleinfo_sensor.cpp | 2 +- esphome/components/teleinfo/teleinfo.cpp | 2 +- .../text_sensor/teleinfo_text_sensor.cpp | 2 +- esphome/components/tem3200/tem3200.cpp | 2 +- .../template_alarm_control_panel.cpp | 2 +- .../binary_sensor/template_binary_sensor.cpp | 2 +- .../template/climate/template_climate.cpp | 2 +- .../template/cover/template_cover.cpp | 2 +- .../template/datetime/template_date.cpp | 2 +- .../template/datetime/template_datetime.cpp | 2 +- .../template/datetime/template_time.cpp | 2 +- .../components/template/fan/template_fan.cpp | 2 +- .../template/lock/template_lock.cpp | 2 +- .../template/number/template_number.cpp | 2 +- .../template/select/template_select.cpp | 2 +- .../template/sensor/template_sensor.cpp | 2 +- .../template/switch/template_switch.cpp | 2 +- .../template/text/template_text.cpp | 2 +- .../text_sensor/template_text_sensor.cpp | 2 +- .../time/template_real_time_clock.cpp | 2 +- .../template/valve/template_valve.cpp | 2 +- .../water_heater/template_water_heater.cpp | 2 +- esphome/components/text/text.cpp | 2 +- esphome/components/text/text_call.cpp | 2 +- .../text/text_sensor/text_text_sensor.cpp | 2 +- esphome/components/text_sensor/filter.cpp | 2 +- .../components/text_sensor/text_sensor.cpp | 2 +- esphome/components/tfluna/tfluna.cpp | 2 +- .../thermopro_ble/thermopro_ble.cpp | 2 +- .../thermostat/thermostat_climate.cpp | 2 +- esphome/components/time/automation.cpp | 2 +- esphome/components/time/real_time_clock.cpp | 2 +- .../time_based/cover/time_based_cover.cpp | 2 +- .../components/tinyusb/tinyusb_component.cpp | 2 +- .../components/tlc59208f/tlc59208f_output.cpp | 2 +- esphome/components/tlc5947/tlc5947.cpp | 2 +- esphome/components/tlc5971/tlc5971.cpp | 2 +- esphome/components/tm1621/tm1621.cpp | 2 +- esphome/components/tm1637/tm1637.cpp | 2 +- .../tm1638/output/tm1638_output_led.cpp | 2 +- .../tm1638/switch/tm1638_switch_led.cpp | 2 +- esphome/components/tm1638/tm1638.cpp | 2 +- esphome/components/tm1651/tm1651.cpp | 2 +- esphome/components/tmp102/tmp102.cpp | 2 +- esphome/components/tmp1075/tmp1075.cpp | 2 +- esphome/components/tmp117/tmp117.cpp | 2 +- .../components/tof10120/tof10120_sensor.cpp | 2 +- .../components/tormatic/tormatic_cover.cpp | 2 +- esphome/components/toshiba/toshiba.cpp | 2 +- .../total_daily_energy/total_daily_energy.cpp | 2 +- .../components/touchscreen/touchscreen.cpp | 2 +- esphome/components/tsl2561/tsl2561.cpp | 2 +- esphome/components/tsl2591/tsl2591.cpp | 2 +- .../tt21100/binary_sensor/tt21100_button.cpp | 2 +- .../tt21100/touchscreen/tt21100.cpp | 2 +- esphome/components/ttp229_bsf/ttp229_bsf.cpp | 2 +- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 2 +- esphome/components/tuya/automation.cpp | 2 +- .../tuya/binary_sensor/tuya_binary_sensor.cpp | 2 +- .../components/tuya/climate/tuya_climate.cpp | 2 +- esphome/components/tuya/cover/tuya_cover.cpp | 2 +- esphome/components/tuya/fan/tuya_fan.cpp | 2 +- esphome/components/tuya/light/tuya_light.cpp | 2 +- .../components/tuya/number/tuya_number.cpp | 2 +- .../components/tuya/select/tuya_select.cpp | 2 +- .../components/tuya/sensor/tuya_sensor.cpp | 2 +- .../components/tuya/switch/tuya_switch.cpp | 2 +- .../tuya/text_sensor/tuya_text_sensor.cpp | 2 +- esphome/components/tuya/tuya.cpp | 2 +- .../tuya/water_heater/tuya_water_heater.cpp | 2 +- esphome/components/tx20/tx20.cpp | 2 +- .../components/uart/button/uart_button.cpp | 2 +- esphome/components/uart/event/uart_event.cpp | 2 +- .../uart/packet_transport/uart_transport.cpp | 2 +- .../components/uart/switch/uart_switch.cpp | 2 +- esphome/components/uart/uart.cpp | 2 +- esphome/components/uart/uart_component.cpp | 2 +- .../uart/uart_component_esp8266.cpp | 2 +- .../uart/uart_component_esp_idf.cpp | 2 +- .../components/uart/uart_component_host.cpp | 2 +- .../uart/uart_component_libretiny.cpp | 2 +- .../components/uart/uart_component_rp2.cpp | 2 +- esphome/components/uart/uart_debugger.cpp | 2 +- esphome/components/uart_mux/uart_mux.cpp | 2 +- esphome/components/uart_tcp/uart_tcp.cpp | 2 +- .../udp/packet_transport/udp_transport.cpp | 2 +- esphome/components/udp/udp_component.cpp | 2 +- esphome/components/ufire_ec/ufire_ec.cpp | 2 +- esphome/components/ufire_ise/ufire_ise.cpp | 2 +- esphome/components/ufm01/ufm01.cpp | 2 +- esphome/components/uln2003/uln2003.cpp | 2 +- .../ultrasonic/ultrasonic_sensor.cpp | 2 +- esphome/components/update/update_entity.cpp | 2 +- .../climate/uponor_smatrix_climate.cpp | 2 +- .../sensor/uponor_smatrix_sensor.cpp | 2 +- .../uponor_smatrix/uponor_smatrix.cpp | 2 +- .../uptime/sensor/uptime_seconds_sensor.cpp | 2 +- .../uptime/sensor/uptime_timestamp_sensor.cpp | 2 +- .../uptime/text_sensor/uptime_text_sensor.cpp | 2 +- .../components/usb_cdc_acm/usb_cdc_acm.cpp | 2 +- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 2 +- esphome/components/valve/valve.cpp | 2 +- .../vbus/binary_sensor/vbus_binary_sensor.cpp | 2 +- .../components/vbus/sensor/vbus_sensor.cpp | 2 +- esphome/components/vbus/vbus.cpp | 2 +- esphome/components/veml3235/veml3235.cpp | 2 +- esphome/components/veml7700/veml7700.cpp | 2 +- .../version/version_text_sensor.cpp | 2 +- esphome/components/vl53l0x/vl53l0x_sensor.cpp | 2 +- .../voice_assistant/voice_assistant.cpp | 2 +- .../components/wake_on_lan/wake_on_lan.cpp | 2 +- esphome/components/watchdog/watchdog.cpp | 2 +- .../components/water_heater/water_heater.cpp | 2 +- .../output/waveshare_io_ch32v003_output.cpp | 2 +- .../sensor/waveshare_io_ch32v003_sensor.cpp | 2 +- .../waveshare_io_ch32v003.cpp | 2 +- .../web_server/ota/ota_web_server.cpp | 2 +- esphome/components/web_server/web_server.cpp | 2 +- .../components/web_server_idf/multipart.cpp | 2 +- .../web_server_idf/web_server_idf.cpp | 2 +- esphome/components/weikai/weikai.cpp | 2 +- esphome/components/weikai_i2c/weikai_i2c.cpp | 2 +- esphome/components/weikai_spi/weikai_spi.cpp | 2 +- esphome/components/whirlpool/whirlpool.cpp | 2 +- esphome/components/whynter/whynter.cpp | 2 +- esphome/components/wiegand/wiegand.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 2 +- .../wifi/wifi_component_esp8266.cpp | 2 +- .../wifi/wifi_component_esp_idf.cpp | 2 +- .../wifi/wifi_component_libretiny.cpp | 2 +- .../components/wifi/wifi_component_pico_w.cpp | 2 +- .../wifi_info/wifi_info_text_sensor.cpp | 2 +- .../wifi_signal/wifi_signal_sensor.cpp | 2 +- esphome/components/wireguard/wireguard.cpp | 2 +- esphome/components/wl_134/wl_134.cpp | 2 +- esphome/components/wled/wled_light_effect.cpp | 2 +- esphome/components/wts01/wts01.cpp | 2 +- esphome/components/x9c/x9c.cpp | 2 +- esphome/components/xdb401/xdb401.cpp | 2 +- esphome/components/xgzp68xx/xgzp68xx.cpp | 2 +- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 2 +- .../xiaomi_body_scale/xiaomi_body_scale.cpp | 2 +- .../components/xiaomi_cgd1/xiaomi_cgd1.cpp | 2 +- .../components/xiaomi_cgdk2/xiaomi_cgdk2.cpp | 2 +- .../components/xiaomi_cgg1/xiaomi_cgg1.cpp | 2 +- .../components/xiaomi_cgpr1/xiaomi_cgpr1.cpp | 2 +- .../xiaomi_gcls002/xiaomi_gcls002.cpp | 2 +- .../xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp | 2 +- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp | 2 +- .../xiaomi_hhccpot002/xiaomi_hhccpot002.cpp | 2 +- .../xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp | 2 +- .../xiaomi_lywsd02/xiaomi_lywsd02.cpp | 2 +- .../xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp | 2 +- .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp | 2 +- .../xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp | 2 +- .../xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp | 2 +- .../xiaomi_mhoc303/xiaomi_mhoc303.cpp | 2 +- .../xiaomi_mhoc401/xiaomi_mhoc401.cpp | 2 +- .../xiaomi_miscale/xiaomi_miscale.cpp | 2 +- .../xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp | 2 +- .../xiaomi_mue4094rt/xiaomi_mue4094rt.cpp | 2 +- .../xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp | 2 +- .../xiaomi_wx08zm/xiaomi_wx08zm.cpp | 2 +- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 2 +- esphome/components/xl9535/xl9535.cpp | 2 +- .../xpt2046/touchscreen/xpt2046.cpp | 2 +- esphome/components/yashima/yashima.cpp | 2 +- esphome/components/zephyr/gpio.cpp | 2 +- esphome/components/zephyr/preferences.cpp | 2 +- esphome/components/zephyr/reset_reason.cpp | 2 +- .../zephyr_ble_server/ble_server.cpp | 2 +- .../zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp | 2 +- esphome/components/zephyr_pwm/zephyr_pwm.cpp | 2 +- esphome/components/zhlt01/zhlt01.cpp | 2 +- .../zigbee/time/zigbee_time_esp32.cpp | 2 +- .../zigbee/time/zigbee_time_zephyr.cpp | 2 +- .../zigbee/zigbee_attribute_esp32.cpp | 2 +- .../zigbee/zigbee_binary_sensor_zephyr.cpp | 2 +- esphome/components/zigbee/zigbee_esp32.cpp | 2 +- .../zigbee/zigbee_number_zephyr.cpp | 2 +- .../zigbee/zigbee_sensor_zephyr.cpp | 2 +- .../zigbee/zigbee_switch_zephyr.cpp | 2 +- esphome/components/zigbee/zigbee_zephyr.cpp | 2 +- .../zio_ultrasonic/zio_ultrasonic.cpp | 2 +- .../components/zwave_proxy/zwave_proxy.cpp | 2 +- esphome/components/zyaura/zyaura.cpp | 2 +- script/api_protobuf/api_protobuf.py | 2 +- script/build_language_schema.py | 5 +- script/ci-custom.py | 39 +++++++++++++ tests/script/test_ci_custom.py | 56 +++++++++++++++++++ 1080 files changed, 1178 insertions(+), 1080 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 6111af2b7e5..8c97169cbf1 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -6,7 +6,7 @@ namespace esphome::a01nyub { -static const char *const TAG = "a01nyub.sensor"; +ESPHOME_LOG_TAG(TAG, "a01nyub.sensor"); void A01nyubComponent::loop() { uint8_t data; diff --git a/esphome/components/a02yyuw/a02yyuw.cpp b/esphome/components/a02yyuw/a02yyuw.cpp index 2832334ef19..20e8f6c7b56 100644 --- a/esphome/components/a02yyuw/a02yyuw.cpp +++ b/esphome/components/a02yyuw/a02yyuw.cpp @@ -6,7 +6,7 @@ namespace esphome::a02yyuw { -static const char *const TAG = "a02yyuw.sensor"; +ESPHOME_LOG_TAG(TAG, "a02yyuw.sensor"); void A02yyuwComponent::loop() { uint8_t data; diff --git a/esphome/components/a4988/a4988.cpp b/esphome/components/a4988/a4988.cpp index d8fc6752f37..4af9e8baef7 100644 --- a/esphome/components/a4988/a4988.cpp +++ b/esphome/components/a4988/a4988.cpp @@ -3,7 +3,7 @@ namespace esphome::a4988 { -static const char *const TAG = "a4988.stepper"; +ESPHOME_LOG_TAG(TAG, "a4988.stepper"); void A4988::setup() { if (this->sleep_pin_ != nullptr) { diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index 3137a59fad6..36b6534c0de 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -3,7 +3,7 @@ namespace esphome::absolute_humidity { -static const char *const TAG{"absolute_humidity.sensor"}; +ESPHOME_LOG_TAG(TAG, "absolute_humidity.sensor"); void AbsoluteHumidityComponent::setup() { this->temperature_sensor_->add_on_state_callback([this](float state) { diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index 477962a0404..e126a3d52ab 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -14,7 +14,7 @@ namespace esphome::ac_dimmer { -static const char *const TAG = "ac_dimmer"; +ESPHOME_LOG_TAG(TAG, "ac_dimmer"); // Global array to store dimmer objects static AcDimmerDataStore *all_dimmers[32]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp b/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp index 543b4760851..fd26e6bccd3 100644 --- a/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp +++ b/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp @@ -9,7 +9,7 @@ #include "esp_clk_tree.h" #include "soc/clk_tree_defs.h" -static const char *const TAG = "hw_timer_esp_idf"; +ESPHOME_LOG_TAG(TAG, "hw_timer_esp_idf"); namespace esphome::ac_dimmer { diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp index bf6849acaf7..e3510d380eb 100644 --- a/esphome/components/adalight/adalight_light_effect.cpp +++ b/esphome/components/adalight/adalight_light_effect.cpp @@ -3,7 +3,7 @@ namespace esphome::adalight { -static const char *const TAG = "adalight_light_effect"; +ESPHOME_LOG_TAG(TAG, "adalight_light_effect"); static const uint32_t ADALIGHT_ACK_INTERVAL = 1000; static const uint32_t ADALIGHT_RECEIVE_TIMEOUT = 1000; diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 70211000c3d..dcb41382015 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -3,7 +3,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); const LogString *sampling_mode_to_str(SamplingMode mode) { switch (mode) { diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index c9887cea7cd..6bab6fc4697 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index 77a192e0254..db231f75984 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC) namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index dfa545b3951..2ea9da935fe 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index ce665e85015..5756ac038aa 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index bf45059740f..c2f4aedd497 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -7,7 +7,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); void ADCSensor::setup() { if (!adc_is_ready_dt(this->channel_)) { diff --git a/esphome/components/adc128s102/adc128s102.cpp b/esphome/components/adc128s102/adc128s102.cpp index ef0db4730a1..bbe95d03bec 100644 --- a/esphome/components/adc128s102/adc128s102.cpp +++ b/esphome/components/adc128s102/adc128s102.cpp @@ -3,7 +3,7 @@ namespace esphome::adc128s102 { -static const char *const TAG = "adc128s102"; +ESPHOME_LOG_TAG(TAG, "adc128s102"); float ADC128S102::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp b/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp index 01b6a3f2b83..3cc3f0c0561 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::adc128s102 { -static const char *const TAG = "adc128s102.sensor"; +ESPHOME_LOG_TAG(TAG, "adc128s102.sensor"); ADC128S102Sensor::ADC128S102Sensor(uint8_t channel) : channel_(channel) {} diff --git a/esphome/components/addressable_light/addressable_light_display.cpp b/esphome/components/addressable_light/addressable_light_display.cpp index 4cbcb3324be..6a3df025e62 100644 --- a/esphome/components/addressable_light/addressable_light_display.cpp +++ b/esphome/components/addressable_light/addressable_light_display.cpp @@ -3,7 +3,7 @@ namespace esphome::addressable_light { -static const char *const TAG = "addressable_light.display"; +ESPHOME_LOG_TAG(TAG, "addressable_light.display"); int AddressableLightDisplay::get_width_internal() { return this->width_; } int AddressableLightDisplay::get_height_internal() { return this->height_; } diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 0f4189ad90c..e131980ebb0 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -15,7 +15,7 @@ namespace esphome::ade7880 { -static const char *const TAG = "ade7880"; +ESPHOME_LOG_TAG(TAG, "ade7880"); void IRAM_ATTR ADE7880Store::gpio_intr(ADE7880Store *arg) { arg->reset_done = true; } diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 1adf44f8f72..21a16c5081a 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -5,7 +5,7 @@ namespace esphome::ade7953_base { -static const char *const TAG = "ade7953"; +ESPHOME_LOG_TAG(TAG, "ade7953"); constexpr uint16_t CONFIG_DEFAULT = 0x8004u; constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u; diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.cpp b/esphome/components/ade7953_i2c/ade7953_i2c.cpp index 252e55ee5c1..cc0fb7e80d8 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.cpp +++ b/esphome/components/ade7953_i2c/ade7953_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::ade7953_i2c { -static const char *const TAG = "ade7953"; +ESPHOME_LOG_TAG(TAG, "ade7953"); void AdE7953I2c::dump_config() { ESP_LOGCONFIG(TAG, "ADE7953_i2c:"); diff --git a/esphome/components/ade7953_spi/ade7953_spi.cpp b/esphome/components/ade7953_spi/ade7953_spi.cpp index c2d85231d6b..e7c7bac97fc 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.cpp +++ b/esphome/components/ade7953_spi/ade7953_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ade7953_spi { -static const char *const TAG = "ade7953"; +ESPHOME_LOG_TAG(TAG, "ade7953"); // Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2; diff --git a/esphome/components/ads1115/ads1115.cpp b/esphome/components/ads1115/ads1115.cpp index 58704bf7c68..b75e1b73046 100644 --- a/esphome/components/ads1115/ads1115.cpp +++ b/esphome/components/ads1115/ads1115.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1115 { -static const char *const TAG = "ads1115"; +ESPHOME_LOG_TAG(TAG, "ads1115"); static const uint8_t ADS1115_REGISTER_CONVERSION = 0x00; static const uint8_t ADS1115_REGISTER_CONFIG = 0x01; diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.cpp b/esphome/components/ads1115/sensor/ads1115_sensor.cpp index 8086d972310..776346171b7 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.cpp +++ b/esphome/components/ads1115/sensor/ads1115_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1115 { -static const char *const TAG = "ads1115.sensor"; +ESPHOME_LOG_TAG(TAG, "ads1115.sensor"); float ADS1115Sensor::sample() { return this->parent_->request_measurement(this->multiplexer_, this->gain_, this->resolution_, this->samplerate_); diff --git a/esphome/components/ads1118/ads1118.cpp b/esphome/components/ads1118/ads1118.cpp index 0a07193bfe0..c21dc2d3f06 100644 --- a/esphome/components/ads1118/ads1118.cpp +++ b/esphome/components/ads1118/ads1118.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1118 { -static const char *const TAG = "ads1118"; +ESPHOME_LOG_TAG(TAG, "ads1118"); static const uint8_t ADS1118_DATA_RATE_860_SPS = 0b111; void ADS1118::setup() { diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.cpp b/esphome/components/ads1118/sensor/ads1118_sensor.cpp index 383a3d25fcb..2b3d68d1f40 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.cpp +++ b/esphome/components/ads1118/sensor/ads1118_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1118 { -static const char *const TAG = "ads1118.sensor"; +ESPHOME_LOG_TAG(TAG, "ads1118.sensor"); void ADS1118Sensor::dump_config() { LOG_SENSOR(" ", "ADS1118 Sensor", this); diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index 377677ccdb8..2db39a206d5 100644 --- a/esphome/components/ags10/ags10.cpp +++ b/esphome/components/ags10/ags10.cpp @@ -4,7 +4,7 @@ #include namespace esphome::ags10 { -static const char *const TAG = "ags10"; +ESPHOME_LOG_TAG(TAG, "ags10"); // Data acquisition. static const uint8_t REG_TVOC = 0x00; diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index cc90abfc3a6..83ab55e6576 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -19,7 +19,7 @@ namespace esphome::aht10 { -static const char *const TAG = "aht10"; +ESPHOME_LOG_TAG(TAG, "aht10"); static const uint8_t AHT10_INITIALIZE_CMD[] = {0xE1, 0x08, 0x00}; static const uint8_t AHT20_INITIALIZE_CMD[] = {0xBE, 0x08, 0x00}; static const uint8_t AHT10_MEASURE_CMD[] = {0xAC, 0x33, 0x00}; diff --git a/esphome/components/aic3204/aic3204.cpp b/esphome/components/aic3204/aic3204.cpp index 0ba960fd702..892a6c56ac0 100644 --- a/esphome/components/aic3204/aic3204.cpp +++ b/esphome/components/aic3204/aic3204.cpp @@ -6,7 +6,7 @@ namespace esphome::aic3204 { -static const char *const TAG = "aic3204"; +ESPHOME_LOG_TAG(TAG, "aic3204"); #define ERROR_CHECK(err, msg) \ if (!(err)) { \ diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index f2625a78325..5f0e2eb3ecf 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -4,7 +4,7 @@ namespace esphome::airthings_ble { -static const char *const TAG = "airthings_ble"; +ESPHOME_LOG_TAG(TAG, "airthings_ble"); bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) { for (auto &it : device.get_manufacturer_datas()) { diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp index 5fa59f22fd5..d67a2e4a0fa 100644 --- a/esphome/components/airthings_wave_base/airthings_wave_base.cpp +++ b/esphome/components/airthings_wave_base/airthings_wave_base.cpp @@ -8,7 +8,7 @@ namespace esphome::airthings_wave_base { -static const char *const TAG = "airthings_wave_base"; +ESPHOME_LOG_TAG(TAG, "airthings_wave_base"); void AirthingsWaveBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp index f487e9dbc08..54dbc21a6dc 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp @@ -4,7 +4,7 @@ namespace esphome::airthings_wave_mini { -static const char *const TAG = "airthings_wave_mini"; +ESPHOME_LOG_TAG(TAG, "airthings_wave_mini"); void AirthingsWaveMini::read_sensors(uint8_t *raw_value, uint16_t value_len) { auto *value = (WaveMiniReadings *) raw_value; diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp index 80fe081b572..4b23cb685ca 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp @@ -4,7 +4,7 @@ namespace esphome::airthings_wave_plus { -static const char *const TAG = "airthings_wave_plus"; +ESPHOME_LOG_TAG(TAG, "airthings_wave_plus"); void AirthingsWavePlus::read_sensors(uint8_t *raw_value, uint16_t value_len) { auto *value = (WavePlusReadings *) raw_value; diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index fc72c13ce30..6f23b1aca89 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -10,7 +10,7 @@ namespace esphome::alarm_control_panel { -static const char *const TAG = "alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "alarm_control_panel"); AlarmControlPanelCall AlarmControlPanel::make_call() { return AlarmControlPanelCall(this); } diff --git a/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp b/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp index 0c43cd555a3..667cae42a07 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp @@ -6,7 +6,7 @@ namespace esphome::alarm_control_panel { -static const char *const TAG = "alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "alarm_control_panel"); AlarmControlPanelCall::AlarmControlPanelCall(AlarmControlPanel *parent) : parent_(parent) {} diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index 92b00d87cb8..ce81ca4025f 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -7,7 +7,7 @@ namespace esphome::alpha3 { -static const char *const TAG = "alpha3"; +ESPHOME_LOG_TAG(TAG, "alpha3"); void Alpha3::dump_config() { ESP_LOGCONFIG(TAG, "ALPHA3"); diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index 8980a8dfc3a..d34061889be 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -26,7 +26,7 @@ namespace esphome::am2315c { -static const char *const TAG = "am2315c"; +ESPHOME_LOG_TAG(TAG, "am2315c"); bool AM2315C::reset_register_(uint8_t reg) { // code based on demo code sent by www.aosong.com diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 5445ab38981..94053ab5dc8 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -10,7 +10,7 @@ namespace esphome::am2320 { -static const char *const TAG = "am2320"; +ESPHOME_LOG_TAG(TAG, "am2320"); void AM2320Component::update() { uint8_t data[8]; diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp index 4b096983a44..6fb8c0c7eba 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::am43 { -static const char *const TAG = "am43_cover"; +ESPHOME_LOG_TAG(TAG, "am43_cover"); using namespace esphome::cover; diff --git a/esphome/components/am43/sensor/am43_sensor.cpp b/esphome/components/am43/sensor/am43_sensor.cpp index ddc3eadae9a..1a30af03aef 100644 --- a/esphome/components/am43/sensor/am43_sensor.cpp +++ b/esphome/components/am43/sensor/am43_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::am43 { -static const char *const TAG = "am43"; +ESPHOME_LOG_TAG(TAG, "am43"); void Am43::dump_config() { ESP_LOGCONFIG(TAG, "AM43"); diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp index d25c10021c0..c5061365a21 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::analog_threshold { -static const char *const TAG = "analog_threshold.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "analog_threshold.binary_sensor"); void AnalogThresholdBinarySensor::setup() { float sensor_value = this->sensor_->get_state(); diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index b0769bb622e..28e2da93e93 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -5,7 +5,7 @@ namespace esphome::anova { -static const char *const TAG = "anova"; +ESPHOME_LOG_TAG(TAG, "anova"); using namespace esphome::climate; diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index 57a502ca420..daf6bb6b338 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -7,7 +7,7 @@ namespace esphome::apds9306 { -static const char *const TAG = "apds9306"; +ESPHOME_LOG_TAG(TAG, "apds9306"); enum { // APDS9306 registers APDS9306_MAIN_CTRL = 0x00, diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index da8029b4ee0..ef5d43e2cba 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -4,7 +4,7 @@ namespace esphome::apds9960 { -static const char *const TAG = "apds9960"; +ESPHOME_LOG_TAG(TAG, "apds9960"); #define APDS9960_ERROR_CHECK(func) \ if (!(func)) { \ diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7d56b1ce583..42ef3726672 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -88,7 +88,7 @@ static_assert(sizeof(ESPHOME_VERSION) - 1 <= 32, "Update max_data_length for esp static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for name in api.proto"); static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); -static const char *const TAG = "api.connection"; +ESPHOME_LOG_TAG(TAG, "api.connection"); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN void log_dropped_message(const char *tag, int line, const LogString *what) { diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 41d1230aaa6..de50eb109fa 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -10,7 +10,7 @@ namespace esphome::api { -static const char *const TAG = "api.frame_helper"; +ESPHOME_LOG_TAG(TAG, "api.frame_helper"); // Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t API_MAX_LOG_BYTES = 168; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 21ef085c53f..47772fe590a 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -22,7 +22,7 @@ using noise::noise_err_to_logstr; static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE, "api and noise component handshake size limits must match"); -static const char *const TAG = "api.noise"; +ESPHOME_LOG_TAG(TAG, "api.noise"); static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index d4e3354fa00..dc00d8ec1e4 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -16,7 +16,7 @@ namespace esphome::api { -static const char *const TAG = "api.plaintext"; +ESPHOME_LOG_TAG(TAG, "api.plaintext"); // Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t API_MAX_LOG_BYTES = 168; diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index c86107cef0b..b38976e8cd5 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -15,7 +15,7 @@ namespace esphome::api { -static const char *const TAG = "api.outgoing"; +ESPHOME_LOG_TAG(TAG, "api.outgoing"); #ifndef API_OUTGOING_CONNECTION_HOST static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 1ef70f1fd4e..fee775daa0e 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -6,7 +6,7 @@ namespace esphome::api { -static const char *const TAG = "api.service"; +ESPHOME_LOG_TAG(TAG, "api.service"); #ifdef HAS_PROTO_MESSAGE_DUMP void APIServerConnectionBase::log_send_message_(const LogString *name, const char *dump) { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 33869dd976e..7dad4e390e0 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -23,7 +23,7 @@ namespace esphome::api { -static const char *const TAG = "api"; +ESPHOME_LOG_TAG(TAG, "api"); // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index 986017a138d..10a5cbc70cc 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -11,7 +11,7 @@ namespace esphome::api { -static const char *const TAG = "api.wizard"; +ESPHOME_LOG_TAG(TAG, "api.wizard"); #ifdef USE_API_WIZARD_INPUTS static bool wizard_entity_id_valid(const char *entity_id, size_t length) { diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 6738d5d8b71..e808e8a9ecf 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -6,7 +6,7 @@ namespace esphome::api { -static const char *const TAG = "api.proto"; +ESPHOME_LOG_TAG(TAG, "api.proto"); uint32_t ProtoSize::varint_slow(uint32_t value) { return varint_wide(value); } diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index e78f301b30d..d7fb120741b 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::aqi { -static const char *const TAG = "aqi"; +ESPHOME_LOG_TAG(TAG, "aqi"); void AQISensor::setup() { if (this->pm_2_5_sensor_ != nullptr) { diff --git a/esphome/components/as3935/as3935.cpp b/esphome/components/as3935/as3935.cpp index a1d74c78deb..00ac7ef365a 100644 --- a/esphome/components/as3935/as3935.cpp +++ b/esphome/components/as3935/as3935.cpp @@ -3,7 +3,7 @@ namespace esphome::as3935 { -static const char *const TAG = "as3935"; +ESPHOME_LOG_TAG(TAG, "as3935"); void AS3935Component::setup() { this->irq_pin_->setup(); diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp index b3d015114f0..06f816182ed 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.cpp +++ b/esphome/components/as3935_i2c/as3935_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::as3935_i2c { -static const char *const TAG = "as3935_i2c"; +ESPHOME_LOG_TAG(TAG, "as3935_i2c"); void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t start_pos) { uint8_t write_reg; diff --git a/esphome/components/as3935_spi/as3935_spi.cpp b/esphome/components/as3935_spi/as3935_spi.cpp index 026fde2f213..f9661263574 100644 --- a/esphome/components/as3935_spi/as3935_spi.cpp +++ b/esphome/components/as3935_spi/as3935_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::as3935_spi { -static const char *const TAG = "as3935_spi"; +ESPHOME_LOG_TAG(TAG, "as3935_spi"); void SPIAS3935Component::setup() { this->spi_setup(); diff --git a/esphome/components/as5600/as5600.cpp b/esphome/components/as5600/as5600.cpp index da1add5458b..fc768b383b0 100644 --- a/esphome/components/as5600/as5600.cpp +++ b/esphome/components/as5600/as5600.cpp @@ -3,7 +3,7 @@ namespace esphome::as5600 { -static const char *const TAG = "as5600"; +ESPHOME_LOG_TAG(TAG, "as5600"); // Configuration registers static const uint8_t REGISTER_ZMCO = 0x00; // 8 bytes / R diff --git a/esphome/components/as5600/sensor/as5600_sensor.cpp b/esphome/components/as5600/sensor/as5600_sensor.cpp index ba295659a13..88884805a0f 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.cpp +++ b/esphome/components/as5600/sensor/as5600_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::as5600 { -static const char *const TAG = "as5600.sensor"; +ESPHOME_LOG_TAG(TAG, "as5600.sensor"); // Configuration registers static const uint8_t REGISTER_ZMCO = 0x00; // 8 bytes / R diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 32094ddf101..359636a4992 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -4,7 +4,7 @@ namespace esphome::as7341 { -static const char *const TAG = "as7341"; +ESPHOME_LOG_TAG(TAG, "as7341"); void AS7341Component::setup() { LOG_I2C_DEVICE(this); diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index 853ff206c25..03191f6e370 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -9,7 +9,7 @@ namespace esphome::async_tcp { -static const char *const TAG = "async_tcp"; +ESPHOME_LOG_TAG(TAG, "async_tcp"); // Read buffer size matches TCP MSS (1500 MTU - 40 bytes IP/TCP headers). // This implementation only runs on ESP-IDF and host which have ample stack. diff --git a/esphome/components/at581x/at581x.cpp b/esphome/components/at581x/at581x.cpp index 575a9afd40b..618e4bee5b6 100644 --- a/esphome/components/at581x/at581x.cpp +++ b/esphome/components/at581x/at581x.cpp @@ -51,7 +51,7 @@ const uint8_t SELF_CHECK_TIME_ADDR = 0x38; // 2 bytes, up to 0x39 namespace esphome::at581x { -static const char *const TAG = "at581x"; +ESPHOME_LOG_TAG(TAG, "at581x"); bool AT581XComponent::i2c_write_reg(uint8_t addr, uint8_t data) { return this->write_register(addr, &data, 1) == esphome::i2c::NO_ERROR; diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 22cb2b31504..95b482c8404 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -3,7 +3,7 @@ namespace esphome::atc_mithermometer { -static const char *const TAG = "atc_mithermometer"; +ESPHOME_LOG_TAG(TAG, "atc_mithermometer"); void ATCMiThermometer::dump_config() { ESP_LOGCONFIG(TAG, "ATC MiThermometer"); diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp index 46948be47d8..8ec611357c8 100644 --- a/esphome/components/atm90e26/atm90e26.cpp +++ b/esphome/components/atm90e26/atm90e26.cpp @@ -4,7 +4,7 @@ namespace esphome::atm90e26 { -static const char *const TAG = "atm90e26"; +ESPHOME_LOG_TAG(TAG, "atm90e26"); void ATM90E26Component::update() { if (this->read16_(ATM90E26_REGISTER_FUNCEN) != 0x0030) { diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 23701e78348..0c4f095ab57 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -7,7 +7,7 @@ namespace esphome::atm90e32 { -static const char *const TAG = "atm90e32"; +ESPHOME_LOG_TAG(TAG, "atm90e32"); static const LogString *offset_calibration_name(bool power_offsets) { return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset"); diff --git a/esphome/components/atm90e32/button/atm90e32_button.cpp b/esphome/components/atm90e32/button/atm90e32_button.cpp index e120e453644..03871495214 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.cpp +++ b/esphome/components/atm90e32/button/atm90e32_button.cpp @@ -4,7 +4,7 @@ namespace esphome::atm90e32 { -static const char *const TAG = "atm90e32.button"; +ESPHOME_LOG_TAG(TAG, "atm90e32.button"); void ATM90E32GainCalibrationButton::press_action() { if (this->parent_ == nullptr) { diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 051395606c8..13dea3f9bc4 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -7,7 +7,7 @@ namespace esphome::audio { -static const char *const TAG = "audio.decoder"; +ESPHOME_LOG_TAG(TAG, "audio.decoder"); static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 9f2960620e1..ea3c82e637e 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -22,7 +22,7 @@ static const size_t HTTP_STREAM_BUFFER_SIZE = 2048; static const uint8_t MAX_REDIRECTIONS = 5; -static const char *const TAG = "audio_reader"; +ESPHOME_LOG_TAG(TAG, "audio_reader"); // Some common HTTP status codes - borrowed from http_request component accessed 20241224 enum HttpStatus { diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.cpp b/esphome/components/audio_file/media_source/audio_file_media_source.cpp index 0cda1eca9ed..0aebcc176fd 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.cpp +++ b/esphome/components/audio_file/media_source/audio_file_media_source.cpp @@ -11,7 +11,7 @@ namespace esphome::audio_file { -static const char *const TAG = "audio_file_media_source"; +ESPHOME_LOG_TAG(TAG, "audio_file_media_source"); static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; static constexpr size_t DECODER_TASK_STACK_SIZE = 5120; diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index 6cea4d73115..ce1f2543107 100644 --- a/esphome/components/audio_http/audio_http_media_source.cpp +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -11,7 +11,7 @@ namespace esphome::audio_http { -static const char *const TAG = "audio_http_media_source"; +ESPHOME_LOG_TAG(TAG, "audio_http_media_source"); // Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers. static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp index 3869224b911..1d6354549c5 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::axs15231 { -static const char *const TAG = "ax15231.touchscreen"; +ESPHOME_LOG_TAG(TAG, "ax15231.touchscreen"); constexpr static const uint8_t AXS_READ_TOUCHPAD[11] = {0xb5, 0xab, 0xa5, 0x5a, 0x0, 0x0, 0x0, 0x8}; diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index e0ae824b0d0..8bed59b3dbc 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -3,7 +3,7 @@ namespace esphome::b_parasite { -static const char *const TAG = "b_parasite"; +ESPHOME_LOG_TAG(TAG, "b_parasite"); void BParasite::dump_config() { ESP_LOGCONFIG(TAG, "b_parasite"); diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index eebc970795a..1553f336186 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -3,7 +3,7 @@ namespace esphome::ballu { -static const char *const TAG = "ballu.climate"; +ESPHOME_LOG_TAG(TAG, "ballu.climate"); const uint16_t BALLU_HEADER_MARK = 9000; const uint16_t BALLU_HEADER_SPACE = 4500; diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index a1104aa1b2a..1546df9f10b 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -3,7 +3,7 @@ namespace esphome::bang_bang { -static const char *const TAG = "bang_bang.climate"; +ESPHOME_LOG_TAG(TAG, "bang_bang.climate"); BangBangClimate::BangBangClimate() = default; diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 0cf970b3cc6..dcaae6d8d48 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -35,7 +35,7 @@ static const uint32_t SPI_RX_FINISH_EN = 1 << 3; namespace esphome::beken_spi_led_strip { -static const char *const TAG = "beken_spi_led_strip"; +ESPHOME_LOG_TAG(TAG, "beken_spi_led_strip"); struct SpiData { SemaphoreHandle_t dma_tx_semaphore; diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index ab952895a88..2d9709f4228 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -4,7 +4,7 @@ namespace esphome::bh1750 { -static const char *const TAG = "bh1750.sensor"; +ESPHOME_LOG_TAG(TAG, "bh1750.sensor"); static const uint8_t BH1750_COMMAND_POWER_ON = 0b00000001; static const uint8_t BH1750_COMMAND_MT_REG_HI = 0b01000000; // last 3 bits diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp index 4f746a17d4b..6b7f1a35863 100644 --- a/esphome/components/bh1900nux/bh1900nux.cpp +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -3,7 +3,7 @@ namespace esphome::bh1900nux { -static const char *const TAG = "bh1900nux.sensor"; +ESPHOME_LOG_TAG(TAG, "bh1900nux.sensor"); // I2C Registers static const uint8_t TEMPERATURE_REG = 0x00; diff --git a/esphome/components/binary/fan/binary_fan.cpp b/esphome/components/binary/fan/binary_fan.cpp index 118d87c09d0..4cc0b145a2b 100644 --- a/esphome/components/binary/fan/binary_fan.cpp +++ b/esphome/components/binary/fan/binary_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::binary { -static const char *const TAG = "binary.fan"; +ESPHOME_LOG_TAG(TAG, "binary.fan"); void BinaryFan::setup() { auto restore = this->restore_state_(); diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index 65c7dbbdb69..d4b4906c30d 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -8,7 +8,7 @@ namespace esphome::binary_sensor { #ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER -static const char *const TAG = "binary_sensor.automation"; +ESPHOME_LOG_TAG(TAG, "binary_sensor.automation"); // MultiClickTrigger timeout IDs. // MultiClickTrigger is its own Component instance, so the scheduler scopes diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 7596975a684..43915c4e17b 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::binary_sensor { -static const char *const TAG = "binary_sensor"; +ESPHOME_LOG_TAG(TAG, "binary_sensor"); // Function implementation of LOG_BINARY_SENSOR macro to reduce code size void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj) { diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 8b882212c8a..32b25f72116 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -8,7 +8,7 @@ namespace esphome::binary_sensor { -static const char *const TAG = "sensor.filter"; +ESPHOME_LOG_TAG(TAG, "sensor.filter"); void Filter::output(bool value) { if (this->next_ == nullptr) { diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 3185f156976..4f00a85c8dd 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -3,7 +3,7 @@ namespace esphome::binary_sensor_map { -static const char *const TAG = "binary_sensor_map"; +ESPHOME_LOG_TAG(TAG, "binary_sensor_map"); void BinarySensorMap::dump_config() { LOG_SENSOR(" ", "binary_sensor_map", this); } diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index 3192fc79d70..05b94e17f9e 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -29,7 +29,7 @@ extern "C" { namespace esphome::bk72xx_ble { -static const char *const TAG = "bk72xx_ble"; +ESPHOME_LOG_TAG(TAG, "bk72xx_ble"); // Pin the SDK surface this file depends on: a beken-bdk bump that moves these // must fail the build, not corrupt the kernel message. diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 7a4efff4550..49167c3000e 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -81,7 +81,7 @@ void ble_entry(void); namespace esphome::bk72xx_ble { -static const char *const TAG = "bk72xx_ble"; +ESPHOME_LOG_TAG(TAG, "bk72xx_ble"); static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index 0939e1259f6..7e7f04135e6 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -16,7 +16,7 @@ namespace esphome::bk72xx_ble_tracker { -static const char *const TAG = "bk72xx_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "bk72xx_ble_tracker"); // Minimum interval between scan (re)start attempts, so a failing controller start // cannot be retried every main-loop iteration (single-core CPU starvation). The diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 9a27cffd043..38edb6ed919 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -5,7 +5,7 @@ namespace esphome::bl0906 { -static const char *const TAG = "bl0906"; +ESPHOME_LOG_TAG(TAG, "bl0906"); constexpr uint32_t to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 9ae3360e3ac..b0b9bb1c176 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -4,7 +4,7 @@ namespace esphome::bl0939 { -static const char *const TAG = "bl0939"; +ESPHOME_LOG_TAG(TAG, "bl0939"); // https://www.belling.com.cn/media/file_object/bel_product/BL0939/datasheet/BL0939_V1.2_cn.pdf // (unfortunately chinese, but the protocol can be understood with some translation tool) diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index 642368d93fa..a8b470de7ec 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -4,7 +4,7 @@ namespace esphome::bl0940 { -static const char *const TAG = "bl0940"; +ESPHOME_LOG_TAG(TAG, "bl0940"); static const uint8_t BL0940_FULL_PACKET = 0xAA; static const uint8_t BL0940_PACKET_HEADER = 0x55; // 0x58 according to en doc but 0x55 in cn doc diff --git a/esphome/components/bl0940/button/calibration_reset_button.cpp b/esphome/components/bl0940/button/calibration_reset_button.cpp index a0b41b9a5bb..5598818f546 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.cpp +++ b/esphome/components/bl0940/button/calibration_reset_button.cpp @@ -6,7 +6,7 @@ namespace esphome::bl0940 { -static const char *const TAG = "bl0940.button.calibration_reset"; +ESPHOME_LOG_TAG(TAG, "bl0940.button.calibration_reset"); void CalibrationResetButton::dump_config() { LOG_BUTTON("", "Calibration Reset Button", this); } diff --git a/esphome/components/bl0940/number/calibration_number.cpp b/esphome/components/bl0940/number/calibration_number.cpp index 6f054443a28..d382a12e699 100644 --- a/esphome/components/bl0940/number/calibration_number.cpp +++ b/esphome/components/bl0940/number/calibration_number.cpp @@ -3,7 +3,7 @@ namespace esphome::bl0940 { -static const char *const TAG = "bl0940.number"; +ESPHOME_LOG_TAG(TAG, "bl0940.number"); void CalibrationNumber::setup() { float value = 0.0f; diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index e952df21bed..7d0eff8c9da 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -6,7 +6,7 @@ namespace esphome::bl0942 { -static const char *const TAG = "bl0942"; +ESPHOME_LOG_TAG(TAG, "bl0942"); static const uint8_t BL0942_READ_COMMAND = 0x58; static const uint8_t BL0942_FULL_PACKET = 0xAA; diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index 25001c8f74b..eb24cdeeb43 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -9,7 +9,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_client"; +ESPHOME_LOG_TAG(TAG, "ble_client"); void BLEClient::setup() { BLEClientBase::setup(); diff --git a/esphome/components/ble_client/output/ble_binary_output.cpp b/esphome/components/ble_client/output/ble_binary_output.cpp index 5d53c597086..479bfefd597 100644 --- a/esphome/components/ble_client/output/ble_binary_output.cpp +++ b/esphome/components/ble_client/output/ble_binary_output.cpp @@ -5,7 +5,7 @@ #ifdef USE_ESP32 namespace esphome::ble_client { -static const char *const TAG = "ble_binary_output"; +ESPHOME_LOG_TAG(TAG, "ble_binary_output"); void BLEBinaryOutput::dump_config() { ESP_LOGCONFIG(TAG, "BLE Binary Output:"); diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 715298e5928..69e7450dc20 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_rssi_sensor"; +ESPHOME_LOG_TAG(TAG, "ble_rssi_sensor"); void BLEClientRSSISensor::loop() { // Parent BLEClientNode has a loop() method, but this component uses diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 5dbb7e42ed2..1ddd50c3079 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_sensor"; +ESPHOME_LOG_TAG(TAG, "ble_sensor"); void BLESensor::loop() { // Parent BLEClientNode has a loop() method, but this component uses diff --git a/esphome/components/ble_client/switch/ble_switch.cpp b/esphome/components/ble_client/switch/ble_switch.cpp index 5baca2adcf1..1edd42760e0 100644 --- a/esphome/components/ble_client/switch/ble_switch.cpp +++ b/esphome/components/ble_client/switch/ble_switch.cpp @@ -6,7 +6,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_switch"; +ESPHOME_LOG_TAG(TAG, "ble_switch"); void BLEClientSwitch::write_state(bool state) { this->parent_->set_enabled(state); diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index ed2b0a63a0e..f6df7b43364 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -9,7 +9,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_text_sensor"; +ESPHOME_LOG_TAG(TAG, "ble_text_sensor"); void BLETextSensor::loop() { // Parent BLEClientNode has a loop() method, but this component uses diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index 23ca6b1dbd8..f7fa488aac7 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -16,7 +16,7 @@ namespace esphome::ble_device_base { -static const char *const TAG = "ble_device_base"; +ESPHOME_LOG_TAG(TAG, "ble_device_base"); // Longest advertisement payload worth hex-dumping at VERY_VERBOSE // (legacy advertising: 31-byte adv + 31-byte scan response). diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index b566122f8ad..64a36c468f3 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -19,7 +19,7 @@ RING_BUF_DECLARE(global_ble_rx_ring_buf, ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE); #endif // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) -static const char *const TAG = "ble_nus"; +ESPHOME_LOG_TAG(TAG, "ble_nus"); void BLENUS::write_array(const uint8_t *data, size_t len) { if (atomic_get(&this->tx_status_) == TX_DISABLED) { diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp index bc169623ce0..91947ccd26a 100644 --- a/esphome/components/ble_presence/ble_presence_device.cpp +++ b/esphome/components/ble_presence/ble_presence_device.cpp @@ -3,7 +3,7 @@ namespace esphome::ble_presence { -static const char *const TAG = "ble_presence"; +ESPHOME_LOG_TAG(TAG, "ble_presence"); void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); } diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.cpp b/esphome/components/ble_rssi/ble_rssi_sensor.cpp index 7c7c7b21487..eb049313a8d 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp +++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::ble_rssi { -static const char *const TAG = "ble_rssi"; +ESPHOME_LOG_TAG(TAG, "ble_rssi"); void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); } diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp index 3d7a3017931..42a691b1d92 100644 --- a/esphome/components/ble_scanner/ble_scanner.cpp +++ b/esphome/components/ble_scanner/ble_scanner.cpp @@ -3,7 +3,7 @@ namespace esphome::ble_scanner { -static const char *const TAG = "ble_scanner"; +ESPHOME_LOG_TAG(TAG, "ble_scanner"); void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index a001729083c..a99a39bc9e1 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -12,7 +12,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, uint8_t connection_index, const char *address_str) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index d3af02780fe..7b66c671835 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -27,7 +27,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); using ble_device_base::FAST_CONN_TIMEOUT; using ble_device_base::FAST_MAX_CONN_INTERVAL; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index ddab4812cce..5e358175bf1 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -31,7 +31,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); void BluetoothConnection::set_address(uint64_t address) { // Keep the proxy's pre-allocated connections-free message in step diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index eec2c8c3186..c6e2703c48b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -15,7 +15,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); using ble_device_base::ESPBTUUID; using ble_device_base::GATT_ERR_NOT_CONNECTED; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index cb37057cd4b..e4b94870747 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -14,7 +14,7 @@ namespace esphome::bluetooth_proxy { -static const char *const TAG = "bluetooth_proxy"; +ESPHOME_LOG_TAG(TAG, "bluetooth_proxy"); // BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE is defined during code generation // It sets the batch size for BLE advertisements to maximize WiFi efficiency diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index d911301c9d3..df1cf96d698 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -6,7 +6,7 @@ namespace esphome::bm8563 { -static const char *const TAG = "bm8563"; +ESPHOME_LOG_TAG(TAG, "bm8563"); static constexpr uint8_t CONTROL_STATUS_1_REG = 0x00; static constexpr uint8_t CONTROL_STATUS_2_REG = 0x01; diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 11c796352a3..6cc011d76b5 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -11,7 +11,7 @@ namespace esphome::bme280_base { -static const char *const TAG = "bme280.sensor"; +ESPHOME_LOG_TAG(TAG, "bme280.sensor"); static const uint8_t BME280_REGISTER_DIG_T1 = 0x88; static const uint8_t BME280_REGISTER_DIG_T2 = 0x8A; diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index bac8ed8a5a8..2ff7ca2b8cc 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -4,7 +4,7 @@ namespace esphome::bme680 { -static const char *const TAG = "bme680.sensor"; +ESPHOME_LOG_TAG(TAG, "bme680.sensor"); static const uint8_t BME680_REGISTER_COEFF1 = 0x89; static const uint8_t BME680_REGISTER_COEFF2 = 0xE1; diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 8e16a28e336..219f72d3dfc 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -5,7 +5,7 @@ namespace esphome::bme680_bsec { #ifdef USE_BSEC -static const char *const TAG = "bme680_bsec.sensor"; +ESPHOME_LOG_TAG(TAG, "bme680_bsec.sensor"); static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index d4ac57d7500..2b113c179fd 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -13,7 +13,7 @@ namespace esphome::bme68x_bsec2 { #define BME68X_BSEC2_SAMPLE_RATE_LOG(r) (r == SAMPLE_RATE_DEFAULT ? "Default" : (r == SAMPLE_RATE_ULP ? "ULP" : "LP")) #define BME68X_BSEC2_VOLTAGE_LOG(v) (v == VOLTAGE_3_3V ? "3.3V" : "1.8V") -static const char *const TAG = "bme68x_bsec2.sensor"; +ESPHOME_LOG_TAG(TAG, "bme68x_bsec2.sensor"); static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp index 2a2c5e2bcc8..8672e4f6ffc 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp @@ -11,7 +11,7 @@ namespace esphome::bme68x_bsec2_i2c { -static const char *const TAG = "bme68x_bsec2_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bme68x_bsec2_i2c.sensor"); void BME68xBSEC2I2CComponent::setup() { // must set up our bme68x_dev instance before calling setup() diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index 442ee183bf9..a90d490ae18 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -4,7 +4,7 @@ namespace esphome::bmi160 { -static const char *const TAG = "bmi160"; +ESPHOME_LOG_TAG(TAG, "bmi160"); static constexpr uint32_t GYRO_WAKEUP_TIMEOUT_MS = 100; const uint8_t BMI160_REGISTER_CHIPID = 0x00; diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index acb93158d4b..a1709c1fa56 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -5,7 +5,7 @@ namespace esphome::bmi270 { -static const char *const TAG = "bmi270"; +ESPHOME_LOG_TAG(TAG, "bmi270"); #if defined(USE_ARDUINO) && !defined(USE_ESP32) static const size_t MAX_I2C_BUFFER_SIZE = 32; diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index f42875b2089..4badfd95f5b 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -3,7 +3,7 @@ namespace esphome::bmp085 { -static const char *const TAG = "bmp085.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp085.sensor"); static const uint8_t BMP085_ADDRESS = 0x77; static const uint8_t BMP085_REGISTER_AC1_H = 0xAA; diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 34e1d671019..88ff0985812 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -6,7 +6,7 @@ namespace esphome::bmp280_base { -static const char *const TAG = "bmp280.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp280.sensor"); static const uint8_t BMP280_REGISTER_STATUS = 0xF3; static const uint8_t BMP280_REGISTER_CONTROL = 0xF4; diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index aa5e799db16..ba85a20e3d8 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -13,7 +13,7 @@ namespace esphome::bmp3xx_base { -static const char *const TAG = "bmp3xx.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp3xx.sensor"); static const LogString *chip_type_to_str(uint8_t chip_type) { switch (chip_type) { diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp index 704d415993d..8d8f3122a91 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::bmp3xx_i2c { -static const char *const TAG = "bmp3xx_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp3xx_i2c.sensor"); bool BMP3XXI2CComponent::read_byte(uint8_t a_register, uint8_t *data) { return I2CDevice::read_byte(a_register, data); diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp b/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp index 5657626c2d7..741f656c077 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::bmp3xx_spi { -static const char *const TAG = "bmp3xx_spi.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp3xx_spi.sensor"); uint8_t set_bit(uint8_t num, int position) { int mask = 1 << position; diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 7a627eee03f..7d4761f5166 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -17,7 +17,7 @@ namespace esphome::bmp581_base { -static const char *const TAG = "bmp581"; +ESPHOME_LOG_TAG(TAG, "bmp581"); // Oversampling strings indexed by Oversampling enum (0-7): NONE, X2, X4, X8, X16, X32, X64, X128 PROGMEM_STRING_TABLE(OversamplingStrings, "None", "2x", "4x", "8x", "16x", "32x", "64x", "128x", ""); diff --git a/esphome/components/bmp581_spi/bmp581_spi.cpp b/esphome/components/bmp581_spi/bmp581_spi.cpp index 01435880f08..129128026c0 100644 --- a/esphome/components/bmp581_spi/bmp581_spi.cpp +++ b/esphome/components/bmp581_spi/bmp581_spi.cpp @@ -7,7 +7,7 @@ namespace esphome::bmp581_spi { -static const char *const TAG = "bmp581_spi"; +ESPHOME_LOG_TAG(TAG, "bmp581_spi"); // OR (|) register with BMP_SPI_READ for read inline constexpr uint8_t BMP_SPI_READ = 0x80; diff --git a/esphome/components/bp1658cj/bp1658cj.cpp b/esphome/components/bp1658cj/bp1658cj.cpp index 95a6b3be457..c7dc61c6f04 100644 --- a/esphome/components/bp1658cj/bp1658cj.cpp +++ b/esphome/components/bp1658cj/bp1658cj.cpp @@ -3,7 +3,7 @@ namespace esphome::bp1658cj { -static const char *const TAG = "bp1658cj"; +ESPHOME_LOG_TAG(TAG, "bp1658cj"); static const uint8_t BP1658CJ_MODEL_ID = 0x80; static const uint8_t BP1658CJ_ADDR_STANDBY = 0x0; diff --git a/esphome/components/bp5758d/bp5758d.cpp b/esphome/components/bp5758d/bp5758d.cpp index 3cd841b2cd6..5789acdcb97 100644 --- a/esphome/components/bp5758d/bp5758d.cpp +++ b/esphome/components/bp5758d/bp5758d.cpp @@ -3,7 +3,7 @@ namespace esphome::bp5758d { -static const char *const TAG = "bp5758d"; +ESPHOME_LOG_TAG(TAG, "bp5758d"); static const uint8_t BP5758D_MODEL_ID = 0b10000000; static const uint8_t BP5758D_ADDR_STANDBY = 0b00000000; diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 51f5d866a49..c055c430413 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -26,7 +26,7 @@ namespace esphome::bthome_mithermometer { -static const char *const TAG = "bthome_mithermometer"; +ESPHOME_LOG_TAG(TAG, "bthome_mithermometer"); static constexpr size_t BTHOME_BINDKEY_SIZE = 16; static constexpr size_t BTHOME_NONCE_SIZE = 13; static constexpr size_t BTHOME_MIC_SIZE = 4; diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index 2a2a6451325..e85ce60df6e 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -3,7 +3,7 @@ namespace esphome::button { -static const char *const TAG = "button"; +ESPHOME_LOG_TAG(TAG, "button"); // Function implementation of LOG_BUTTON macro to reduce code size void log_button(const char *tag, const char *prefix, const char *type, Button *obj) { diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp index 55a3f0b96c2..84d5a9117da 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp @@ -6,7 +6,7 @@ namespace esphome::camera_encoder { -static const char *const TAG = "camera_encoder"; +ESPHOME_LOG_TAG(TAG, "camera_encoder"); ESP32CameraJPEGEncoder::ESP32CameraJPEGEncoder(uint8_t quality, camera::EncoderBuffer *output) { this->quality_ = quality; diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 1032a389c68..828eb77d1ca 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -4,7 +4,7 @@ namespace esphome::canbus { -static const char *const TAG = "canbus"; +ESPHOME_LOG_TAG(TAG, "canbus"); void Canbus::setup() { if (!this->setup_internal()) { diff --git a/esphome/components/cap1188/cap1188.cpp b/esphome/components/cap1188/cap1188.cpp index 125a4aba51e..d6d97329db4 100644 --- a/esphome/components/cap1188/cap1188.cpp +++ b/esphome/components/cap1188/cap1188.cpp @@ -4,7 +4,7 @@ namespace esphome::cap1188 { -static const char *const TAG = "cap1188"; +ESPHOME_LOG_TAG(TAG, "cap1188"); void CAP1188Component::setup() { this->disable_loop(); diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 599422a46b0..36e9ec406d1 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -13,7 +13,7 @@ namespace esphome::captive_portal { -static const char *const TAG = "captive_portal"; +ESPHOME_LOG_TAG(TAG, "captive_portal"); void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index 56ad9f7176b..d7a46373b86 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -9,7 +9,7 @@ namespace esphome::captive_portal { -static const char *const TAG = "captive_portal.dns"; +ESPHOME_LOG_TAG(TAG, "captive_portal.dns"); // DNS constants static constexpr uint16_t DNS_PORT = 53; diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 6a4a3a4d531..94d89b899d3 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -11,7 +11,7 @@ namespace esphome::cc1101 { -static const char *const TAG = "cc1101"; +ESPHOME_LOG_TAG(TAG, "cc1101"); static constexpr size_t TX_FIFO_SIZE = 64; static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) { diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index afaeac55aaa..126ed0049d1 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -5,7 +5,7 @@ namespace esphome::ccs811 { -static const char *const TAG = "ccs811"; +ESPHOME_LOG_TAG(TAG, "ccs811"); // based on // - https://cdn.sparkfun.com/datasheets/BreakoutBoards/CCS811_Programming_Guide.pdf diff --git a/esphome/components/cd74hc4067/cd74hc4067.cpp b/esphome/components/cd74hc4067/cd74hc4067.cpp index c275aca75f7..b0a042e868a 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.cpp +++ b/esphome/components/cd74hc4067/cd74hc4067.cpp @@ -4,7 +4,7 @@ namespace esphome::cd74hc4067 { -static const char *const TAG = "cd74hc4067"; +ESPHOME_LOG_TAG(TAG, "cd74hc4067"); void CD74HC4067Component::setup() { this->pin_s0_->setup(); diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp index 042688bfe61..a1bf0a19729 100644 --- a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp @@ -13,7 +13,7 @@ namespace esphome::cdc_acm_uart { -static const char *const TAG = "cdc_acm_uart"; +ESPHOME_LOG_TAG(TAG, "cdc_acm_uart"); static constexpr size_t UART_TASK_STACK_SIZE = 4096; static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64; diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 85603f46f4f..28b355a4f1d 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -10,7 +10,7 @@ static const uint8_t CH422G_REG_IN = 0x26; // read reg for input bits static const uint8_t CH422G_REG_OUT = 0x38; // write reg for output bits 0-7 static const uint8_t CH422G_REG_OUT_UPPER = 0x23; // write reg for output bits 8-11 -static const char *const TAG = "ch422g"; +ESPHOME_LOG_TAG(TAG, "ch422g"); void CH422GComponent::setup() { // set outputs before mode diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index 8424d130b4b..b7e9d224faf 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -12,7 +12,7 @@ static constexpr uint8_t CH423_REG_IO_RD = 0x26; // Read IO7-IO0 (0x4D >> 1, ro static constexpr uint8_t CH423_REG_OCL = 0x22; // Write OC7-OC0 (0x44 >> 1) static constexpr uint8_t CH423_REG_OCH = 0x23; // Write OC15-OC8 (0x46 >> 1) -static const char *const TAG = "ch423"; +ESPHOME_LOG_TAG(TAG, "ch423"); void CH423Component::setup() { // set outputs before mode diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index d08952115bd..790307cec14 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -6,7 +6,7 @@ namespace esphome::climate { -static const char *const TAG = "climate"; +ESPHOME_LOG_TAG(TAG, "climate"); // Memory-efficient lookup tables struct StringToUint8 { diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index 47143e6e607..622aff243cd 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -3,7 +3,7 @@ namespace esphome::climate_ir { -static const char *const TAG = "climate_ir"; +ESPHOME_LOG_TAG(TAG, "climate_ir"); climate::ClimateTraits ClimateIR::traits() { auto traits = climate::ClimateTraits(); diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index bb612eda7bd..042d1dcb21f 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -3,7 +3,7 @@ namespace esphome::climate_ir_lg { -static const char *const TAG = "climate.climate_ir_lg"; +ESPHOME_LOG_TAG(TAG, "climate.climate_ir_lg"); // All codes provided here are missing the checksum (last 4 bits) // this checksum needs to be calculated before sending (look at `calc_checksum_()`) diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 2e3352b8953..a25a753f754 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -5,7 +5,7 @@ namespace esphome::cm1106 { -static const char *const TAG = "cm1106"; +ESPHOME_LOG_TAG(TAG, "cm1106"); static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index 8ef0976e3b7..d10490a20ce 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -7,7 +7,7 @@ namespace esphome::combination { -static const char *const TAG = "combination"; +ESPHOME_LOG_TAG(TAG, "combination"); void CombinationComponent::log_config_(const LogString *combo_type) { LOG_SENSOR("", "Combination Sensor:", this); diff --git a/esphome/components/coolix/coolix.cpp b/esphome/components/coolix/coolix.cpp index 56eb43cd82b..00a7b8a854a 100644 --- a/esphome/components/coolix/coolix.cpp +++ b/esphome/components/coolix/coolix.cpp @@ -4,7 +4,7 @@ namespace esphome::coolix { -static const char *const TAG = "coolix.climate"; +ESPHOME_LOG_TAG(TAG, "coolix.climate"); static const uint32_t COOLIX_OFF = 0xB27BE0; static const uint32_t COOLIX_SWING = 0xB26BE0; diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp index b084b0080a9..80093a4a965 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "copy.binary_sensor"); void CopyBinarySensor::setup() { source_->add_on_state_callback([this](bool value) { this->publish_state(value); }); diff --git a/esphome/components/copy/button/copy_button.cpp b/esphome/components/copy/button/copy_button.cpp index 297e1b0c948..c9441500a96 100644 --- a/esphome/components/copy/button/copy_button.cpp +++ b/esphome/components/copy/button/copy_button.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.button"; +ESPHOME_LOG_TAG(TAG, "copy.button"); void CopyButton::dump_config() { LOG_BUTTON("", "Copy Button", this); } diff --git a/esphome/components/copy/cover/copy_cover.cpp b/esphome/components/copy/cover/copy_cover.cpp index 3412784cc77..71bdef667e9 100644 --- a/esphome/components/copy/cover/copy_cover.cpp +++ b/esphome/components/copy/cover/copy_cover.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.cover"; +ESPHOME_LOG_TAG(TAG, "copy.cover"); void CopyCover::setup() { source_->add_on_state_callback([this]() { diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 6eed787b058..1e721332be7 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.fan"; +ESPHOME_LOG_TAG(TAG, "copy.fan"); void CopyFan::setup() { // Copy preset modes once from source fan — stored on Fan base class diff --git a/esphome/components/copy/lock/copy_lock.cpp b/esphome/components/copy/lock/copy_lock.cpp index 5fdb9a757b8..d8b0b17f84b 100644 --- a/esphome/components/copy/lock/copy_lock.cpp +++ b/esphome/components/copy/lock/copy_lock.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.lock"; +ESPHOME_LOG_TAG(TAG, "copy.lock"); void CopyLock::setup() { source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); }); diff --git a/esphome/components/copy/number/copy_number.cpp b/esphome/components/copy/number/copy_number.cpp index a10bd209f4e..7ddc7da1704 100644 --- a/esphome/components/copy/number/copy_number.cpp +++ b/esphome/components/copy/number/copy_number.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.number"; +ESPHOME_LOG_TAG(TAG, "copy.number"); void CopyNumber::setup() { source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/copy/select/copy_select.cpp b/esphome/components/copy/select/copy_select.cpp index e4f97f3c162..2315514ac54 100644 --- a/esphome/components/copy/select/copy_select.cpp +++ b/esphome/components/copy/select/copy_select.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.select"; +ESPHOME_LOG_TAG(TAG, "copy.select"); void CopySelect::setup() { source_->add_on_state_callback([this](size_t index) { this->publish_state(index); }); diff --git a/esphome/components/copy/sensor/copy_sensor.cpp b/esphome/components/copy/sensor/copy_sensor.cpp index 9df23ab92d0..17eee92c12c 100644 --- a/esphome/components/copy/sensor/copy_sensor.cpp +++ b/esphome/components/copy/sensor/copy_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.sensor"; +ESPHOME_LOG_TAG(TAG, "copy.sensor"); void CopySensor::setup() { source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 555f0030a5b..51850f898a0 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.switch"; +ESPHOME_LOG_TAG(TAG, "copy.switch"); void CopySwitch::setup() { source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/copy/text/copy_text.cpp b/esphome/components/copy/text/copy_text.cpp index 5ec3ba05b7c..fe8e1a010aa 100644 --- a/esphome/components/copy/text/copy_text.cpp +++ b/esphome/components/copy/text/copy_text.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.text"; +ESPHOME_LOG_TAG(TAG, "copy.text"); void CopyText::setup() { source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); }); diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.cpp b/esphome/components/copy/text_sensor/copy_text_sensor.cpp index 97182e8a618..96a0aa32b5f 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.cpp +++ b/esphome/components/copy/text_sensor/copy_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.text_sensor"; +ESPHOME_LOG_TAG(TAG, "copy.text_sensor"); void CopyTextSensor::setup() { source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); }); diff --git a/esphome/components/counter/counter_sensor.cpp b/esphome/components/counter/counter_sensor.cpp index 22adb49cca7..c2f6688b2dd 100644 --- a/esphome/components/counter/counter_sensor.cpp +++ b/esphome/components/counter/counter_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::counter { -static const char *const TAG = "counter"; +ESPHOME_LOG_TAG(TAG, "counter"); void CounterSensor::setup() { if (this->restore_) { diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index dc2db3bf329..30e7f70b02b 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -8,7 +8,7 @@ namespace esphome::cover { -static const char *const TAG = "cover"; +ESPHOME_LOG_TAG(TAG, "cover"); const LogString *cover_command_to_str(float pos) { if (pos == COVER_OPEN) { diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index 1f9233841a6..6ef4588b171 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -3,7 +3,7 @@ namespace esphome::cs5460a { -static const char *const TAG = "cs5460a"; +ESPHOME_LOG_TAG(TAG, "cs5460a"); void CS5460AComponent::write_register_(enum CS5460ARegister addr, uint32_t value) { this->write_byte(CMD_WRITE | (addr << 1)); diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 103bc844528..695b7f33d6c 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -4,7 +4,7 @@ namespace esphome::cse7761 { -static const char *const TAG = "cse7761"; +ESPHOME_LOG_TAG(TAG, "cse7761"); /*********************************************************************************************\ * CSE7761 - Energy (Sonoff Dual R3 Pow v1.x) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 30f1b7a8679..92769011563 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -6,7 +6,7 @@ namespace esphome::cse7766 { -static const char *const TAG = "cse7766"; +ESPHOME_LOG_TAG(TAG, "cse7766"); void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); diff --git a/esphome/components/cst226/binary_sensor/cstt6_button.cpp b/esphome/components/cst226/binary_sensor/cstt6_button.cpp index c14da2b176a..a755c3d09c8 100644 --- a/esphome/components/cst226/binary_sensor/cstt6_button.cpp +++ b/esphome/components/cst226/binary_sensor/cstt6_button.cpp @@ -3,7 +3,7 @@ namespace esphome::cst226 { -static const char *const TAG = "CST226.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "CST226.binary_sensor"); void CST226Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp index 49d61cbfbca..849cff4a49e 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp @@ -2,7 +2,7 @@ namespace esphome::cst226 { -static const char *const TAG = "cst226.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst226.touchscreen"); void CST226Touchscreen::setup() { if (this->reset_pin_ != nullptr) { diff --git a/esphome/components/cst328/binary_sensor/cst328_button.cpp b/esphome/components/cst328/binary_sensor/cst328_button.cpp index b58f4b4b9f6..2010f097515 100644 --- a/esphome/components/cst328/binary_sensor/cst328_button.cpp +++ b/esphome/components/cst328/binary_sensor/cst328_button.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" namespace esphome::cst328 { -static const char *const TAG = "cst328.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "cst328.binary_sensor"); void CST328Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp index 5e1a2ebf728..946eab1b177 100644 --- a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp @@ -3,7 +3,7 @@ namespace esphome::cst328 { -static const char *const TAG = "cst328.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst328.touchscreen"); static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.cpp b/esphome/components/ct_clamp/ct_clamp_sensor.cpp index 613c3428be3..d9e4b2b8fa2 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.cpp +++ b/esphome/components/ct_clamp/ct_clamp_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::ct_clamp { -static const char *const TAG = "ct_clamp"; +ESPHOME_LOG_TAG(TAG, "ct_clamp"); void CTClampSensor::dump_config() { LOG_SENSOR("", "CT Clamp Sensor", this); diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index d15b310a37a..6311fd3cf3f 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -6,7 +6,7 @@ namespace esphome::current_based { -static const char *const TAG = "current_based.cover"; +ESPHOME_LOG_TAG(TAG, "current_based.cover"); using namespace esphome::cover; diff --git a/esphome/components/d01/d01.cpp b/esphome/components/d01/d01.cpp index f7a0c08ec43..e15a5959cbc 100644 --- a/esphome/components/d01/d01.cpp +++ b/esphome/components/d01/d01.cpp @@ -17,7 +17,7 @@ namespace esphome::d01 { -static const char *const TAG = "d01"; +ESPHOME_LOG_TAG(TAG, "d01"); static const uint8_t D01_FRAME_HEADER = 0xA5; diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index 15575583d97..d1a076733aa 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -5,7 +5,7 @@ namespace esphome::dac7678 { -static const char *const TAG = "dac7678"; +ESPHOME_LOG_TAG(TAG, "dac7678"); static const uint8_t DAC7678_REG_INPUT_N = 0x00; static const uint8_t DAC7678_REG_SELECT_UPDATE_N = 0x10; diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index d45586ba2dc..7a1310b9ac1 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -3,7 +3,7 @@ namespace esphome::daikin { -static const char *const TAG = "daikin.climate"; +ESPHOME_LOG_TAG(TAG, "daikin.climate"); void DaikinClimate::transmit_state() { uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00, diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index e31f72dfb96..e79135b1859 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -7,7 +7,7 @@ namespace esphome::daikin_arc { -static const char *const TAG = "daikin.climate"; +ESPHOME_LOG_TAG(TAG, "daikin.climate"); void DaikinArcClimate::setup() { climate_ir::ClimateIR::setup(); diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 1b085013f1d..5d8ef52dc01 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -3,7 +3,7 @@ namespace esphome::daikin_brc { -static const char *const TAG = "daikin_brc.climate"; +ESPHOME_LOG_TAG(TAG, "daikin_brc.climate"); void DaikinBrcClimate::control(const climate::ClimateCall &call) { this->mode_button_ = 0x00; diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index c418362ced1..2721383ae84 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -3,7 +3,7 @@ namespace esphome::dallas_temp { -static const char *const TAG = "dallas.temp.sensor"; +ESPHOME_LOG_TAG(TAG, "dallas.temp.sensor"); static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 45d4db49721..a486037266e 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -6,7 +6,7 @@ namespace esphome::daly_bms { -static const char *const TAG = "daly_bms"; +ESPHOME_LOG_TAG(TAG, "daly_bms"); static const uint8_t DALY_FRAME_SIZE = 13; static const uint8_t DALY_TEMPERATURE_OFFSET = 40; diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index b99b89259fb..795d54afd90 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -7,7 +7,7 @@ namespace esphome::datetime { -static const char *const TAG = "datetime.date_entity"; +ESPHOME_LOG_TAG(TAG, "datetime.date_entity"); void DateEntity::publish_state() { if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) { diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 8f180fd081e..6aa14163430 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -7,7 +7,7 @@ namespace esphome::datetime { -static const char *const TAG = "datetime.datetime_entity"; +ESPHOME_LOG_TAG(TAG, "datetime.datetime_entity"); void DateTimeEntity::publish_state() { if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) { diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index da4c9eb31e4..ea1a97256cd 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -7,7 +7,7 @@ namespace esphome::datetime { -static const char *const TAG = "datetime.time_entity"; +ESPHOME_LOG_TAG(TAG, "datetime.time_entity"); void TimeEntity::publish_state() { if (this->hour_ > 23) { diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f4522c623..9c7622d1161 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -11,7 +11,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); void DebugComponent::dump_config() { ESP_LOGCONFIG(TAG, "Debug component:"); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 8e1a67224eb..1ffc0a2eb60 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -19,7 +19,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); // index by values returned by esp_reset_reason diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index acce28818c7..5b62eb61907 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -17,7 +17,7 @@ extern const char *core_release; namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); // PROGMEM string table for reset reasons, indexed by reason code (0-6), with "Unknown" as fallback // clang-format off diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 55b29310a14..a1166758e26 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -4,7 +4,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); const char *DebugComponent::get_reset_reason_(std::span buffer) { // Return the static string directly diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index 4ace4be0a3d..7cedac287c0 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -15,7 +15,7 @@ #endif namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); const char *DebugComponent::get_reset_reason_(std::span buffer) { char *buf = buffer.data(); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index e23a0f668a0..f020c8cd772 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -12,7 +12,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); constexpr std::uintptr_t MBR_PARAM_PAGE_ADDR = 0xFFC; constexpr std::uintptr_t MBR_BOOTLOADER_ADDR = 0xFF8; diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index a955095875b..47ebd3641e4 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index d33102bf4fa..f363a0f0acb 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -4,7 +4,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000; diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 20297028b2c..eef392ad5cf 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -28,7 +28,7 @@ namespace esphome::deep_sleep { // - Touch: Touch pad wakeup (esp_sleep_enable_touchpad_wakeup) // - GPIO wakeup: GPIO wakeup for RTC pins -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 2b98f4b8555..408d5a6fe60 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -11,7 +11,7 @@ extern "C" { namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index cadf7bf42d9..aff5c0e9c7d 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); // The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and // WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index fab2d68347b..eec4d7f55b1 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -3,7 +3,7 @@ namespace esphome::delonghi { -static const char *const TAG = "delonghi.climate"; +ESPHOME_LOG_TAG(TAG, "delonghi.climate"); void DelonghiClimate::transmit_state() { uint8_t remote_state[DELONGHI_STATE_FRAME_SIZE] = {0}; diff --git a/esphome/components/dew_point/dew_point.cpp b/esphome/components/dew_point/dew_point.cpp index 04ac305e3d2..7012bcbb755 100644 --- a/esphome/components/dew_point/dew_point.cpp +++ b/esphome/components/dew_point/dew_point.cpp @@ -3,7 +3,7 @@ namespace esphome::dew_point { -static const char *const TAG = "dew_point.sensor"; +ESPHOME_LOG_TAG(TAG, "dew_point.sensor"); void DewPointComponent::setup() { // Register callbacks for sensor updates diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 1195f595c8c..c45e895b385 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -4,7 +4,7 @@ namespace esphome::dfplayer { -static const char *const TAG = "dfplayer"; +ESPHOME_LOG_TAG(TAG, "dfplayer"); void DFPlayer::next() { this->ack_set_is_playing_ = true; diff --git a/esphome/components/dfrobot_sen0395/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp index 570bfef9439..f5aee549435 100644 --- a/esphome/components/dfrobot_sen0395/commands.cpp +++ b/esphome/components/dfrobot_sen0395/commands.cpp @@ -8,7 +8,7 @@ namespace esphome::dfrobot_sen0395 { -static const char *const TAG = "dfrobot_sen0395.commands"; +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395.commands"); uint8_t Command::execute(DfrobotSen0395Component *parent) { this->parent_ = parent; diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp index 42cfd5dff68..9c092982e55 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp @@ -5,7 +5,7 @@ namespace esphome::dfrobot_sen0395 { -static const char *const TAG = "dfrobot_sen0395"; +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395"); const char ASCII_CR = 0x0D; const char ASCII_LF = 0x0A; diff --git a/esphome/components/dht/dht.cpp b/esphome/components/dht/dht.cpp index 2196f3a982c..12b8b0feba4 100644 --- a/esphome/components/dht/dht.cpp +++ b/esphome/components/dht/dht.cpp @@ -4,7 +4,7 @@ namespace esphome::dht { -static const char *const TAG = "dht"; +ESPHOME_LOG_TAG(TAG, "dht"); void DHT::setup() { this->t_pin_->digital_write(true); diff --git a/esphome/components/dht12/dht12.cpp b/esphome/components/dht12/dht12.cpp index 78f9140929b..e099b65d5b7 100644 --- a/esphome/components/dht12/dht12.cpp +++ b/esphome/components/dht12/dht12.cpp @@ -7,7 +7,7 @@ namespace esphome::dht12 { -static const char *const TAG = "dht12"; +ESPHOME_LOG_TAG(TAG, "dht12"); void DHT12Component::update() { uint8_t data[5]; diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 66fadf12ece..49cd2fc7c66 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -8,7 +8,7 @@ #include "esphome/core/log.h" namespace esphome::display { -static const char *const TAG = "display"; +ESPHOME_LOG_TAG(TAG, "display"); // COLOR_OFF and COLOR_ON are now inline constexpr in display.h diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index d564ea67bd5..45580927027 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -6,7 +6,7 @@ namespace esphome::display { -static const char *const TAG = "display"; +ESPHOME_LOG_TAG(TAG, "display"); void DisplayBuffer::init_internal_(uint32_t buffer_length) { RAMAllocator allocator; diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index 3ecf6d1cf15..8eb9288a5ad 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -4,7 +4,7 @@ namespace esphome::display { -static const char *const TAG = "display"; +ESPHOME_LOG_TAG(TAG, "display"); void Rect::expand(int16_t horizontal, int16_t vertical) { if (this->is_set() && (this->w >= (-2 * horizontal)) && (this->h >= (-2 * vertical))) { diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index e672fb200fc..c34f7126603 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -5,7 +5,7 @@ namespace esphome::dlms_meter { -static const char *const TAG = "dlms_meter"; +ESPHOME_LOG_TAG(TAG, "dlms_meter"); static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) { std::array buf; vsnprintf(buf.data(), buf.size(), fmt, args); diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 3b4693139d3..abb34d6598a 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -4,7 +4,7 @@ namespace esphome::dps310 { -static const char *const TAG = "dps310"; +ESPHOME_LOG_TAG(TAG, "dps310"); void DPS310Component::setup() { uint8_t coef_data_raw[DPS310_NUM_COEF_REGS]; diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 1d04b52168e..4ddef04f413 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -6,7 +6,7 @@ namespace esphome::ds1307 { -static const char *const TAG = "ds1307"; +ESPHOME_LOG_TAG(TAG, "ds1307"); void DS1307Component::setup() { if (!this->read_rtc_()) { diff --git a/esphome/components/ds1603l/ds1603l.cpp b/esphome/components/ds1603l/ds1603l.cpp index b0b0ef8175d..7762a593f11 100644 --- a/esphome/components/ds1603l/ds1603l.cpp +++ b/esphome/components/ds1603l/ds1603l.cpp @@ -7,7 +7,7 @@ namespace esphome::ds1603l { -static const char *const TAG = "ds1603l.sensor"; +ESPHOME_LOG_TAG(TAG, "ds1603l.sensor"); void DS1603L::loop() { // Assemble frames one byte at a time so a stream that starts mid-frame can realign diff --git a/esphome/components/ds2484/ds2484.cpp b/esphome/components/ds2484/ds2484.cpp index 69103ccbd22..55198481960 100644 --- a/esphome/components/ds2484/ds2484.cpp +++ b/esphome/components/ds2484/ds2484.cpp @@ -1,7 +1,7 @@ #include "ds2484.h" namespace esphome::ds2484 { -static const char *const TAG = "ds2484.onewire"; +ESPHOME_LOG_TAG(TAG, "ds2484.onewire"); void DS2484OneWireBus::setup() { this->reset_device(); diff --git a/esphome/components/ds248x/ds248x.cpp b/esphome/components/ds248x/ds248x.cpp index c8f7395119c..db104b3d154 100644 --- a/esphome/components/ds248x/ds248x.cpp +++ b/esphome/components/ds248x/ds248x.cpp @@ -4,7 +4,7 @@ namespace esphome::ds248x { -static const char *const TAG = "ds248x"; +ESPHOME_LOG_TAG(TAG, "ds248x"); void DS248xComponent::setup() { ESP_LOGCONFIG(TAG, "Setting up DS248x..."); diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.cpp b/esphome/components/ds248x/ds248x_one_wire_bus.cpp index ed5b05bab70..201261d4bf6 100644 --- a/esphome/components/ds248x/ds248x_one_wire_bus.cpp +++ b/esphome/components/ds248x/ds248x_one_wire_bus.cpp @@ -4,7 +4,7 @@ namespace esphome::ds248x { -static const char *const TAG = "ds248x.one_wire"; +ESPHOME_LOG_TAG(TAG, "ds248x.one_wire"); void DS248xOneWireBus::setup() { ESP_LOGCONFIG(TAG, "Setting up DS248x 1-Wire Bus (Channel %d)...", this->channel_); diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.cpp b/esphome/components/duty_cycle/duty_cycle_sensor.cpp index fd0a48b9351..69a41eb548d 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.cpp +++ b/esphome/components/duty_cycle/duty_cycle_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::duty_cycle { -static const char *const TAG = "duty_cycle"; +ESPHOME_LOG_TAG(TAG, "duty_cycle"); void DutyCycleSensor::setup() { this->pin_->setup(); diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp index 697a4e96f3c..646c2febc6c 100644 --- a/esphome/components/duty_time/duty_time_sensor.cpp +++ b/esphome/components/duty_time/duty_time_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::duty_time_sensor { -static const char *const TAG = "duty_time_sensor"; +ESPHOME_LOG_TAG(TAG, "duty_time_sensor"); #ifdef USE_BINARY_SENSOR void DutyTimeSensor::set_sensor(binary_sensor::BinarySensor *const sensor) { diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index 061af4c4c0e..09c854c6495 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -7,7 +7,7 @@ namespace esphome::e131 { -static const char *const TAG = "e131"; +ESPHOME_LOG_TAG(TAG, "e131"); static const int PORT = 5568; E131Component::E131Component() {} diff --git a/esphome/components/e131/e131_addressable_light_effect.cpp b/esphome/components/e131/e131_addressable_light_effect.cpp index 6157eba4e93..c8940052fde 100644 --- a/esphome/components/e131/e131_addressable_light_effect.cpp +++ b/esphome/components/e131/e131_addressable_light_effect.cpp @@ -5,7 +5,7 @@ namespace esphome::e131 { -static const char *const TAG = "e131_addressable_light_effect"; +ESPHOME_LOG_TAG(TAG, "e131_addressable_light_effect"); static const int MAX_DATA_SIZE = (sizeof(E131Packet::values) - 1); E131AddressableLightEffect::E131AddressableLightEffect(const char *name) : AddressableLightEffect(name) {} diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index afed2abe311..e3f2e3940af 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -14,7 +14,7 @@ namespace esphome::e131 { -static const char *const TAG = "e131"; +ESPHOME_LOG_TAG(TAG, "e131"); static const uint8_t ACN_ID[12] = {0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00}; static const uint32_t VECTOR_ROOT = 4; diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 2dd7405ff5f..71ce5b0c706 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -4,7 +4,7 @@ namespace esphome::ee895 { -static const char *const TAG = "ee895"; +ESPHOME_LOG_TAG(TAG, "ee895"); // Serial number is 16 bytes static constexpr size_t EE895_SERIAL_NUMBER_SIZE = 16; diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.cpp b/esphome/components/ektf2232/touchscreen/ektf2232.cpp index 51532548c1f..81434c9db62 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.cpp +++ b/esphome/components/ektf2232/touchscreen/ektf2232.cpp @@ -6,7 +6,7 @@ namespace esphome::ektf2232 { -static const char *const TAG = "ektf2232"; +ESPHOME_LOG_TAG(TAG, "ektf2232"); static const uint8_t SOFT_RESET_CMD[4] = {0x77, 0x77, 0x77, 0x77}; static const uint8_t HELLO[4] = {0x55, 0x55, 0x55, 0x55}; diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index bb041bfd6d5..6a21b4e7b8e 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -7,7 +7,7 @@ namespace esphome::emc2101 { -static const char *const TAG = "EMC2101"; +ESPHOME_LOG_TAG(TAG, "EMC2101"); static const uint8_t EMC2101_CHIP_ID = 0x16; // EMC2101 default device id from part id static const uint8_t EMC2101_ALT_CHIP_ID = 0x28; // EMC2101 alternate device id from part id diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.cpp b/esphome/components/emc2101/sensor/emc2101_sensor.cpp index bb5eea21f3b..bcc8c01c4ff 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.cpp +++ b/esphome/components/emc2101/sensor/emc2101_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::emc2101 { -static const char *const TAG = "EMC2101.sensor"; +ESPHOME_LOG_TAG(TAG, "EMC2101.sensor"); void EMC2101Sensor::dump_config() { ESP_LOGCONFIG(TAG, "Emc2101 sensor:"); diff --git a/esphome/components/emmeti/emmeti.cpp b/esphome/components/emmeti/emmeti.cpp index 82991aeac75..5cf0fd8ae1d 100644 --- a/esphome/components/emmeti/emmeti.cpp +++ b/esphome/components/emmeti/emmeti.cpp @@ -3,7 +3,7 @@ namespace esphome::emmeti { -static const char *const TAG = "emmeti.climate"; +ESPHOME_LOG_TAG(TAG, "emmeti.climate"); // setters uint8_t EmmetiClimate::set_temp_() { diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 8efc2de7538..ae92b737316 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -4,7 +4,7 @@ namespace esphome::emontx { -static const char *const TAG = "emontx"; +ESPHOME_LOG_TAG(TAG, "emontx"); void EmonTx::setup() { this->buffer_pos_ = 0; } diff --git a/esphome/components/emontx/sensor/emontx_sensor.cpp b/esphome/components/emontx/sensor/emontx_sensor.cpp index 142df0150e7..d6f3200e993 100644 --- a/esphome/components/emontx/sensor/emontx_sensor.cpp +++ b/esphome/components/emontx/sensor/emontx_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::emontx { -static const char *const TAG = "emontx_sensor"; +ESPHOME_LOG_TAG(TAG, "emontx_sensor"); void EmonTxSensor::dump_config() { LOG_SENSOR(" ", "EmonTx Sensor", this); } diff --git a/esphome/components/endstop/endstop_cover.cpp b/esphome/components/endstop/endstop_cover.cpp index b3c210a8d8a..25b5b542092 100644 --- a/esphome/components/endstop/endstop_cover.cpp +++ b/esphome/components/endstop/endstop_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::endstop { -static const char *const TAG = "endstop.cover"; +ESPHOME_LOG_TAG(TAG, "endstop.cover"); using namespace esphome::cover; diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index 20c67c34505..b06182e59c0 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -20,7 +20,7 @@ namespace esphome::ens160_base { -static const char *const TAG = "ens160"; +ESPHOME_LOG_TAG(TAG, "ens160"); // Datasheet specifies 10ms, but some users report that 10ms is not sufficient for the // sensor to boot and be ready for commands. 11ms seems to be a safe value. diff --git a/esphome/components/ens160_i2c/ens160_i2c.cpp b/esphome/components/ens160_i2c/ens160_i2c.cpp index 1f02ddb7182..b65855b3733 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.cpp +++ b/esphome/components/ens160_i2c/ens160_i2c.cpp @@ -7,7 +7,7 @@ namespace esphome::ens160_i2c { -static const char *const TAG = "ens160_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "ens160_i2c.sensor"); bool ENS160I2CComponent::read_byte(uint8_t a_register, uint8_t *data) { return I2CDevice::read_byte(a_register, data); diff --git a/esphome/components/ens160_spi/ens160_spi.cpp b/esphome/components/ens160_spi/ens160_spi.cpp index 41ab8298dbb..fe46c22c604 100644 --- a/esphome/components/ens160_spi/ens160_spi.cpp +++ b/esphome/components/ens160_spi/ens160_spi.cpp @@ -6,7 +6,7 @@ namespace esphome::ens160_spi { -static const char *const TAG = "ens160_spi.sensor"; +ESPHOME_LOG_TAG(TAG, "ens160_spi.sensor"); inline uint8_t reg_read(uint8_t reg) { return (reg << 1) | 0x01; } diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 11b73afe377..fa1d4594070 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -12,7 +12,7 @@ namespace esphome::ens210 { -static const char *const TAG = "ens210"; +ESPHOME_LOG_TAG(TAG, "ens210"); // ENS210 chip constants static const uint8_t ENS210_BOOTING_MS = 2; // Booting time in ms (also after reset, or going to high power) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 1fab038c283..0c1d9b0fafb 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static const char *const TAG = "epaper_spi"; +ESPHOME_LOG_TAG(TAG, "epaper_spi"); static constexpr size_t EPAPER_MAX_CMD_LOG_BYTES = 128; static constexpr const char *const EPAPER_STATE_STRINGS[] = { diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp index 820dc3c77ae..0f7f7549d88 100644 --- a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp @@ -4,7 +4,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.4bpp"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.4bpp"); void EPaper4bpp::fill(Color color) { // If clipping is active, fall back to base implementation diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp index fc0d674246f..60e3245deff 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.inkplate2"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.inkplate2"); // Map RGB to the panel's black/white/red via the shared converter. enum class Inkplate2Color : uint8_t { BLACK, WHITE, RED }; diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp index b81542b6c14..5bffba35c99 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp @@ -7,7 +7,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.inkplate6color"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.inkplate6color"); // Native hardware color codes for this panel's 4-bit color values. enum Inkplate6ColorHex : uint8_t { diff --git a/esphome/components/epaper_spi/epaper_spi_jd79660.cpp b/esphome/components/epaper_spi/epaper_spi_jd79660.cpp index 1cd1087c6b6..0de044e8d76 100644 --- a/esphome/components/epaper_spi/epaper_spi_jd79660.cpp +++ b/esphome/components/epaper_spi/epaper_spi_jd79660.cpp @@ -4,7 +4,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.jd79660"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.jd79660"); /** Pixel color as 2bpp. Must match IC LUT values. */ enum JD79660Color : uint8_t { diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index fffb2b5e842..b6653038c33 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.mono"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.mono"); void EPaperMono::refresh_screen(bool partial) { ESP_LOGV(TAG, "Refresh screen"); diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index f47d37d5504..a2fe9bd6418 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -4,7 +4,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.6c"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.6c"); // Native hardware color codes for this panel's 4-bit color values. enum E6Color : uint8_t { diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp index e845a5d5858..a9245fa1c2b 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -6,7 +6,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.ssd1677"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1677"); void EPaperSSD1677::setup() { EPaperMono::setup(); diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp index 18fbb016f3b..34120c908b6 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp @@ -4,7 +4,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.ssd1677_gray4"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1677_gray4"); // Combine two source bytes (4 pixels each, 2 bits per pixel, most significant pixel first) into // one plane byte covering the same 8 pixels (1 bit per pixel), choosing high or low bit diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp index 6fb7e1ac1a6..9b6acf412a3 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.mono"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.mono"); void EPaperSSD1683::refresh_screen(bool partial) { ESP_LOGV(TAG, "Refresh screen"); diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index 95d1fcb4846..bd5a4c9b6e2 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.t133a01"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.t133a01"); // Color indices used in the 4bpp buffer (sprite-side) // These MUST match the Arduino GFX TFT_eSPI.h color definitions and diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp index 2a4ff2969aa..328cd833299 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.uc8179"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8179"); bool EPaperUC8179::initialise(bool partial) { EPaperBase::initialise(partial); // send the model init sequence diff --git a/esphome/components/epaper_spi/epaper_waveshare.cpp b/esphome/components/epaper_spi/epaper_waveshare.cpp index 7a7b4e22d3a..d4c7ba5fdb5 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare.cpp @@ -2,7 +2,7 @@ namespace esphome::epaper_spi { -static const char *const TAG = "epaper_spi.waveshare"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.waveshare"); bool EpaperWaveshare::initialise(bool partial) { EPaperBase::initialise(partial); diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index ad2021ed64f..90cf6af7b2f 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -3,7 +3,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_weact_3c"; +ESPHOME_LOG_TAG(TAG, "epaper_weact_3c"); enum class BwrState : uint8_t { BWR_BLACK, diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 5afc22aec4f..0354f719ef5 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -6,7 +6,7 @@ namespace esphome::es7210 { -static const char *const TAG = "es7210"; +ESPHOME_LOG_TAG(TAG, "es7210"); static const size_t MCLK_DIV_FRE = 256; diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index fc3cba7ae42..1bfa6933ddc 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -8,7 +8,7 @@ namespace esphome::es7243e { -static const char *const TAG = "es7243e"; +ESPHOME_LOG_TAG(TAG, "es7243e"); // Mark the component as failed; use only in setup #define ES7243E_ERROR_FAILED(func) \ diff --git a/esphome/components/es8156/es8156.cpp b/esphome/components/es8156/es8156.cpp index 03d9713df95..467402e704c 100644 --- a/esphome/components/es8156/es8156.cpp +++ b/esphome/components/es8156/es8156.cpp @@ -6,7 +6,7 @@ namespace esphome::es8156 { -static const char *const TAG = "es8156"; +ESPHOME_LOG_TAG(TAG, "es8156"); // Mark the component as failed; use only in setup #define ES8156_ERROR_FAILED(func) \ diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 9a4da3744a1..96b644ee780 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -6,7 +6,7 @@ namespace esphome::es8311 { -static const char *const TAG = "es8311"; +ESPHOME_LOG_TAG(TAG, "es8311"); // Mark the component as failed; use only in setup #define ES8311_ERROR_FAILED(func) \ diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 0b972402308..040fd7fe260 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -6,7 +6,7 @@ namespace esphome::es8388 { -static const char *const TAG = "es8388"; +ESPHOME_LOG_TAG(TAG, "es8388"); // Mark the component as failed; use only in setup #define ES8388_ERROR_FAILED(func) \ diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b72a2777c7a..78326249add 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -161,7 +161,7 @@ static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-c namespace esphome::esp32 { -static const char *const TAG = "esp32.crash"; +ESPHOME_LOG_TAG(TAG, "esp32.crash"); // RAM copy of the build timestamp. The generated constant lives in flash, // which the panic handler must not read (cache may be disabled during diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index a00c984f764..6e4de6aa515 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -23,7 +23,7 @@ namespace esphome { namespace esp32 { -static const char *const TAG = "esp32"; +ESPHOME_LOG_TAG(TAG, "esp32"); static const gpio_hal_context_t GPIO_HAL = {.dev = GPIO_HAL_GET_HW(GPIO_PORT_0)}; diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index 91b4241211d..88fccea0ca8 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -15,7 +15,7 @@ namespace esphome { -static const char *const TAG = "esp32"; +ESPHOME_LOG_TAG(TAG, "esp32"); bool random_bytes(uint8_t *data, size_t len) { esp_fill_random(data, len); diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index f3d5844cd7a..02978ae0f7a 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -12,7 +12,7 @@ namespace esphome::esp32 { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a44886b8c81..b5051c63e17 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -38,7 +38,7 @@ extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-nam namespace esphome::esp32_ble { -static const char *const TAG = "esp32_ble"; +ESPHOME_LOG_TAG(TAG, "esp32_ble"); #ifdef CONFIG_BT_CONTROLLER_DISABLED // Bringing up the remote BT controller issues synchronous RPCs to the diff --git a/esphome/components/esp32_ble/ble_advertising.cpp b/esphome/components/esp32_ble/ble_advertising.cpp index 68704e49e2e..30d21ff9f40 100644 --- a/esphome/components/esp32_ble/ble_advertising.cpp +++ b/esphome/components/esp32_ble/ble_advertising.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_ble { -static const char *const TAG = "esp32_ble.advertising"; +ESPHOME_LOG_TAG(TAG, "esp32_ble.advertising"); BLEAdvertising::BLEAdvertising(uint32_t advertising_cycle_time) : advertising_cycle_time_(advertising_cycle_time) { this->advertising_data_.set_scan_rsp = false; diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 47e4b7ebb20..7481d2c612c 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -18,7 +18,7 @@ namespace esphome::esp32_ble_beacon { -static const char *const TAG = "esp32_ble_beacon"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_beacon"); static const esp_ble_ibeacon_head_t IBEACON_COMMON_HEAD = { .flags = {0x02, 0x01, 0x06}, .length = 0x1A, .type = 0xFF, .company_id = {0x4C, 0x00}, .beacon_type = {0x02, 0x15}}; diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index e830702f114..fceca5f558a 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_ble_client { -static const char *const TAG = "esp32_ble_client"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_client"); BLECharacteristic::~BLECharacteristic() { for (auto &desc : this->descriptors) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 88454f7bdbf..68fccac2880 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_ble_client { -static const char *const TAG = "esp32_ble_client"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_client"); // Connection parameters are shared with the other GATT client backends // (ble_device_base/ble_client_state.h) so the platforms cannot drift. diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index 695f468c5b9..40e9f81d23b 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -8,7 +8,7 @@ namespace esphome::esp32_ble_client { -static const char *const TAG = "esp32_ble_client"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_client"); BLECharacteristic *BLEService::get_characteristic(espbt::ESPBTUUID uuid) { if (!this->parsed) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 4d364b46556..75a33993797 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server.characteristic"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server.characteristic"); BLECharacteristic::~BLECharacteristic() { for (auto *descriptor : this->descriptors_) { diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 3dcac3691c2..228313e2d8e 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server.descriptor"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server.descriptor"); static RAMAllocator descriptor_allocator{}; // NOLINT diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index e34a57cf37f..650a765b6a5 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -18,7 +18,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server"); void BLEServer::setup() { if (this->parent_->is_failed()) { diff --git a/esphome/components/esp32_ble_server/ble_service.cpp b/esphome/components/esp32_ble_server/ble_service.cpp index ac8d7fa003c..c8f4ecbf332 100644 --- a/esphome/components/esp32_ble_server/ble_service.cpp +++ b/esphome/components/esp32_ble_server/ble_service.cpp @@ -6,7 +6,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server.service"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server.service"); BLEService::BLEService(ESPBTUUID uuid, uint16_t num_handles, uint8_t inst_id, bool advertise) : uuid_(uuid), num_handles_(num_handles), inst_id_(inst_id), advertise_(advertise) {} diff --git a/esphome/components/esp32_ble_tracker/bluedroid_stubs.cpp b/esphome/components/esp32_ble_tracker/bluedroid_stubs.cpp index 5cedc22d069..cffa57a518f 100644 --- a/esphome/components/esp32_ble_tracker/bluedroid_stubs.cpp +++ b/esphome/components/esp32_ble_tracker/bluedroid_stubs.cpp @@ -15,7 +15,7 @@ #include "esphome/core/log.h" namespace esphome::esp32_ble_tracker { -static const char *const TAG = "esp32_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_tracker"); } // namespace esphome::esp32_ble_tracker static_assert(ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 2, 0), diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 9bb90026e3f..8d3148c2fc7 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -32,7 +32,7 @@ namespace esphome::esp32_ble_tracker { -static const char *const TAG = "esp32_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_tracker"); ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 03fdbc4de7c..ef29fc9b25d 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_camera { -static const char *const TAG = "esp32_camera"; +ESPHOME_LOG_TAG(TAG, "esp32_camera"); static constexpr size_t FRAMEBUFFER_TASK_STACK_SIZE = 1792; #if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE static constexpr uint32_t FRAME_LOG_INTERVAL_MS = 60000; diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index bee231d132d..36c1cdfa45b 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -16,7 +16,7 @@ namespace esphome::esp32_camera_web_server { static const uint32_t IMAGE_REQUEST_TIMEOUT = 5000; // How often streaming_handler_ reports its throughput. static const uint32_t STREAM_STATS_INTERVAL = 5000; -static const char *const TAG = "esp32_camera_web_server"; +ESPHOME_LOG_TAG(TAG, "esp32_camera_web_server"); #define PART_BOUNDARY "123456789000000000000987654321" #define CONTENT_TYPE "image/jpeg" diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index e04405c63c0..083c97f8cfe 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_can { -static const char *const TAG = "esp32_can"; +ESPHOME_LOG_TAG(TAG, "esp32_can"); static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config) { switch (bitrate) { diff --git a/esphome/components/esp32_dac/esp32_dac.cpp b/esphome/components/esp32_dac/esp32_dac.cpp index 54b89c46ad0..3517941de1e 100644 --- a/esphome/components/esp32_dac/esp32_dac.cpp +++ b/esphome/components/esp32_dac/esp32_dac.cpp @@ -12,7 +12,7 @@ static constexpr uint8_t DAC0_PIN = 17; static constexpr uint8_t DAC0_PIN = 25; #endif -static const char *const TAG = "esp32_dac"; +ESPHOME_LOG_TAG(TAG, "esp32_dac"); void ESP32DAC::setup() { this->pin_->setup(); diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp index ad29b208feb..03b03fd983f 100644 --- a/esphome/components/esp32_hosted/esp_now_hosted.cpp +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -45,7 +45,7 @@ extern "C" { namespace { -const char *const TAG = "esp_now_hosted"; +ESPHOME_LOG_TAG(TAG, "esp_now_hosted"); // One outstanding request at a time. ESPHome drives esp_now_* from the main // loop; the matching response and the async RECV/SEND events all arrive on the diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index c35301a6852..7b9056bc809 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -22,7 +22,7 @@ extern "C" { namespace esphome::esp32_hosted { -static const char *const TAG = "esp32_hosted.update"; +ESPHOME_LOG_TAG(TAG, "esp32_hosted.update"); // Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 7cac1dfb41c..420ff64ddc6 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_rmt_led_strip { -static const char *const TAG = "esp32_rmt_led_strip"; +ESPHOME_LOG_TAG(TAG, "esp32_rmt_led_strip"); static const size_t RMT_SYMBOLS_PER_BYTE = 8; diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index 54bbbe52ed8..ed7d0bd7cb2 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -13,7 +13,7 @@ template static const char *lookup_str(const char *const (&table)[N], return (index < N) ? table[index] : "UNKNOWN"; } -static const char *const TAG = "esp32_touch"; +ESPHOME_LOG_TAG(TAG, "esp32_touch"); static constexpr uint32_t SETUP_MODE_LOG_INTERVAL_MS = 250; static constexpr uint32_t INITIAL_STATE_DELAY_MS = 1500; diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index dc79043f217..1ba96eaebb2 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -82,7 +82,7 @@ static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC block namespace esphome::esp8266 { -static const char *const TAG = "esp8266"; +ESPHOME_LOG_TAG(TAG, "esp8266"); static inline bool is_crash_reason(uint32_t reason) { return reason == REASON_WDT_RST || reason == REASON_EXCEPTION_RST || reason == REASON_SOFT_WDT_RST; diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index 66ac122f000..ae09bdb91c3 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -5,7 +5,7 @@ namespace esphome::esp8266 { -static const char *const TAG = "esp8266"; +ESPHOME_LOG_TAG(TAG, "esp8266"); static int flags_to_mode(gpio::Flags flags, uint8_t pin) { flags = diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index d954ae4a0f3..b731187f9c3 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -14,7 +14,7 @@ extern "C" { namespace esphome::esp8266 { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.cpp b/esphome/components/esp8266_pwm/esp8266_pwm.cpp index b5b3d5073aa..c01a69ce10d 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.cpp +++ b/esphome/components/esp8266_pwm/esp8266_pwm.cpp @@ -10,7 +10,7 @@ namespace esphome::esp8266_pwm { -static const char *const TAG = "esp8266_pwm"; +ESPHOME_LOG_TAG(TAG, "esp8266_pwm"); void ESP8266PWM::setup() { this->pin_->setup(); diff --git a/esphome/components/esp_ldo/esp_ldo.cpp b/esphome/components/esp_ldo/esp_ldo.cpp index b3c9a59865d..e978dd12b02 100644 --- a/esphome/components/esp_ldo/esp_ldo.cpp +++ b/esphome/components/esp_ldo/esp_ldo.cpp @@ -5,7 +5,7 @@ namespace esphome::esp_ldo { -static const char *const TAG = "esp_ldo"; +ESPHOME_LOG_TAG(TAG, "esp_ldo"); void EspLdo::setup() { esp_ldo_channel_config_t config{}; config.chan_id = this->channel_; diff --git a/esphome/components/espectre/espectre.cpp b/esphome/components/espectre/espectre.cpp index 0cce6a3b94d..c758a3b2466 100644 --- a/esphome/components/espectre/espectre.cpp +++ b/esphome/components/espectre/espectre.cpp @@ -13,7 +13,7 @@ namespace esphome::espectre { -static const char *const TAG = "espectre"; +ESPHOME_LOG_TAG(TAG, "espectre"); struct TrafficModeOption { const char *name; diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.cpp b/esphome/components/espectre/sensor/espectre_diagnostics.cpp index 0edc3c07284..bb6024a2332 100644 --- a/esphome/components/espectre/sensor/espectre_diagnostics.cpp +++ b/esphome/components/espectre/sensor/espectre_diagnostics.cpp @@ -7,7 +7,7 @@ namespace esphome::espectre { -static const char *const TAG = "espectre.sensor"; +ESPHOME_LOG_TAG(TAG, "espectre.sensor"); static void publish_diagnostic(sensor::Sensor *sensor, const ::espectre::RuntimeDiagnosticsSample *sample, float ::espectre::RuntimeDiagnosticsSample::*field, float scale = 1.0f) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index c399c0901c6..318e1e5282e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -30,7 +30,7 @@ namespace esphome { -static const char *const TAG = "esphome.ota"; +ESPHOME_LOG_TAG(TAG, "esphome.ota"); #ifdef USE_OTA_ENCRYPTION const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { diff --git a/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp b/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp index f3005cac488..38b58b0b03d 100644 --- a/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp +++ b/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp @@ -7,7 +7,7 @@ namespace esphome { -static const char *const TAG = "esphome.ota"; +ESPHOME_LOG_TAG(TAG, "esphome.ota"); // The window doubles as the output buffer; flushed bytes stay as back // reference history for the next windowful. diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index b3d8501f100..a271ef9d39d 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -14,7 +14,7 @@ namespace esphome { -static const char *const TAG = "esphome.ota"; +ESPHOME_LOG_TAG(TAG, "esphome.ota"); #ifdef USE_ESP8266 static constexpr char OTA_NOISE_PROLOGUE_INIT[] PROGMEM = "NoiseOTAInit"; diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index ecf0f79e4a7..3fd468764af 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -26,7 +26,7 @@ namespace esphome::espnow { -static constexpr const char *TAG = "espnow"; +ESPHOME_LOG_TAG(TAG, "espnow"); ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index b7686f23d6f..7053229db42 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -7,7 +7,7 @@ namespace esphome::espnow { -static const char *const TAG = "espnow.transport"; +ESPHOME_LOG_TAG(TAG, "espnow.transport"); bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); } diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index e9281453c90..1568d37b114 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -77,7 +77,7 @@ namespace esphome::ethernet { -static const char *const TAG = "ethernet"; +ESPHOME_LOG_TAG(TAG, "ethernet"); // PHY register size for hex logging static constexpr size_t PHY_REG_SIZE = 2; diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 94d84cc8914..ecb238419ee 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -14,7 +14,7 @@ namespace esphome::ethernet { -static const char *const TAG = "ethernet"; +ESPHOME_LOG_TAG(TAG, "ethernet"); void EthernetComponent::setup() { // Configure SPI pins diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp index 15ef6a1f205..e75d3eb9ae6 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::ethernet_info { -static const char *const TAG = "ethernet_info"; +ESPHOME_LOG_TAG(TAG, "ethernet_info"); #ifdef USE_ETHERNET_IP_STATE_LISTENERS void IPAddressEthernetInfo::setup() { ethernet::global_eth_component->add_ip_state_listener(this); } diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 00bb9497f96..3170c7a8dd0 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -5,7 +5,7 @@ namespace esphome::event { -static const char *const TAG = "event"; +ESPHOME_LOG_TAG(TAG, "event"); void Event::trigger(const char *event_type) { // Linear search with strcmp - faster than std::set for small datasets (1-5 items typical) diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp index 243e2cf31e1..ff4eded9cf3 100644 --- a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::exponential_moving_average { -static const char *const TAG = "exponential_moving_average"; +ESPHOME_LOG_TAG(TAG, "exponential_moving_average"); const LogString *time_weighting_to_string(TimeWeighting weighting) { switch (weighting) { diff --git a/esphome/components/exposure_notifications/exposure_notifications.cpp b/esphome/components/exposure_notifications/exposure_notifications.cpp index 4f4b93b59cc..063d7ba6789 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.cpp +++ b/esphome/components/exposure_notifications/exposure_notifications.cpp @@ -6,7 +6,7 @@ namespace esphome::exposure_notifications { using namespace ble_device_base; -static const char *const TAG = "exposure_notifications"; +ESPHOME_LOG_TAG(TAG, "exposure_notifications"); bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) { // See also https://blog.google/documents/70/Exposure_Notification_-_Bluetooth_Specification_v1.2.2.pdf diff --git a/esphome/components/ezo_pmp/ezo_pmp.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index 21307fcd7af..8db57670aea 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -4,7 +4,7 @@ namespace esphome::ezo_pmp { -static const char *const TAG = "ezo-pmp"; +ESPHOME_LOG_TAG(TAG, "ezo-pmp"); static const uint16_t EZO_PMP_COMMAND_NONE = 0; static const uint16_t EZO_PMP_COMMAND_TYPE_READ = 1; diff --git a/esphome/components/factory_reset/button/factory_reset_button.cpp b/esphome/components/factory_reset/button/factory_reset_button.cpp index efce998d9d1..8ef7dc67eb4 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.cpp +++ b/esphome/components/factory_reset/button/factory_reset_button.cpp @@ -11,7 +11,7 @@ namespace esphome::factory_reset { -static const char *const TAG = "factory_reset.button"; +ESPHOME_LOG_TAG(TAG, "factory_reset.button"); void FactoryResetButton::dump_config() { LOG_BUTTON("", "Factory Reset Button", this); } void FactoryResetButton::press_action() { diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index bceaf6e40f5..1de2e3f5320 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -11,7 +11,7 @@ namespace esphome::factory_reset { -static const char *const TAG = "factory_reset"; +ESPHOME_LOG_TAG(TAG, "factory_reset"); static const uint32_t POWER_CYCLES_KEY = 0xFA5C0DE; static bool was_power_cycled() { diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.cpp b/esphome/components/factory_reset/switch/factory_reset_switch.cpp index 65404a35d57..5f38a44654c 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.cpp +++ b/esphome/components/factory_reset/switch/factory_reset_switch.cpp @@ -11,7 +11,7 @@ namespace esphome::factory_reset { -static const char *const TAG = "factory_reset.switch"; +ESPHOME_LOG_TAG(TAG, "factory_reset.switch"); void FactoryResetSwitch::dump_config() { LOG_SWITCH("", "Factory Reset Switch", this); } void FactoryResetSwitch::write_state(bool state) { diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 65521e63d51..866f5eb0cda 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -6,7 +6,7 @@ namespace esphome::fan { -static const char *const TAG = "fan"; +ESPHOME_LOG_TAG(TAG, "fan"); // Compat: shared empty vector for getter when no preset modes are set. // Remove in 2026.11.0 when deprecated FanTraits setters are removed diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index da4dbf2ed77..38c594a7c7b 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -5,7 +5,7 @@ namespace esphome::fastled_base { -static const char *const TAG = "fastled"; +ESPHOME_LOG_TAG(TAG, "fastled"); void FastLEDLightOutput::setup() { this->controller_->init(); diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 4baffc74f83..fb6c3929b04 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::feedback { -static const char *const TAG = "feedback.cover"; +ESPHOME_LOG_TAG(TAG, "feedback.cover"); static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1; diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 07630f121ac..99e88af5b74 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -5,7 +5,7 @@ namespace esphome::fingerprint_grow { -static const char *const TAG = "fingerprint_grow"; +ESPHOME_LOG_TAG(TAG, "fingerprint_grow"); // Based on Adafruit's library: https://github.com/adafruit/Adafruit-Fingerprint-Sensor-Library diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index 2592370b6ad..8f7ad6e71f2 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -6,7 +6,7 @@ #include "esphome/components/unicode/unicode.h" namespace esphome::font { -static const char *const TAG = "font"; +ESPHOME_LOG_TAG(TAG, "font"); #ifdef USE_LVGL_FONT static const uint8_t OPA4_TABLE[16] = {0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255}; diff --git a/esphome/components/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index a89b1661828..81d6647642e 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -3,7 +3,7 @@ namespace esphome::fs3000 { -static const char *const TAG = "fs3000"; +ESPHOME_LOG_TAG(TAG, "fs3000"); void FS3000Component::setup() { if (model_ == FIVE) { diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 24d3529fb4c..ac5f4d89338 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::ft5x06 { -static const char *const TAG = "ft5x06.touchscreen"; +ESPHOME_LOG_TAG(TAG, "ft5x06.touchscreen"); void FT5x06Touchscreen::setup() { if (this->interrupt_pin_ != nullptr) { diff --git a/esphome/components/ft63x6/ft63x6.cpp b/esphome/components/ft63x6/ft63x6.cpp index dd8f5ba629a..85729c17f68 100644 --- a/esphome/components/ft63x6/ft63x6.cpp +++ b/esphome/components/ft63x6/ft63x6.cpp @@ -25,7 +25,7 @@ static const uint8_t FT6X36_ADDR_THRESHHOLD = 0x80; static const uint8_t FT6X36_ADDR_TOUCHRATE_ACTIVE = 0x88; static const uint8_t FT63X6_ADDR_CHIP_ID = 0xA3; -static const char *const TAG = "FT63X6"; +ESPHOME_LOG_TAG(TAG, "FT63X6"); void FT63X6Touchscreen::setup() { if (this->interrupt_pin_ != nullptr) { diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 9995a3abbd9..9a928dac01b 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -2,7 +2,7 @@ namespace esphome::fujitsu_general { -static const char *const TAG = "fujitsu_general.climate"; +ESPHOME_LOG_TAG(TAG, "fujitsu_general.climate"); // Common header constexpr uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index e84c51bde8a..fcb9e832ea8 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -11,7 +11,7 @@ namespace esphome::gcja5 { -static const char *const TAG = "gcja5"; +ESPHOME_LOG_TAG(TAG, "gcja5"); void GCJA5Component::loop() { const uint32_t now = App.get_loop_component_start_time(); diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index bc0ef70578f..3c7e8d21613 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -4,7 +4,7 @@ namespace esphome::gdk101 { -static const char *const TAG = "gdk101"; +ESPHOME_LOG_TAG(TAG, "gdk101"); static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5; static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 30; static constexpr uint32_t RESET_INTERVAL_ID = 0; diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp index 75f0c69a7b1..6e2844eb9e8 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::gl_r01_i2c { -static const char *const TAG = "gl_r01_i2c"; +ESPHOME_LOG_TAG(TAG, "gl_r01_i2c"); // Register definitions from datasheet static const uint8_t REG_VERSION = 0x00; diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp index 0dd4a13a212..552261155ff 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp @@ -6,7 +6,7 @@ namespace esphome::gp2y1010au0f { -static const char *const TAG = "gp2y1010au0f"; +ESPHOME_LOG_TAG(TAG, "gp2y1010au0f"); static const float MIN_VOLTAGE = 0.0f; static const float MAX_VOLTAGE = 4.0f; diff --git a/esphome/components/gp8403/gp8403.cpp b/esphome/components/gp8403/gp8403.cpp index 7e93bb6295d..b82c32c053e 100644 --- a/esphome/components/gp8403/gp8403.cpp +++ b/esphome/components/gp8403/gp8403.cpp @@ -4,7 +4,7 @@ namespace esphome::gp8403 { -static const char *const TAG = "gp8403"; +ESPHOME_LOG_TAG(TAG, "gp8403"); static const uint8_t RANGE_REGISTER = 0x01; static const uint8_t OUTPUT_REGISTER = 0x02; diff --git a/esphome/components/gp8403/output/gp8403_output.cpp b/esphome/components/gp8403/output/gp8403_output.cpp index 7a22a280ac7..92872f72f21 100644 --- a/esphome/components/gp8403/output/gp8403_output.cpp +++ b/esphome/components/gp8403/output/gp8403_output.cpp @@ -4,7 +4,7 @@ namespace esphome::gp8403 { -static const char *const TAG = "gp8403.output"; +ESPHOME_LOG_TAG(TAG, "gp8403.output"); void GP8403Output::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 9d044dca2d0..87985f189cc 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::gpio { -static const char *const TAG = "gpio.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "gpio.binary_sensor"); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG #ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index f445efeca37..62b197d13c5 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -4,7 +4,7 @@ namespace esphome::gpio { -static const char *const TAG = "gpio.one_wire"; +ESPHOME_LOG_TAG(TAG, "gpio.one_wire"); void GPIOOneWireBus::setup() { this->t_pin_->setup(); diff --git a/esphome/components/gpio/output/gpio_binary_output.cpp b/esphome/components/gpio/output/gpio_binary_output.cpp index 402ad03c49a..b407ea3f4b1 100644 --- a/esphome/components/gpio/output/gpio_binary_output.cpp +++ b/esphome/components/gpio/output/gpio_binary_output.cpp @@ -3,7 +3,7 @@ namespace esphome::gpio { -static const char *const TAG = "gpio.output"; +ESPHOME_LOG_TAG(TAG, "gpio.output"); void GPIOBinaryOutput::dump_config() { ESP_LOGCONFIG(TAG, "Binary Output:"); diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d231b3d77a5..4daea8d877b 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::gpio { -static const char *const TAG = "switch.gpio"; +ESPHOME_LOG_TAG(TAG, "switch.gpio"); #ifdef USE_GPIO_SWITCH_INTERLOCK static constexpr uint32_t INTERLOCK_TIMEOUT_ID = 0; #endif diff --git a/esphome/components/gps/gps.cpp b/esphome/components/gps/gps.cpp index 4b8abd219b0..ec54ac97480 100644 --- a/esphome/components/gps/gps.cpp +++ b/esphome/components/gps/gps.cpp @@ -3,7 +3,7 @@ namespace esphome::gps { -static const char *const TAG = "gps"; +ESPHOME_LOG_TAG(TAG, "gps"); TinyGPSPlus &GPSListener::get_tiny_gps() { return this->parent_->get_tiny_gps(); } diff --git a/esphome/components/gps/time/gps_time.cpp b/esphome/components/gps/time/gps_time.cpp index 3859983ceb7..958d0645d90 100644 --- a/esphome/components/gps/time/gps_time.cpp +++ b/esphome/components/gps/time/gps_time.cpp @@ -3,7 +3,7 @@ namespace esphome::gps { -static const char *const TAG = "gps.time"; +ESPHOME_LOG_TAG(TAG, "gps.time"); void GPSTime::from_tiny_gps_(TinyGPSPlus &tiny_gps) { if (!tiny_gps.time.isValid() || !tiny_gps.date.isValid() || !tiny_gps.time.isUpdated() || diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 9ceb2f2ba01..e09eb385e32 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -9,8 +9,8 @@ namespace esphome::graph { using namespace display; -static const char *const TAG = "graph"; -static const char *const TAGL = "graphlegend"; +ESPHOME_LOG_TAG(TAG, "graph"); +ESPHOME_LOG_TAG(TAGL, "graphlegend"); void HistoryData::init(int length) { this->length_ = length; diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index d261c488557..8761e5fb4cc 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -7,7 +7,7 @@ namespace esphome::graphical_display_menu { -static const char *const TAG = "graphical_display_menu"; +ESPHOME_LOG_TAG(TAG, "graphical_display_menu"); void GraphicalDisplayMenu::setup() { if (this->display_ != nullptr) { diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index e59f186521b..f47be4aea03 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -3,7 +3,7 @@ namespace esphome::gree { -static const char *const TAG = "gree.climate"; +ESPHOME_LOG_TAG(TAG, "gree.climate"); void GreeClimate::set_model(Model model) { if (model == GREE_YAN) { diff --git a/esphome/components/gree/switch/gree_switch.cpp b/esphome/components/gree/switch/gree_switch.cpp index 2f649733afc..0d8f7695809 100644 --- a/esphome/components/gree/switch/gree_switch.cpp +++ b/esphome/components/gree/switch/gree_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::gree { -static const char *const TAG = "gree.switch"; +ESPHOME_LOG_TAG(TAG, "gree.switch"); void GreeModeBitSwitch::setup() { auto initial = this->get_initial_state_with_restore_mode(); diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index 0de0b02182f..b01dd119e63 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -4,7 +4,7 @@ namespace esphome::grove_gas_mc_v2 { -static const char *const TAG = "grove_gas_mc_v2"; +ESPHOME_LOG_TAG(TAG, "grove_gas_mc_v2"); // I2C Commands for Grove Gas Multichannel V2 Sensor // Taken from: diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index 2c68eef623c..80de3451d77 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -4,7 +4,7 @@ namespace esphome::grove_tb6612fng { -static const char *const TAG = "GroveMotorDriveTB6612FNG"; +ESPHOME_LOG_TAG(TAG, "GroveMotorDriveTB6612FNG"); static const uint8_t GROVE_MOTOR_DRIVER_I2C_CMD_BRAKE = 0x00; static const uint8_t GROVE_MOTOR_DRIVER_I2C_CMD_STOP = 0x01; diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 08c3966ed9b..5c079b42d92 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -5,7 +5,7 @@ namespace esphome::growatt_solar { namespace helpers = modbus::helpers; -static const char *const TAG = "growatt_solar"; +ESPHOME_LOG_TAG(TAG, "growatt_solar"); static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.cpp b/esphome/components/gsl3670/gsl3670_touchscreen.cpp index 9115130f4a0..e518368071c 100644 --- a/esphome/components/gsl3670/gsl3670_touchscreen.cpp +++ b/esphome/components/gsl3670/gsl3670_touchscreen.cpp @@ -4,7 +4,7 @@ namespace esphome::gsl3670 { -static const char *const TAG = "gsl3670.touchscreen"; +ESPHOME_LOG_TAG(TAG, "gsl3670.touchscreen"); static const size_t MAX_TOUCHES = 3; // --------------------------------------------------------------------------- // setup() – mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP: diff --git a/esphome/components/gt911/binary_sensor/gt911_button.cpp b/esphome/components/gt911/binary_sensor/gt911_button.cpp index 7b356f3946d..2a3fc631d60 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.cpp +++ b/esphome/components/gt911/binary_sensor/gt911_button.cpp @@ -3,7 +3,7 @@ namespace esphome::gt911 { -static const char *const TAG = "GT911.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "GT911.binary_sensor"); void GT911Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 8ced2679474..07dd325a2c7 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -6,7 +6,7 @@ namespace esphome::gt911 { -static const char *const TAG = "gt911.touchscreen"; +ESPHOME_LOG_TAG(TAG, "gt911.touchscreen"); static const uint8_t PRIMARY_ADDRESS = 0x5D; // default I2C address for GT911 static const uint8_t SECONDARY_ADDRESS = 0x14; // secondary I2C address for GT911 diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 87f9331d555..65a3ed2616f 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -12,7 +12,7 @@ using namespace esphome::uart; namespace esphome::haier { -static const char *const TAG = "haier.climate"; +ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t COMMUNICATION_TIMEOUT_MS = 60000; constexpr size_t STATUS_REQUEST_INTERVAL_MS = 5000; constexpr size_t PROTOCOL_INITIALIZATION_INTERVAL = 10000; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 0ce4142fd43..5e672c3e65d 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -11,7 +11,7 @@ using namespace esphome::uart; namespace esphome::haier { -static const char *const TAG = "haier.climate"; +ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64; constexpr uint8_t CONTROL_MESSAGE_RETRIES = 5; diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index fdb3b779e2f..01e1cbaeed5 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -9,7 +9,7 @@ using namespace esphome::uart; namespace esphome::haier { -static const char *const TAG = "haier.climate"; +ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr uint8_t CONTROL_MESSAGE_RETRIES = 5; constexpr std::chrono::milliseconds CONTROL_MESSAGE_RETRIES_INTERVAL = std::chrono::milliseconds(500); diff --git a/esphome/components/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index d43dfbb89aa..6abca8c2dde 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -6,7 +6,7 @@ namespace esphome::havells_solar { namespace helpers = modbus::helpers; -static const char *const TAG = "havells_solar"; +ESPHOME_LOG_TAG(TAG, "havells_solar"); static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index fc2a738e8d5..311f82b8505 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::hbridge { -static const char *const TAG = "fan.hbridge"; +ESPHOME_LOG_TAG(TAG, "fan.hbridge"); void HBridgeFan::set_hbridge_levels_(float a_level, float b_level) { this->pin_a_->set_level(a_level); diff --git a/esphome/components/hbridge/switch/hbridge_switch.cpp b/esphome/components/hbridge/switch/hbridge_switch.cpp index c8e472d7aab..837c355f460 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.cpp +++ b/esphome/components/hbridge/switch/hbridge_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::hbridge { -static const char *const TAG = "switch.hbridge"; +ESPHOME_LOG_TAG(TAG, "switch.hbridge"); float HBridgeSwitch::get_setup_priority() const { return setup_priority::HARDWARE; } void HBridgeSwitch::setup() { diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 6a19f977a65..4fbfbc84d70 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -7,7 +7,7 @@ namespace esphome::hc8 { -static const char *const TAG = "hc8"; +ESPHOME_LOG_TAG(TAG, "hc8"); static const std::array HC8_COMMAND_GET_PPM{0x64, 0x69, 0x03, 0x5E, 0x4E}; static const std::array HC8_COMMAND_CALIBRATE_PREAMBLE{0x11, 0x03, 0x03}; diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index 6692345e62e..be0de1bae6e 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -4,7 +4,7 @@ namespace esphome::hdc1080 { -static const char *const TAG = "hdc1080"; +ESPHOME_LOG_TAG(TAG, "hdc1080"); static const uint8_t HDC1080_CMD_CONFIGURATION = 0x02; static const uint8_t HDC1080_CMD_TEMPERATURE = 0x00; diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 946cf4898f1..825d7d3da96 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -5,7 +5,7 @@ namespace esphome::hdc2010 { -static const char *const TAG = "hdc2010"; +ESPHOME_LOG_TAG(TAG, "hdc2010"); // Register addresses static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index bf3a4bc79f1..8210733b4cb 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -4,7 +4,7 @@ namespace esphome::hdc2080 { -static const char *const TAG = "hdc2080"; +ESPHOME_LOG_TAG(TAG, "hdc2080"); // Register map (Table 8-6) static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; // Temperature [7:0] diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index 53d4c7f016b..b45b3b612f1 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -6,7 +6,7 @@ namespace esphome::hdc302x { -static const char *const TAG = "hdc302x.sensor"; +ESPHOME_LOG_TAG(TAG, "hdc302x.sensor"); // Commands (per datasheet Table 7-4) static const uint8_t HDC302X_CMD_SOFT_RESET[2] = {0x30, 0xa2}; diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index 008505e2bbf..7c67c5ffd3f 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -6,7 +6,7 @@ namespace esphome::he60r { -static const char *const TAG = "he60r.cover"; +ESPHOME_LOG_TAG(TAG, "he60r.cover"); static const uint8_t QUERY_BYTE = 0x38; static const uint8_t TOGGLE_BYTE = 0x30; diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 502f83cd5d4..eb23eb8ed50 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -41,7 +41,7 @@ class IRSenderESPHome : public IRSender { remote_base::RemoteTransmitterBase::TransmitCall transmit_; }; -static const char *const TAG = "heatpumpir.climate"; +ESPHOME_LOG_TAG(TAG, "heatpumpir.climate"); const std::map> PROTOCOL_CONSTRUCTOR_MAP = { {PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.cpp b/esphome/components/hitachi_ac344/hitachi_ac344.cpp index ea211a7a59b..c712500a53d 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.cpp +++ b/esphome/components/hitachi_ac344/hitachi_ac344.cpp @@ -2,7 +2,7 @@ namespace esphome::hitachi_ac344 { -static const char *const TAG = "climate.hitachi_ac344"; +ESPHOME_LOG_TAG(TAG, "climate.hitachi_ac344"); void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, const uint8_t data) { if (offset >= 8 || !nbits) diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.cpp b/esphome/components/hitachi_ac424/hitachi_ac424.cpp index cdf8dda22ce..d8f7f661799 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.cpp +++ b/esphome/components/hitachi_ac424/hitachi_ac424.cpp @@ -2,7 +2,7 @@ namespace esphome::hitachi_ac424 { -static const char *const TAG = "climate.hitachi_ac424"; +ESPHOME_LOG_TAG(TAG, "climate.hitachi_ac424"); void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, const uint8_t data) { if (offset >= 8 || !nbits) diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index a9242598027..e4845daa57b 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -5,7 +5,7 @@ namespace esphome::hlk_fm22x { -static const char *const TAG = "hlk_fm22x"; +ESPHOME_LOG_TAG(TAG, "hlk_fm22x"); static constexpr uint32_t PAYLOAD_TIMEOUT_MS = 20; void HlkFm22xComponent::setup() { diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index 9ef81f075d3..a14a9aaf486 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -3,7 +3,7 @@ namespace esphome::hlw8012 { -static const char *const TAG = "hlw8012"; +ESPHOME_LOG_TAG(TAG, "hlw8012"); // valid for HLW8012 and CSE7759 static const uint32_t HLW8012_CLOCK_FREQUENCY = 3579000; diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp index 55e6664a8b8..cbb6b6c628d 100644 --- a/esphome/components/hlw8032/hlw8032.cpp +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -4,7 +4,7 @@ namespace esphome::hlw8032 { -static const char *const TAG = "hlw8032"; +ESPHOME_LOG_TAG(TAG, "hlw8032"); static constexpr uint8_t STATE_REG_OFFSET = 0; static constexpr uint8_t VOLTAGE_PARAM_OFFSET = 2; diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index 02c6e751468..bfed99b21b5 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -3,7 +3,7 @@ namespace esphome::hm3301 { -static const char *const TAG = "hm3301.sensor"; +ESPHOME_LOG_TAG(TAG, "hm3301.sensor"); static const uint8_t PM_1_0_VALUE_INDEX = 5; static const uint8_t PM_2_5_VALUE_INDEX = 6; diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index c6b7da66105..c747e7968ed 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -6,7 +6,7 @@ namespace esphome::hmc5883l { -static const char *const TAG = "hmc5883l"; +ESPHOME_LOG_TAG(TAG, "hmc5883l"); static const uint8_t HMC5883L_ADDRESS = 0x1E; static const uint8_t HMC5883L_REGISTER_CONFIG_A = 0x00; static const uint8_t HMC5883L_REGISTER_CONFIG_B = 0x01; diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp index edce6ce4c2b..db32eb29521 100644 --- a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp.binary_sensor"); void HoermannHcpConnectedBinarySensor::setup() { // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp index 66a141758e8..ab2afc74d93 100644 --- a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -4,7 +4,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp.cover"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp.cover"); cover::CoverTraits HoermannHcpCover::get_traits() { cover::CoverTraits traits; diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 22f0c12c8c2..bbf1380d178 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -9,7 +9,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp"); // Hoermann HCP holding-register blocks. static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp index f7e40b89210..6195c5d63d8 100644 --- a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -4,7 +4,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp.light"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp.light"); light::LightTraits HoermannHcpLight::get_traits() { auto traits = light::LightTraits(); diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp index 765a80ae6f3..cc8d15d7234 100644 --- a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp.text_sensor"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp.text_sensor"); // Indexed by DoorState. Each fits the 15 characters std::string keeps inline, so publishing never allocates. PROGMEM_STRING_TABLE(DoorStateStrings, "Open", "Opening", "Closed", "Closing", "Half open", "Moving to vent", diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp index 735fb9f5da1..d14158399c5 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "homeassistant.binary_sensor"); void HomeassistantBinarySensor::setup() { api::global_api_server->subscribe_home_assistant_state(this->entity_id_, this->attribute_, [this](StringRef state) { diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp index 87280c2b5b1..6cf1c87eaa5 100644 --- a/esphome/components/homeassistant/button/homeassistant_button.cpp +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -9,7 +9,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.button"; +ESPHOME_LOG_TAG(TAG, "homeassistant.button"); void HomeassistantButton::dump_config() { LOG_BUTTON("", "Homeassistant Button", this); diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index e8a679e91cf..df92aad38eb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -9,7 +9,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.number"; +ESPHOME_LOG_TAG(TAG, "homeassistant.number"); void HomeassistantNumber::state_changed_(StringRef state) { auto number_value = parse_number(state.c_str()); diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index d3401de7f0e..189a6b1f376 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -10,7 +10,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.select"; +ESPHOME_LOG_TAG(TAG, "homeassistant.select"); namespace { diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index b79a56953ae..8256dad484f 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.sensor"; +ESPHOME_LOG_TAG(TAG, "homeassistant.sensor"); void HomeassistantSensor::setup() { api::global_api_server->subscribe_home_assistant_state(this->entity_id_, this->attribute_, [this](StringRef state) { diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 79d75c91336..841f16df200 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.switch"; +ESPHOME_LOG_TAG(TAG, "homeassistant.switch"); using namespace esphome::switch_; diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp index 31e4919700f..7155f6ee189 100644 --- a/esphome/components/homeassistant/text/homeassistant_text.cpp +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -10,7 +10,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.text"; +ESPHOME_LOG_TAG(TAG, "homeassistant.text"); void HomeassistantText::state_changed_(StringRef state) { if (state == this->state) { diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp index 40d7455ece1..853b0d9b38c 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.text_sensor"; +ESPHOME_LOG_TAG(TAG, "homeassistant.text_sensor"); void HomeassistantTextSensor::setup() { api::global_api_server->subscribe_home_assistant_state(this->entity_id_, this->attribute_, [this](StringRef state) { diff --git a/esphome/components/homeassistant/time/homeassistant_time.cpp b/esphome/components/homeassistant/time/homeassistant_time.cpp index c08bd73d625..a668d66800b 100644 --- a/esphome/components/homeassistant/time/homeassistant_time.cpp +++ b/esphome/components/homeassistant/time/homeassistant_time.cpp @@ -3,7 +3,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.time"; +ESPHOME_LOG_TAG(TAG, "homeassistant.time"); void HomeassistantTime::dump_config() { ESP_LOGCONFIG(TAG, "Home Assistant Time"); diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index 7f075847fdc..a237f3bd609 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -7,7 +7,7 @@ namespace esphome::honeywell_hih_i2c { -static const char *const TAG = "honeywell_hih.i2c"; +ESPHOME_LOG_TAG(TAG, "honeywell_hih.i2c"); static const uint8_t REQUEST_CMD[1] = {0x00}; // Measurement Request Format static const uint16_t MAX_COUNT = 0x3FFE; // 2^14 - 2 diff --git a/esphome/components/honeywellabp/honeywellabp.cpp b/esphome/components/honeywellabp/honeywellabp.cpp index dd86b95c787..27f3635a739 100644 --- a/esphome/components/honeywellabp/honeywellabp.cpp +++ b/esphome/components/honeywellabp/honeywellabp.cpp @@ -3,7 +3,7 @@ namespace esphome::honeywellabp { -static const char *const TAG = "honeywellabp"; +ESPHOME_LOG_TAG(TAG, "honeywellabp"); const float MIN_COUNT = 1638.4; // 1638 counts (10% of 2^14 counts or 0x0666) const float MAX_COUNT = 14745.6; // 14745 counts (90% of 2^14 counts or 0x3999) diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp index bfab045838d..891eb4447df 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp @@ -9,7 +9,7 @@ static const uint8_t STATUS_BIT_BUSY = 5; static const uint8_t STATUS_BIT_ERROR = 2; static const uint8_t STATUS_MATH_SAT = 0; -static const char *const TAG = "honeywellabp2"; +ESPHOME_LOG_TAG(TAG, "honeywellabp2"); void HONEYWELLABP2Sensor::read_sensor_data() { if (this->read(raw_data_, 7) != i2c::ERROR_OK) { diff --git a/esphome/components/host/gpio.cpp b/esphome/components/host/gpio.cpp index f99b82bcc20..c1fd56e945a 100644 --- a/esphome/components/host/gpio.cpp +++ b/esphome/components/host/gpio.cpp @@ -6,7 +6,7 @@ namespace esphome { namespace host { -static const char *const TAG = "host"; +ESPHOME_LOG_TAG(TAG, "host"); struct ISRPinArg { uint8_t pin; diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 7274d9de57e..9e203efcb9c 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -14,7 +14,7 @@ namespace esphome { -static const char *const TAG = "helpers.host"; +ESPHOME_LOG_TAG(TAG, "helpers.host"); bool random_bytes(uint8_t *data, size_t len) { FILE *fp = fopen("/dev/urandom", "r"); diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index 497b9d11e58..9a8208e96ea 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -9,7 +9,7 @@ namespace esphome::host { namespace fs = std::filesystem; -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); void HostPreferences::setup_() { if (this->setup_complete_) diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index b323dd04360..1d58a4317a6 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -11,7 +11,7 @@ namespace esphome::hrxl_maxsonar_wr { -static const char *const TAG = "hrxl.maxsonar.wr.sensor"; +ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); static const uint8_t ASCII_CR = 0x0D; static const uint8_t ASCII_NBSP = 0xFF; static const int MAX_DATA_LENGTH_BYTES = 6; diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index 67bfb1917eb..e955531b570 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -4,7 +4,7 @@ namespace esphome::hte501 { -static const char *const TAG = "hte501"; +ESPHOME_LOG_TAG(TAG, "hte501"); static constexpr size_t HTE501_SERIAL_NUMBER_SIZE = 7; diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 37e0de5d6a0..e689c82d2fb 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -6,7 +6,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); void HttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 0d968222e9e..b7565e676d5 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -18,7 +18,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); #ifdef USE_ESP8266 // ESP8266 Arduino core (WiFiClientSecureBearSSL.cpp) returns -1000 on OOM static constexpr int ESP8266_SSL_ERR_OOM = -1000; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index a7889702023..c32881dc1f6 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 4e5a2c42b57..1413b843b74 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -18,7 +18,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); static constexpr uint32_t ERROR_DURATION_MS = 1000; void HttpRequestIDF::dump_config() { diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 7e7594c3c3a..a26c366fa61 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -11,7 +11,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.ota"; +ESPHOME_LOG_TAG(TAG, "http_request.ota"); void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); }; diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 6a74c00e8e5..bedb0e98e70 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -22,7 +22,7 @@ namespace esphome::http_request { #define UPDATE_RETURN return #endif -static const char *const TAG = "http_request.update"; +ESPHOME_LOG_TAG(TAG, "http_request.update"); // Wraps UpdateInfo + error for the task→main-loop handoff. struct TaskResult { diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index 8a3b2cb5d5c..468a7f8e6a2 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -4,7 +4,7 @@ namespace esphome::htu21d { -static const char *const TAG = "htu21d"; +ESPHOME_LOG_TAG(TAG, "htu21d"); static const uint8_t HTU21D_ADDRESS = 0x40; static const uint8_t HTU21D_REGISTER_RESET = 0xFE; diff --git a/esphome/components/htu31d/htu31d.cpp b/esphome/components/htu31d/htu31d.cpp index 6821b8b69ef..86c9876b2b6 100644 --- a/esphome/components/htu31d/htu31d.cpp +++ b/esphome/components/htu31d/htu31d.cpp @@ -17,7 +17,7 @@ namespace esphome::htu31d { /** Logging prefix */ -static const char *const TAG = "htu31d"; +ESPHOME_LOG_TAG(TAG, "htu31d"); /** Default I2C address for the HTU31D. */ static const uint8_t HTU31D_DEFAULT_I2CADDR = 0x40; diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index d36928a83af..674e23cb344 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -6,7 +6,7 @@ namespace esphome::hub75 { -static const char *const TAG = "hub75"; +ESPHOME_LOG_TAG(TAG, "hub75"); // ======================================== // Constructor diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index fe2fb4c350b..48afc0192de 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -4,7 +4,7 @@ namespace esphome::hx711 { -static const char *const TAG = "hx711"; +ESPHOME_LOG_TAG(TAG, "hx711"); void HX711Sensor::setup() { this->sck_pin_->setup(); diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 05557111fce..0afeb94bf3b 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -3,7 +3,7 @@ namespace esphome::hydreon_rgxx { -static const char *const TAG = "hydreon_rgxx.sensor"; +ESPHOME_LOG_TAG(TAG, "hydreon_rgxx.sensor"); static const int MAX_DATA_LENGTH_BYTES = 80; static const uint8_t ASCII_LF = 0x0A; #define HYDREON_RGXX_COMMA , diff --git a/esphome/components/hyt271/hyt271.cpp b/esphome/components/hyt271/hyt271.cpp index 7b2c960a5f9..72d3940f30a 100644 --- a/esphome/components/hyt271/hyt271.cpp +++ b/esphome/components/hyt271/hyt271.cpp @@ -4,7 +4,7 @@ namespace esphome::hyt271 { -static const char *const TAG = "hyt271"; +ESPHOME_LOG_TAG(TAG, "hyt271"); static const uint8_t HYT271_ADDRESS = 0x28; diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 76b3ec3b4d9..8fc0a503276 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -7,7 +7,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); void I2CBus::i2c_scan_() { for (uint8_t address = 8; address != 120; address++) { diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 39a6aec7742..95aee2bddb2 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -9,7 +9,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 7ca9537e2de..b13b525abbb 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp index 303944636bf..672bc9cc5d1 100644 --- a/esphome/components/i2c/i2c_bus_host.cpp +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -16,7 +16,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); HostI2CBus::~HostI2CBus() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index ffdd2ba8bb2..4145cd58592 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -6,7 +6,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); static const char *get_speed(uint32_t dev_config) { switch (I2C_SPEED_GET(dev_config)) { diff --git a/esphome/components/i2c_device/i2c_device.cpp b/esphome/components/i2c_device/i2c_device.cpp index 2f68a9e96a5..379a25574e7 100644 --- a/esphome/components/i2c_device/i2c_device.cpp +++ b/esphome/components/i2c_device/i2c_device.cpp @@ -5,7 +5,7 @@ namespace esphome::i2c_device { -static const char *const TAG = "i2c_device"; +ESPHOME_LOG_TAG(TAG, "i2c_device"); void I2CDeviceComponent::dump_config() { ESP_LOGCONFIG(TAG, "I2CDevice"); diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index c577ed092ed..79367d67e08 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -19,7 +19,7 @@ static const uint32_t READ_DURATION_MS = 16; static const size_t TASK_STACK_SIZE = 4096; static const ssize_t TASK_PRIORITY = 23; -static const char *const TAG = "i2s_audio.microphone"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.microphone"); enum MicrophoneEventGroupBits : uint32_t { COMMAND_STOP = (1 << 0), // stops the microphone task, set and cleared by ``loop`` diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index ec4e459be78..5054478ceb2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -14,7 +14,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.spdif"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.spdif"); // SPDIF mode adds overhead as each sample is encapsulated in a subframe; // each DMA buffer can hold only 192 samples (~4ms each vs. ~15ms for standard I2S). diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 4b74b2e29d4..a57ef7f55c0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -18,7 +18,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.speaker"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.speaker"); // Software volume control maps the user-facing (0.0, 1.0) range linearly to a dB reduction in // [-49.0, 0.0] dB; 0.0 is silence. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index b4b6173458b..fbc1edbde93 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -18,7 +18,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.speaker.std"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.speaker.std"); static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; static constexpr size_t DMA_BUFFERS_COUNT = 5; diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 30146e0a70c..c98cbcb20ab 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -6,7 +6,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.spdif_encoder"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.spdif_encoder"); // S/PDIF preamble patterns (8 BMC bits each) // These are the BMC-encoded sync patterns that violate normal BMC rules for easy detection. diff --git a/esphome/components/iaqcore/iaqcore.cpp b/esphome/components/iaqcore/iaqcore.cpp index 7397d0975cf..48935c24681 100644 --- a/esphome/components/iaqcore/iaqcore.cpp +++ b/esphome/components/iaqcore/iaqcore.cpp @@ -5,7 +5,7 @@ namespace esphome::iaqcore { -static const char *const TAG = "iaqcore"; +ESPHOME_LOG_TAG(TAG, "iaqcore"); enum IAQCoreErrorCode : uint8_t { ERROR_OK = 0, ERROR_RUNIN = 0x10, ERROR_BUSY = 0x01, ERROR_ERROR = 0x80 }; diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp index 62a4586ebc3..42585269f84 100644 --- a/esphome/components/icnt86/icnt86.cpp +++ b/esphome/components/icnt86/icnt86.cpp @@ -3,7 +3,7 @@ namespace esphome::icnt86 { -static const char *const TAG = "icnt86"; +ESPHOME_LOG_TAG(TAG, "icnt86"); static constexpr uint16_t REG_TOUCH_NUM = 0x1001; static constexpr uint16_t REG_POINT1 = 0x1002; static constexpr uint8_t MAX_TOUCHES = 5; diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index 6745f8064b1..e25980545cf 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -9,7 +9,7 @@ namespace esphome::improv_base { #ifdef USE_IMPROV_NEXT_URL -static const char *const TAG = "improv_base"; +ESPHOME_LOG_TAG(TAG, "improv_base"); static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; static constexpr size_t DEVICE_NAME_PLACEHOLDER_LEN = sizeof(DEVICE_NAME_PLACEHOLDER) - 1; diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 0a20beb33c8..15adc497764 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -20,7 +20,7 @@ namespace esphome::improv_ble { using namespace bytebuffer; -static const char *const TAG = "improv_ble.component"; +ESPHOME_LOG_TAG(TAG, "improv_ble.component"); static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 3827fb6ed45..ecb7bc98fb4 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -16,7 +16,7 @@ namespace esphome::improv_serial { -static const char *const TAG = "improv_serial"; +ESPHOME_LOG_TAG(TAG, "improv_serial"); void ImprovSerialComponent::setup() { global_improv_serial_component = this; diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index 833d1989c8a..a675099b4dc 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -4,7 +4,7 @@ namespace esphome::ina219 { -static const char *const TAG = "ina219"; +ESPHOME_LOG_TAG(TAG, "ina219"); // | A0 | A1 | Address | // | GND | GND | 0x40 | diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index c22237d144b..97b3caf949b 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -5,7 +5,7 @@ namespace esphome::ina226 { -static const char *const TAG = "ina226"; +ESPHOME_LOG_TAG(TAG, "ina226"); // | A0 | A1 | Address | // | GND | GND | 0x40 | diff --git a/esphome/components/ina260/ina260.cpp b/esphome/components/ina260/ina260.cpp index 05039f0e331..82a4e4fba14 100644 --- a/esphome/components/ina260/ina260.cpp +++ b/esphome/components/ina260/ina260.cpp @@ -4,7 +4,7 @@ namespace esphome::ina260 { -static const char *const TAG = "ina260"; +ESPHOME_LOG_TAG(TAG, "ina260"); // | A0 | A1 | Address | // | GND | GND | 0x40 | diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index fec5cd2f131..58e0598b06c 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -7,7 +7,7 @@ namespace esphome::ina2xx_base { -static const char *const TAG = "ina2xx"; +ESPHOME_LOG_TAG(TAG, "ina2xx"); #define OKFAILED(b) ((b) ? "OK" : "FAILED") diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp index 4fc3b00a215..61c7744cca7 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::ina2xx_i2c { -static const char *const TAG = "ina2xx_i2c"; +ESPHOME_LOG_TAG(TAG, "ina2xx_i2c"); void INA2XXI2C::setup() { auto err = this->write(nullptr, 0); diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.cpp b/esphome/components/ina2xx_spi/ina2xx_spi.cpp index 43eb6762367..b9b7a6c774e 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.cpp +++ b/esphome/components/ina2xx_spi/ina2xx_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::ina2xx_spi { -static const char *const TAG = "ina2xx_spi"; +ESPHOME_LOG_TAG(TAG, "ina2xx_spi"); void INA2XXSPI::setup() { this->spi_setup(); diff --git a/esphome/components/ina3221/ina3221.cpp b/esphome/components/ina3221/ina3221.cpp index b88480fc001..f3647c4d1ca 100644 --- a/esphome/components/ina3221/ina3221.cpp +++ b/esphome/components/ina3221/ina3221.cpp @@ -4,7 +4,7 @@ namespace esphome::ina3221 { -static const char *const TAG = "ina3221"; +ESPHOME_LOG_TAG(TAG, "ina3221"); static const uint8_t INA3221_REGISTER_CONFIG = 0x00; static const uint8_t INA3221_REGISTER_CHANNEL1_SHUNT_VOLTAGE = 0x01; diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 67a40fa3490..9b766a8996c 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -4,7 +4,7 @@ namespace esphome::infrared { -static const char *const TAG = "infrared"; +ESPHOME_LOG_TAG(TAG, "infrared"); void Infrared::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index d360142bcba..ef6600b4ebc 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -3,7 +3,7 @@ namespace esphome::inkbird_ibsth1_mini { -static const char *const TAG = "inkbird_ibsth1_mini"; +ESPHOME_LOG_TAG(TAG, "inkbird_ibsth1_mini"); void InkbirdIbstH1Mini::dump_config() { ESP_LOGCONFIG(TAG, "Inkbird IBS TH1 MINI"); diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 2e837fb614e..b1e8535d206 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -9,7 +9,7 @@ namespace esphome::inkplate { -static const char *const TAG = "inkplate"; +ESPHOME_LOG_TAG(TAG, "inkplate"); void Inkplate::setup() { for (uint32_t i = 0; i < 256; i++) { diff --git a/esphome/components/integration/integration_sensor.cpp b/esphome/components/integration/integration_sensor.cpp index bc26fb0f193..c7b6173e39b 100644 --- a/esphome/components/integration/integration_sensor.cpp +++ b/esphome/components/integration/integration_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::integration { -static const char *const TAG = "integration"; +ESPHOME_LOG_TAG(TAG, "integration"); void IntegrationSensor::setup() { if (this->restore_) { diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index 91f47d831fc..00f3b97ec0a 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -9,7 +9,7 @@ uint32_t temp_single_get_current_temperature(uint32_t *temp_value); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_common.cpp b/esphome/components/internal_temperature/internal_temperature_common.cpp index 89a7d34333b..51c55d4adb5 100644 --- a/esphome/components/internal_temperature/internal_temperature_common.cpp +++ b/esphome/components/internal_temperature/internal_temperature_common.cpp @@ -3,7 +3,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); void InternalTemperatureSensor::dump_config() { LOG_SENSOR("", "Internal Temperature Sensor", this); } diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 2c6fda2af49..4da062ac05a 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -16,7 +16,7 @@ uint8_t temprature_sens_read(); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index c4ab33b0a56..b0ccf991456 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -16,7 +16,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index 50c597f6f1b..5c3e81d10fa 100644 --- a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); diff --git a/esphome/components/ir_rf_base/ir_rf_base.cpp b/esphome/components/ir_rf_base/ir_rf_base.cpp index 9aa845f269e..91db4252c07 100644 --- a/esphome/components/ir_rf_base/ir_rf_base.cpp +++ b/esphome/components/ir_rf_base/ir_rf_base.cpp @@ -13,7 +13,7 @@ namespace esphome::ir_rf_base { -static const char *const TAG = "ir_rf"; +ESPHOME_LOG_TAG(TAG, "ir_rf"); #if defined(USE_API) && defined(USE_IR_RF) // A missing completion is answered as failed this long after the frame should have left the diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 88b15606080..0eae11d1a51 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -6,7 +6,7 @@ namespace esphome::ir_rf_proxy { -static const char *const TAG = "ir_rf_proxy"; +ESPHOME_LOG_TAG(TAG, "ir_rf_proxy"); // ========== IrRfProxy (Infrared platform) ========== diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index a6511b63c41..2cfcf111ea3 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -10,7 +10,7 @@ namespace esphome::it8951 { -static const char *const TAG = "it8951"; +ESPHOME_LOG_TAG(TAG, "it8951"); // Soft cap for time spent in a single XFER_ROWS Op so we yield back to the // loop within one tick budget. diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index c67771a0b60..0c7dd277381 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -6,7 +6,7 @@ namespace esphome::jsn_sr04t { -static const char *const TAG = "jsn_sr04t.sensor"; +ESPHOME_LOG_TAG(TAG, "jsn_sr04t.sensor"); void Jsnsr04tComponent::update() { this->write_byte((this->model_ == AJ_SR04M) ? 0x01 : 0x55); diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 3ecfce8b699..28ba34d6ca5 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -7,7 +7,7 @@ namespace esphome::json { -static const char *const TAG = "json"; +ESPHOME_LOG_TAG(TAG, "json"); #ifdef USE_PSRAM // Global allocator that outlives all JsonDocuments returned by parse_json() diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 24e5d259218..9713c72b613 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -5,7 +5,7 @@ namespace esphome::kamstrup_kmp { -static const char *const TAG = "kamstrup_kmp"; +ESPHOME_LOG_TAG(TAG, "kamstrup_kmp"); void KamstrupKMPComponent::dump_config() { ESP_LOGCONFIG(TAG, "kamstrup_kmp:"); diff --git a/esphome/components/key_collector/key_collector.cpp b/esphome/components/key_collector/key_collector.cpp index 42f02d39d4f..bc201a15146 100644 --- a/esphome/components/key_collector/key_collector.cpp +++ b/esphome/components/key_collector/key_collector.cpp @@ -4,7 +4,7 @@ namespace esphome::key_collector { -static const char *const TAG = "key_collector"; +ESPHOME_LOG_TAG(TAG, "key_collector"); void KeyCollector::loop() { if ((this->timeout_ == 0) || this->result_.empty() || (millis() - this->last_key_time_ < this->timeout_)) diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index d6934a97ac0..8bd8b47e21a 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -5,7 +5,7 @@ namespace esphome::kmeteriso { -static const char *const TAG = "kmeteriso.sensor"; +ESPHOME_LOG_TAG(TAG, "kmeteriso.sensor"); static const uint8_t KMETER_ERROR_STATUS_REG = 0x20; static const uint8_t KMETER_TEMP_VAL_REG = 0x00; diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index cb04afe4377..647227524e6 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -3,7 +3,7 @@ namespace esphome::kuntze { -static const char *const TAG = "kuntze"; +ESPHOME_LOG_TAG(TAG, "kuntze"); static constexpr uint16_t REGISTER_PH = 4136; static constexpr uint16_t REGISTER_TEMPERATURE = 4160; diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index 36e5bce8e3c..a97f5217a85 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -4,7 +4,7 @@ namespace esphome::lc709203f { -static const char *const TAG = "lc709203f.sensor"; +ESPHOME_LOG_TAG(TAG, "lc709203f.sensor"); // Device I2C address. This address is fixed. static const uint8_t LC709203F_I2C_ADDR_DEFAULT = 0x0B; diff --git a/esphome/components/lcd_base/lcd_display.cpp b/esphome/components/lcd_base/lcd_display.cpp index 0890da85c13..101e858bef3 100644 --- a/esphome/components/lcd_base/lcd_display.cpp +++ b/esphome/components/lcd_base/lcd_display.cpp @@ -5,7 +5,7 @@ namespace esphome::lcd_base { -static const char *const TAG = "lcd"; +ESPHOME_LOG_TAG(TAG, "lcd"); // First set bit determines command, bits after that are the data. static const uint8_t LCD_DISPLAY_COMMAND_CLEAR_DISPLAY = 0x01; diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.cpp b/esphome/components/lcd_gpio/gpio_lcd_display.cpp index 213fc8637e7..92ae37eab89 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.cpp +++ b/esphome/components/lcd_gpio/gpio_lcd_display.cpp @@ -3,7 +3,7 @@ namespace esphome::lcd_gpio { -static const char *const TAG = "lcd_gpio"; +ESPHOME_LOG_TAG(TAG, "lcd_gpio"); void GPIOLCDDisplay::setup() { this->rs_pin_->setup(); // OUTPUT diff --git a/esphome/components/lcd_menu/lcd_menu.cpp b/esphome/components/lcd_menu/lcd_menu.cpp index f731817bdb4..7ced6e67cec 100644 --- a/esphome/components/lcd_menu/lcd_menu.cpp +++ b/esphome/components/lcd_menu/lcd_menu.cpp @@ -4,7 +4,7 @@ namespace esphome::lcd_menu { -static const char *const TAG = "lcd_menu"; +ESPHOME_LOG_TAG(TAG, "lcd_menu"); void LCDCharacterMenuComponent::setup() { if (this->display_->is_failed()) { diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.cpp b/esphome/components/lcd_pcf8574/pcf8574_display.cpp index d5fc6835985..fc7f5ff2f50 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.cpp +++ b/esphome/components/lcd_pcf8574/pcf8574_display.cpp @@ -4,7 +4,7 @@ namespace esphome::lcd_pcf8574 { -static const char *const TAG = "lcd_pcf8574"; +ESPHOME_LOG_TAG(TAG, "lcd_pcf8574"); static const uint8_t LCD_DISPLAY_BACKLIGHT_ON = 0x08; static const uint8_t LCD_DISPLAY_BACKLIGHT_OFF = 0x00; diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 914de8e1455..22bd20d1e5a 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -12,7 +12,7 @@ namespace esphome::ld2410 { -static const char *const TAG = "ld2410"; +ESPHOME_LOG_TAG(TAG, "ld2410"); enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 7041b7539fb..bd43d06cc27 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -12,7 +12,7 @@ namespace esphome::ld2412 { -static const char *const TAG = "ld2412"; +ESPHOME_LOG_TAG(TAG, "ld2412"); enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp index 6297cba2e79..87375ccbd97 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "ld2420.binary_sensor"); void LD2420BinarySensor::dump_config() { ESP_LOGCONFIG(TAG, "Binary Sensor:"); diff --git a/esphome/components/ld2420/button/reconfig_buttons.cpp b/esphome/components/ld2420/button/reconfig_buttons.cpp index 1e748e59b8f..85e3411f490 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.cpp +++ b/esphome/components/ld2420/button/reconfig_buttons.cpp @@ -2,7 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "ld2420.button"; +ESPHOME_LOG_TAG(TAG, "ld2420.button"); namespace esphome::ld2420 { diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index e342ead4148..dec7e42de47 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -60,7 +60,7 @@ Gate 0 low thresh = 20 00 uint16_t 0x0020, Threshold value = 60 EA 00 00 uint32_ namespace esphome::ld2420 { -static const char *const TAG = "ld2420"; +ESPHOME_LOG_TAG(TAG, "ld2420"); // Local const's static constexpr uint16_t REFRESH_RATE_MS = 1000; diff --git a/esphome/components/ld2420/number/gate_config_number.cpp b/esphome/components/ld2420/number/gate_config_number.cpp index 998eed21881..491cd857349 100644 --- a/esphome/components/ld2420/number/gate_config_number.cpp +++ b/esphome/components/ld2420/number/gate_config_number.cpp @@ -2,7 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "ld2420.number"; +ESPHOME_LOG_TAG(TAG, "ld2420.number"); namespace esphome::ld2420 { diff --git a/esphome/components/ld2420/select/operating_mode_select.cpp b/esphome/components/ld2420/select/operating_mode_select.cpp index 82939e18d33..6d9bb24cbcd 100644 --- a/esphome/components/ld2420/select/operating_mode_select.cpp +++ b/esphome/components/ld2420/select/operating_mode_select.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.select"; +ESPHOME_LOG_TAG(TAG, "ld2420.select"); void LD2420Select::control(size_t index) { this->publish_state(index); diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.cpp b/esphome/components/ld2420/sensor/ld2420_sensor.cpp index 02b5826f98d..d8690a6898a 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.cpp +++ b/esphome/components/ld2420/sensor/ld2420_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.sensor"; +ESPHOME_LOG_TAG(TAG, "ld2420.sensor"); void LD2420Sensor::dump_config() { ESP_LOGCONFIG(TAG, "Sensor:"); diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp index f7b016c9d9e..a8360b1ccbe 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.text_sensor"; +ESPHOME_LOG_TAG(TAG, "ld2420.text_sensor"); void LD2420TextSensor::dump_config() { ESP_LOGCONFIG(TAG, "Text Sensor:"); diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 4b41d63a88a..b43493c26e9 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -16,7 +16,7 @@ namespace esphome::ld2450 { -static const char *const TAG = "ld2450"; +ESPHOME_LOG_TAG(TAG, "ld2450"); enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index aa34ad9d392..fdf1b15d06c 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -8,7 +8,7 @@ namespace esphome::ld6002b { -static const char *const TAG = "ld6002b"; +ESPHOME_LOG_TAG(TAG, "ld6002b"); static constexpr uint8_t TF_SOF = 0x01; static constexpr uint32_t SETUP_DELAY_MS = 100; diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 62833a76493..18ce19a183b 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -22,7 +22,7 @@ static const uint8_t SETUP_ATTEMPT_COUNT_MAX = 5; namespace esphome::ledc { -static const char *const TAG = "ledc.output"; +ESPHOME_LOG_TAG(TAG, "ledc.output"); static bool ledc_peripheral_reset_done = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const int MAX_RES_BITS = LEDC_TIMER_BIT_MAX - 1; diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index ca88835f695..ff333aacef8 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -5,7 +5,7 @@ namespace esphome::libretiny { -static const char *const TAG = "libretiny.gpio"; +ESPHOME_LOG_TAG(TAG, "libretiny.gpio"); static int IRAM_ATTR flags_to_mode(gpio::Flags flags) { flags = diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index d7d93249c51..9cc7b5ff68d 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -9,7 +9,7 @@ namespace esphome::libretiny { -static const char *const TAG = "libretiny"; +ESPHOME_LOG_TAG(TAG, "libretiny"); void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index d0bd3bf26b3..a868e5042e2 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -8,7 +8,7 @@ namespace esphome::libretiny { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.cpp b/esphome/components/libretiny_pwm/libretiny_pwm.cpp index eea593a39da..ee6fd5bb0e7 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.cpp +++ b/esphome/components/libretiny_pwm/libretiny_pwm.cpp @@ -5,7 +5,7 @@ namespace esphome::libretiny_pwm { -static const char *const TAG = "libretiny.pwm"; +ESPHOME_LOG_TAG(TAG, "libretiny.pwm"); void LibreTinyPWM::write_state(float state) { if (!this->initialized_) { diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index d2f5913f4b5..a6440700fa9 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -3,7 +3,7 @@ namespace esphome::light { -static const char *const TAG = "light.addressable"; +ESPHOME_LOG_TAG(TAG, "light.addressable"); void AddressableLight::call_setup() { this->setup(); diff --git a/esphome/components/light/automation.cpp b/esphome/components/light/automation.cpp index ddac2f9341f..35aa035b26a 100644 --- a/esphome/components/light/automation.cpp +++ b/esphome/components/light/automation.cpp @@ -3,7 +3,7 @@ namespace esphome::light { -static const char *const TAG = "light.automation"; +ESPHOME_LOG_TAG(TAG, "light.automation"); void addressableset_warn_about_scale(const char *field) { ESP_LOGW(TAG, "Lambda for parameter %s of light.addressable_set should return values in range 0-1 instead of 0-255.", diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7e54fccebca..5ad9215eedf 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -8,7 +8,7 @@ namespace esphome::light { -static const char *const TAG = "light"; +ESPHOME_LOG_TAG(TAG, "light"); // Cold-path logger; caller handles the clamp so the in-range hot path avoids // the spill/reload around the call. diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 1b9a7f94c1b..9fe483a291c 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -13,7 +13,7 @@ namespace esphome::light { -static const char *const TAG = "light"; +ESPHOME_LOG_TAG(TAG, "light"); // Colour modes are bitmasks of capabilities. A mode the light doesn't support may be a bare set of // required capabilities (see restore_state.py's colour mode inference): use the first supported diff --git a/esphome/components/lightwaverf/lightwaverf.cpp b/esphome/components/lightwaverf/lightwaverf.cpp index b8b6a9697ea..58c4ab07849 100644 --- a/esphome/components/lightwaverf/lightwaverf.cpp +++ b/esphome/components/lightwaverf/lightwaverf.cpp @@ -7,7 +7,7 @@ namespace esphome::lightwaverf { -static const char *const TAG = "lightwaverf.sensor"; +ESPHOME_LOG_TAG(TAG, "lightwaverf.sensor"); static const uint8_t DEFAULT_REPEAT = 10; static const bool DEFAULT_INVERT = false; diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp index 87319235e9b..d76f4dfb395 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::lilygo_t5_47 { -static const char *const TAG = "lilygo_t5_47.touchscreen"; +ESPHOME_LOG_TAG(TAG, "lilygo_t5_47.touchscreen"); static const uint8_t POWER_REGISTER = 0xD6; static const uint8_t TOUCH_REGISTER = 0xD0; diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 2e8b9d3cfe1..56e69faa62f 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -4,7 +4,7 @@ namespace esphome::lm75b { -static const char *const TAG = "lm75b"; +ESPHOME_LOG_TAG(TAG, "lm75b"); void LM75BComponent::dump_config() { ESP_LOGCONFIG(TAG, "LM75B:"); diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 0b15bf434c1..8f569086de2 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -178,7 +178,7 @@ __sprintf( // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,r namespace esphome::ln882h_ble { -static const char *const TAG = "ln882h_ble"; +ESPHOME_LOG_TAG(TAG, "ln882h_ble"); // The SDK event callback is a plain C function pointer with no user argument, // so it reaches the (single) component instance through a file-static pointer. diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index e1083e5fbe7..34981560d24 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -9,7 +9,7 @@ namespace esphome::ln882h_ble_tracker { -static const char *const TAG = "ln882h_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "ln882h_ble_tracker"); static constexpr float BLE_SCAN_UNIT_MS = 0.625f; diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index 66eb692bd5c..aca3a3b0248 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -6,7 +6,7 @@ namespace esphome::lock { -static const char *const TAG = "lock"; +ESPHOME_LOG_TAG(TAG, "lock"); // Lock state strings indexed by LockState enum. // Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 2a0a1c9c0ac..24a6586ae2f 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -8,7 +8,7 @@ namespace esphome::logger { -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) // Implementation for multi-threaded platforms (ESP32 with FreeRTOS, Host with pthreads, LibreTiny with FreeRTOS, diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 8579708559d..62d1a7468ae 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -31,7 +31,7 @@ namespace esphome::logger { -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG static void init_usb_serial_jtag() { diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index ac71ba8e3bf..6822e8f590f 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -8,7 +8,7 @@ namespace esphome::logger { -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); void Logger::pre_setup() { #if defined(USE_ESP8266_LOGGER_SERIAL) diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index 9a322f751df..9abb2837fec 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::logger { -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); void Logger::pre_setup() { if (this->baud_rate_ > 0) { diff --git a/esphome/components/logger/logger_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index b949d7ee162..518a1d7b1d2 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -8,7 +8,7 @@ namespace esphome::logger { -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); void Logger::pre_setup() { if (this->baud_rate_ > 0) { diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 396728ceaf0..95cc5d4a14b 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -37,7 +37,7 @@ __attribute__((section(".noinit"))) struct { #endif } crash_buf; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); #ifdef USE_LOGGER_UART_SELECTION_USB_CDC void Logger::cdc_loop_() { diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 020add695c1..57f801c332b 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -2,7 +2,7 @@ namespace esphome::lps22 { -static constexpr const char *const TAG = "lps22"; +ESPHOME_LOG_TAG(TAG, "lps22"); static constexpr uint8_t WHO_AM_I = 0x0F; static constexpr uint8_t LPS22HB_ID = 0xB1; diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index efdd241578f..b02a610c0a8 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -4,7 +4,7 @@ namespace esphome::lsm6ds { -static const char *const TAG = "lsm6ds"; +ESPHOME_LOG_TAG(TAG, "lsm6ds"); static const struct { uint8_t who_am_i; diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index dd78b20f2c2..d9699afe5a1 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -5,7 +5,7 @@ namespace esphome::ltr390 { -static const char *const TAG = "ltr390"; +ESPHOME_LOG_TAG(TAG, "ltr390"); static const uint8_t LTR390_WAKEUP_TIME = 10; static const uint8_t LTR390_SETTLE_TIME = 5; diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index afdc271167d..91fca7fa9ba 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -8,7 +8,7 @@ using esphome::i2c::ErrorCode; namespace esphome::ltr501 { -static const char *const TAG = "ltr501"; +ESPHOME_LOG_TAG(TAG, "ltr501"); static const uint8_t MAX_TRIES = 5; static const uint8_t MAX_SENSITIVITY_ADJUSTMENTS = 10; diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index 0d43aac20e4..2d3b3755ad5 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -8,7 +8,7 @@ using esphome::i2c::ErrorCode; namespace esphome::ltr_als_ps { -static const char *const TAG = "ltr_als_ps"; +ESPHOME_LOG_TAG(TAG, "ltr_als_ps"); static const uint8_t MAX_TRIES = 5; diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 466d4000235..e35cc0c48bf 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -13,7 +13,7 @@ static void *lv_alloc_draw_buf(size_t size, bool internal); static void *draw_buf_alloc_cb(size_t size, lv_color_format_t color_format) { return lv_alloc_draw_buf(size, false); }; namespace esphome::lvgl { -static const char *const TAG = "lvgl"; +ESPHOME_LOG_TAG(TAG, "lvgl"); static const size_t MIN_BUFFER_FRAC = 8; // buffer must be at least 1/8 of the display size static const size_t MIN_BUFFER_SIZE = 2048; // Sensible minimum buffer size diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp index e132c54daad..65b5816e46e 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp @@ -4,7 +4,7 @@ namespace esphome::m5stack_8angle { -static const char *const TAG = "m5stack_8angle.light"; +ESPHOME_LOG_TAG(TAG, "m5stack_8angle.light"); void M5Stack8AngleLightOutput::setup() { RAMAllocator allocator; diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.cpp b/esphome/components/m5stack_8angle/m5stack_8angle.cpp index f466fba77e7..06f9b90af98 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.cpp +++ b/esphome/components/m5stack_8angle/m5stack_8angle.cpp @@ -4,7 +4,7 @@ namespace esphome::m5stack_8angle { -static const char *const TAG = "m5stack_8angle"; +ESPHOME_LOG_TAG(TAG, "m5stack_8angle"); void M5Stack8AngleComponent::setup() { i2c::ErrorCode err; diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index 97641ac2d39..e0f19ca1657 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -4,7 +4,7 @@ namespace esphome::matrix_keypad { -static const char *const TAG = "matrix_keypad"; +ESPHOME_LOG_TAG(TAG, "matrix_keypad"); void MatrixKeypad::setup() { for (auto *pin : this->rows_) { diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index 8776bb5558b..a15658512b2 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -6,7 +6,7 @@ namespace esphome::max17043 { // MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert // Consult the datasheet at https://www.analog.com/en/products/max17043.html -static const char *const TAG = "max17043"; +ESPHOME_LOG_TAG(TAG, "max17043"); static const uint8_t MAX17043_VCELL = 0x02; static const uint8_t MAX17043_SOC = 0x04; diff --git a/esphome/components/max31855/max31855.cpp b/esphome/components/max31855/max31855.cpp index ac6b3cecb42..3d681e49233 100644 --- a/esphome/components/max31855/max31855.cpp +++ b/esphome/components/max31855/max31855.cpp @@ -5,7 +5,7 @@ namespace esphome::max31855 { -static const char *const TAG = "max31855"; +ESPHOME_LOG_TAG(TAG, "max31855"); void MAX31855Sensor::update() { this->enable(); diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index b5bad8ef743..5612a4cbf70 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -5,7 +5,7 @@ namespace esphome::max31856 { -static const char *const TAG = "max31856"; +ESPHOME_LOG_TAG(TAG, "max31856"); // Based on Adafruit's library: https://github.com/adafruit/Adafruit_MAX31856 diff --git a/esphome/components/max31865/max31865.cpp b/esphome/components/max31865/max31865.cpp index e5a6fca8fb6..b637d6dbfc8 100644 --- a/esphome/components/max31865/max31865.cpp +++ b/esphome/components/max31865/max31865.cpp @@ -6,7 +6,7 @@ namespace esphome::max31865 { -static const char *const TAG = "max31865"; +ESPHOME_LOG_TAG(TAG, "max31865"); void MAX31865Sensor::update() { // Check new faults since last measurement diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 731f5840563..b616b9032ee 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -4,7 +4,7 @@ namespace esphome::max44009 { -static const char *const TAG = "max44009.sensor"; +ESPHOME_LOG_TAG(TAG, "max44009.sensor"); // REGISTERS static constexpr uint8_t MAX44009_REGISTER_CONFIGURATION = 0x02; diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index 8734405508f..ad0c470e336 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -3,7 +3,7 @@ namespace esphome::max6675 { -static const char *const TAG = "max6675"; +ESPHOME_LOG_TAG(TAG, "max6675"); void MAX6675Sensor::update() { this->enable(); diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index ccb14496aa8..7060a8c76b7 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -3,7 +3,7 @@ namespace esphome::max6956 { -static const char *const TAG = "max6956"; +ESPHOME_LOG_TAG(TAG, "max6956"); /// Masks for MAX6956 Configuration register const uint32_t MASK_TRANSITION_DETECTION = 0x80; diff --git a/esphome/components/max6956/output/max6956_led_output.cpp b/esphome/components/max6956/output/max6956_led_output.cpp index c53a429d203..e3c9c1e8272 100644 --- a/esphome/components/max6956/output/max6956_led_output.cpp +++ b/esphome/components/max6956/output/max6956_led_output.cpp @@ -4,7 +4,7 @@ namespace esphome::max6956 { -static const char *const TAG = "max6956_led_channel"; +ESPHOME_LOG_TAG(TAG, "max6956_led_channel"); void MAX6956LedChannel::write_state(float state) { this->parent_->set_pin_brightness(this->pin_, state); } diff --git a/esphome/components/max7219/max7219.cpp b/esphome/components/max7219/max7219.cpp index bec62ea0057..d755dd1a22c 100644 --- a/esphome/components/max7219/max7219.cpp +++ b/esphome/components/max7219/max7219.cpp @@ -5,7 +5,7 @@ namespace esphome::max7219 { -static const char *const TAG = "max7219"; +ESPHOME_LOG_TAG(TAG, "max7219"); static const uint8_t MAX7219_REGISTER_NOOP = 0x00; static const uint8_t MAX7219_REGISTER_DECODE_MODE = 0x09; diff --git a/esphome/components/max7219digit/max7219digit.cpp b/esphome/components/max7219digit/max7219digit.cpp index 26c65aa5d34..178a6fa23a0 100644 --- a/esphome/components/max7219digit/max7219digit.cpp +++ b/esphome/components/max7219digit/max7219digit.cpp @@ -10,7 +10,7 @@ namespace esphome::max7219digit { -static const char *const TAG = "max7219DIGIT"; +ESPHOME_LOG_TAG(TAG, "max7219DIGIT"); static const uint8_t MAX7219_REGISTER_NOOP = 0x00; static const uint8_t MAX7219_REGISTER_DECODE_MODE = 0x09; diff --git a/esphome/components/max9611/max9611.cpp b/esphome/components/max9611/max9611.cpp index 68ef7c31358..420eb7da7cb 100644 --- a/esphome/components/max9611/max9611.cpp +++ b/esphome/components/max9611/max9611.cpp @@ -24,7 +24,7 @@ float gain_to_lsb(MAX9611Multiplexer gain) { } return lsb; } -static const char *const TAG = "max9611"; +ESPHOME_LOG_TAG(TAG, "max9611"); static const uint8_t SETUP_DELAY = 4; // Wait 2 integration periods. static const float VOUT_LSB = 14.0 / 1000.0; // 14mV/LSB static const float TEMP_LSB = 0.48; // 0.48C/LSB diff --git a/esphome/components/mcp23008/mcp23008.cpp b/esphome/components/mcp23008/mcp23008.cpp index 6be5f4c9518..3d2eb618d67 100644 --- a/esphome/components/mcp23008/mcp23008.cpp +++ b/esphome/components/mcp23008/mcp23008.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23008 { -static const char *const TAG = "mcp23008"; +ESPHOME_LOG_TAG(TAG, "mcp23008"); static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 126ece3e7b9..13d930fd470 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp23016 { -static const char *const TAG = "mcp23016"; +ESPHOME_LOG_TAG(TAG, "mcp23016"); void MCP23016::setup() { uint16_t iocon; diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 173d117457c..9cad0324470 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23017 { -static const char *const TAG = "mcp23017"; +ESPHOME_LOG_TAG(TAG, "mcp23017"); static constexpr uint8_t IOCON_MIRROR = 0x40; // Mirror INTA/INTB pins static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin diff --git a/esphome/components/mcp23s08/mcp23s08.cpp b/esphome/components/mcp23s08/mcp23s08.cpp index e7f582f787c..341951f1199 100644 --- a/esphome/components/mcp23s08/mcp23s08.cpp +++ b/esphome/components/mcp23s08/mcp23s08.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23s08 { -static const char *const TAG = "mcp23s08"; +ESPHOME_LOG_TAG(TAG, "mcp23s08"); // IOCON register bits static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode diff --git a/esphome/components/mcp23s17/mcp23s17.cpp b/esphome/components/mcp23s17/mcp23s17.cpp index 652931e9a93..534327f401a 100644 --- a/esphome/components/mcp23s17/mcp23s17.cpp +++ b/esphome/components/mcp23s17/mcp23s17.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23s17 { -static const char *const TAG = "mcp23s17"; +ESPHOME_LOG_TAG(TAG, "mcp23s17"); // IOCON register bits static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 98fd2f62493..0cda5dac4a1 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23x08_base { -static const char *const TAG = "mcp23x08_base"; +ESPHOME_LOG_TAG(TAG, "mcp23x08_base"); bool MCP23X08Base::digital_read_hw(uint8_t pin) { if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_mask_)) { diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 870c6c165f5..7ae17b1c13d 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp23x17_base { -static const char *const TAG = "mcp23x17_base"; +ESPHOME_LOG_TAG(TAG, "mcp23x17_base"); bool MCP23X17Base::digital_read_hw(uint8_t pin) { uint8_t data; diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index f8c5e9f0685..8a1973d022f 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp2515 { -static const char *const TAG = "mcp2515"; +ESPHOME_LOG_TAG(TAG, "mcp2515"); const struct MCP2515::TxBnRegs MCP2515::TXB[N_TXBUFFERS] = {{MCP_TXB0CTRL, MCP_TXB0SIDH, MCP_TXB0DATA}, {MCP_TXB1CTRL, MCP_TXB1SIDH, MCP_TXB1DATA}, diff --git a/esphome/components/mcp3008/mcp3008.cpp b/esphome/components/mcp3008/mcp3008.cpp index e65e249f529..2ee6ce5b9bb 100644 --- a/esphome/components/mcp3008/mcp3008.cpp +++ b/esphome/components/mcp3008/mcp3008.cpp @@ -5,7 +5,7 @@ namespace esphome::mcp3008 { -static const char *const TAG = "mcp3008"; +ESPHOME_LOG_TAG(TAG, "mcp3008"); float MCP3008::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp b/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp index 2647ed199e1..3a119f7d2ab 100644 --- a/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp +++ b/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp3008 { -static const char *const TAG = "mcp3008.sensor"; +ESPHOME_LOG_TAG(TAG, "mcp3008.sensor"); void MCP3008Sensor::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index ef8b5684535..dddc043f0ee 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp3204 { -static const char *const TAG = "mcp3204"; +ESPHOME_LOG_TAG(TAG, "mcp3204"); float MCP3204::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp index 153c37d160f..188d7a1dfcf 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp3204 { -static const char *const TAG = "mcp3204.sensor"; +ESPHOME_LOG_TAG(TAG, "mcp3204.sensor"); void MCP3204Sensor::dump_config() { LOG_SENSOR("", "MCP3204 Sensor", this); diff --git a/esphome/components/mcp3221/mcp3221_sensor.cpp b/esphome/components/mcp3221/mcp3221_sensor.cpp index 1b794ba9663..d38883c3421 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.cpp +++ b/esphome/components/mcp3221/mcp3221_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp3221 { -static const char *const TAG = "mcp3221"; +ESPHOME_LOG_TAG(TAG, "mcp3221"); float MCP3221Sensor::sample() { uint8_t data[2]; diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index cc53f9de7f7..b6e8c247021 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -5,7 +5,7 @@ namespace esphome::mcp4461 { -static const char *const TAG = "mcp4461"; +ESPHOME_LOG_TAG(TAG, "mcp4461"); constexpr uint8_t EEPROM_WRITE_TIMEOUT_MS = 10; void Mcp4461Component::setup() { diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index d38eed4d096..6c1ab99e29b 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -6,7 +6,7 @@ namespace esphome::mcp4461 { -static const char *const TAG = "mcp4461.output"; +ESPHOME_LOG_TAG(TAG, "mcp4461.output"); // public set_level function void Mcp4461Wiper::set_level(float state) { diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index 8e94623de36..faa377b8de9 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp4725 { -static const char *const TAG = "mcp4725"; +ESPHOME_LOG_TAG(TAG, "mcp4725"); void MCP4725::setup() { auto err = this->write(nullptr, 0); diff --git a/esphome/components/mcp4728/mcp4728.cpp b/esphome/components/mcp4728/mcp4728.cpp index 1b15ca05100..7259a0a89ec 100644 --- a/esphome/components/mcp4728/mcp4728.cpp +++ b/esphome/components/mcp4728/mcp4728.cpp @@ -5,7 +5,7 @@ namespace esphome::mcp4728 { -static const char *const TAG = "mcp4728"; +ESPHOME_LOG_TAG(TAG, "mcp4728"); void MCP4728Component::setup() { auto err = this->write(nullptr, 0); diff --git a/esphome/components/mcp47a1/mcp47a1.cpp b/esphome/components/mcp47a1/mcp47a1.cpp index e2b0fa5575d..8bbde590063 100644 --- a/esphome/components/mcp47a1/mcp47a1.cpp +++ b/esphome/components/mcp47a1/mcp47a1.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp47a1 { -static const char *const TAG = "mcp47a1"; +ESPHOME_LOG_TAG(TAG, "mcp47a1"); void MCP47A1::dump_config() { ESP_LOGCONFIG(TAG, "MCP47A1 Output:"); diff --git a/esphome/components/mcp9600/mcp9600.cpp b/esphome/components/mcp9600/mcp9600.cpp index 0e9b472be27..70e7cb176d4 100644 --- a/esphome/components/mcp9600/mcp9600.cpp +++ b/esphome/components/mcp9600/mcp9600.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp9600 { -static const char *const TAG = "mcp9600"; +ESPHOME_LOG_TAG(TAG, "mcp9600"); static const uint8_t MCP9600_REGISTER_HOT_JUNCTION = 0x00; // static const uint8_t MCP9600_REGISTER_JUNCTION_DELTA = 0x01; // Unused, but kept for future reference diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 10e26ed7097..47df37f4563 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -14,7 +14,7 @@ static const uint8_t MCP9808_AMBIENT_CLEAR_FLAGS = 0x1F; static const uint8_t MCP9808_AMBIENT_CLEAR_SIGN = 0x0F; static const uint8_t MCP9808_AMBIENT_TEMP_NEGATIVE = 0x10; -static const char *const TAG = "mcp9808"; +ESPHOME_LOG_TAG(TAG, "mcp9808"); void MCP9808Sensor::setup() { uint16_t manu = 0; diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4f236153c2c..4397c101fc5 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -23,7 +23,7 @@ namespace esphome::mdns { -static const char *const TAG = "mdns"; +ESPHOME_LOG_TAG(TAG, "mdns"); #ifndef USE_WEBSERVER_PORT #define USE_WEBSERVER_PORT 80 // NOLINT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 48df61326e2..1df5b9217e5 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mdns { -static const char *const TAG = "mdns"; +ESPHOME_LOG_TAG(TAG, "mdns"); #ifndef USE_OPENTHREAD static esp_err_t add_service(const MDNSService &service) { diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index 6c3eef912e1..423832dd2ff 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -6,7 +6,7 @@ namespace esphome::media_player { -static const char *const TAG = "media_player"; +ESPHOME_LOG_TAG(TAG, "media_player"); const char *media_player_state_to_string(MediaPlayerState state) { switch (state) { diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 707d952f83b..5be6da88eff 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -5,7 +5,7 @@ namespace esphome::mhz19 { -static const char *const TAG = "mhz19"; +ESPHOME_LOG_TAG(TAG, "mhz19"); static const uint8_t MHZ19_REQUEST_LENGTH = 8; static const uint8_t MHZ19_RESPONSE_LENGTH = 9; static const uint8_t MHZ19_COMMAND_GET_PPM[] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00}; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index cf239be6960..9ad19f66d5e 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -17,7 +17,7 @@ namespace esphome::micro_wake_word { -static const char *const TAG = "micro_wake_word"; +ESPHOME_LOG_TAG(TAG, "micro_wake_word"); static const ssize_t DETECTION_QUEUE_LENGTH = 5; diff --git a/esphome/components/micro_wake_word/model_data.cpp b/esphome/components/micro_wake_word/model_data.cpp index c235e290ec2..ace0d9770ab 100644 --- a/esphome/components/micro_wake_word/model_data.cpp +++ b/esphome/components/micro_wake_word/model_data.cpp @@ -10,7 +10,7 @@ namespace esphome::micro_wake_word { -static const char *const TAG = "micro_wake_word"; +ESPHOME_LOG_TAG(TAG, "micro_wake_word"); ModelData::~ModelData() { this->deallocate_(); } diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 72984f04fb2..7cbd11711a2 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -5,7 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "micro_wake_word"; +ESPHOME_LOG_TAG(TAG, "micro_wake_word"); namespace esphome::micro_wake_word { diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index 13c0e1f1176..159dee2d20e 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.button"; +ESPHOME_LOG_TAG(TAG, "micronova.button"); void MicroNovaButton::dump_config() { LOG_BUTTON("", "Micronova button", this); diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index c2e3ef86405..86b340d5e47 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,7 +3,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova"; +ESPHOME_LOG_TAG(TAG, "micronova"); static constexpr uint8_t STOVE_REPLY_SIZE = 2; static constexpr uint32_t STOVE_REPLY_TIMEOUT = 200; // ms static constexpr uint8_t WRITE_BIT = 1 << 7; // 0x80 diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 02a6de1c844..6ce2d51d7fc 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.number"; +ESPHOME_LOG_TAG(TAG, "micronova.number"); void MicroNovaNumber::dump_config() { LOG_NUMBER("", "Micronova number", this); diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index 8d528145cd2..f53c80fbb8f 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.sensor"; +ESPHOME_LOG_TAG(TAG, "micronova.sensor"); void MicroNovaSensor::dump_config() { LOG_SENSOR("", "Micronova sensor", this); diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index d01cc572543..ba74e68efff 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.switch"; +ESPHOME_LOG_TAG(TAG, "micronova.switch"); void MicroNovaSwitch::dump_config() { LOG_SWITCH("", "Micronova switch", this); diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index 50a0d34b3a7..28f755389fa 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.text_sensor"; +ESPHOME_LOG_TAG(TAG, "micronova.text_sensor"); void MicroNovaTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Micronova text sensor", this); diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index 14a73bc15f7..d4794b66826 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -5,7 +5,7 @@ namespace esphome::mics_4514 { -static const char *const TAG = "mics_4514"; +ESPHOME_LOG_TAG(TAG, "mics_4514"); static const uint8_t SENSOR_REGISTER = 0x04; static const uint8_t POWER_MODE_REGISTER = 0x0a; diff --git a/esphome/components/midea_ir/midea_ir.cpp b/esphome/components/midea_ir/midea_ir.cpp index de25c4652cb..55f84e058fb 100644 --- a/esphome/components/midea_ir/midea_ir.cpp +++ b/esphome/components/midea_ir/midea_ir.cpp @@ -6,7 +6,7 @@ namespace esphome::midea_ir { -static const char *const TAG = "midea_ir.climate"; +ESPHOME_LOG_TAG(TAG, "midea_ir.climate"); void ControlData::set_temp(float temp) { uint8_t min; diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 9cb355fe9c1..dabd62b8f1d 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -4,7 +4,7 @@ namespace esphome::mitsubishi { -static const char *const TAG = "mitsubishi.climate"; +ESPHOME_LOG_TAG(TAG, "mitsubishi.climate"); // IR frame size for Mitsubishi climate static constexpr size_t MITSUBISHI_FRAME_SIZE = 18; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3d30d1a25f0..c6c7b57c5fa 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -8,7 +8,7 @@ namespace esphome::mitsubishi_cn105 { -static const char *const TAG = "mitsubishi_cn105.driver"; +ESPHOME_LOG_TAG(TAG, "mitsubishi_cn105.driver"); static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 53b8c21de60..70c5d479a3f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -4,7 +4,7 @@ namespace esphome::mitsubishi_cn105 { -static const char *const TAG = "mitsubishi_cn105.climate"; +ESPHOME_LOG_TAG(TAG, "mitsubishi_cn105.climate"); static constexpr std::array MODE_MAP{ std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL}, diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index e2a6ee05afd..e3222ecf530 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -6,7 +6,7 @@ namespace esphome::mitsubishi_cn105 { -static const char *const TAG = "mitsubishi_cn105"; +ESPHOME_LOG_TAG(TAG, "mitsubishi_cn105"); void MitsubishiCN105Component::dump_config() { ESP_LOGCONFIG(TAG, "Mitsubishi CN105:"); diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 41b7123269a..dbe07991633 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -24,7 +24,7 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200; static const size_t TASK_STACK_SIZE = 4096; -static const char *const TAG = "speaker_mixer"; +ESPHOME_LOG_TAG(TAG, "speaker_mixer"); // Event bits for SourceSpeaker command processing enum SourceSpeakerEventBits : uint32_t { diff --git a/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp index 9201596af28..61ecb237437 100644 --- a/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp +++ b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::mk2pvrouter { -static const char *const TAG = "mk2pvrouter_binary_sensor"; +ESPHOME_LOG_TAG(TAG, "mk2pvrouter_binary_sensor"); Mk2PVRouterBinarySensor::Mk2PVRouterBinarySensor(const char *tag) : Mk2PVRouterListener(tag) {} diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index f8aa1003bc4..eecaa1a1d41 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -5,7 +5,7 @@ namespace esphome::mk2pvrouter { -static const char *const TAG = "mk2pvrouter"; +ESPHOME_LOG_TAG(TAG, "mk2pvrouter"); constexpr uint8_t START_FRAME = 0x2; constexpr uint8_t END_FRAME = 0x3; diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp index 5ea07307e49..92e2ea715c5 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::mk2pvrouter { -static const char *const TAG = "mk2pvrouter_sensor"; +ESPHOME_LOG_TAG(TAG, "mk2pvrouter_sensor"); Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag, bool scale_centi) : Mk2PVRouterListener(tag), scale_centi_(scale_centi) {} diff --git a/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp index 4aea2ef7fc7..66e6ba0a3b2 100644 --- a/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp +++ b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::mk2pvrouter { -static const char *const TAG = "mk2pvrouter_text_sensor"; +ESPHOME_LOG_TAG(TAG, "mk2pvrouter_text_sensor"); Mk2PVRouterTextSensor::Mk2PVRouterTextSensor(const char *tag) : Mk2PVRouterListener(tag) {} diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 2288e8ff845..82d12a57898 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -5,7 +5,7 @@ namespace esphome::mlx90393 { -static const char *const TAG = "mlx90393"; +ESPHOME_LOG_TAG(TAG, "mlx90393"); const LogString *settings_to_string(MLX90393Setting setting) { switch (setting) { diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 508b3743d18..a67d770cc3d 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -24,7 +24,7 @@ static const uint8_t MLX90614_ID2 = 0x3D; static const uint8_t MLX90614_ID3 = 0x3E; static const uint8_t MLX90614_ID4 = 0x3F; -static const char *const TAG = "mlx90614"; +ESPHOME_LOG_TAG(TAG, "mlx90614"); // The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3; diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 15e715e6751..b7d16b30edd 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -5,7 +5,7 @@ namespace esphome::mmc5603 { -static const char *const TAG = "mmc5603"; +ESPHOME_LOG_TAG(TAG, "mmc5603"); static const uint8_t MMC5603_ADDRESS = 0x30; static const uint8_t MMC56X3_PRODUCT_ID = 0x39; diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index a99df36a705..f7840aafbe9 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -6,7 +6,7 @@ namespace esphome::mmc5983 { -static const char *const TAG = "mmc5983"; +ESPHOME_LOG_TAG(TAG, "mmc5983"); namespace { constexpr uint8_t IC0_ADDR = 0x09; diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5f237be8dd7..7c56d2340ff 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -9,7 +9,7 @@ namespace esphome::modbus { -static const char *const TAG = "modbus"; +ESPHOME_LOG_TAG(TAG, "modbus"); static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index d80e6c86adb..19edc989381 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -5,7 +5,7 @@ namespace esphome::modbus::helpers { -static const char *const TAG = "modbus_helpers"; +ESPHOME_LOG_TAG(TAG, "modbus_helpers"); // A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum, // and the range [start_address, start_address + quantity) stays inside the 16-bit address space. diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index b0c927cf848..679b8dc7146 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -3,7 +3,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.binary_sensor"); void ModbusBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Modbus Controller Binary Sensor", this); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index b8d06d3d5a3..6c90bfa4646 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -7,7 +7,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller"; +ESPHOME_LOG_TAG(TAG, "modbus_controller"); void ModbusController::setup() { this->create_polling_commands_(); } diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 223aa12bec2..f33fc36b0a1 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus.number"; +ESPHOME_LOG_TAG(TAG, "modbus.number"); // Maximum uint16_t registers to log in verbose hex output static constexpr size_t MODBUS_NUMBER_MAX_LOG_REGISTERS = 32; diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index ad29015d328..9fb40f90053 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -6,7 +6,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.output"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.output"); // Maximum bytes to log in verbose hex output static constexpr size_t MODBUS_OUTPUT_MAX_LOG_BYTES = 64; diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index a2f15d54f66..793aa8e9ea4 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -3,7 +3,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.select"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.select"); void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index 2035f2220a3..98037c52370 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.sensor"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.sensor"); void ModbusSensor::dump_config() { LOG_SENSOR(TAG, "Modbus Controller Sensor", this); } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 855a7b28c30..fe2b2762313 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -7,7 +7,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.switch"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.switch"); // Maximum bytes to log in verbose hex output static constexpr size_t MODBUS_SWITCH_MAX_LOG_BYTES = 64; diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp index 31b3fb3e558..74efd41e0f4 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.text_sensor"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.text_sensor"); void ModbusTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Modbus Controller Text Sensor", this); } diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 65bf4ef2f40..763bbf872d4 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -6,7 +6,7 @@ namespace esphome::modbus_server { using modbus::ExceptionCode; using modbus::helpers::registers_to_number; -static const char *const TAG = "modbus_server"; +ESPHOME_LOG_TAG(TAG, "modbus_server"); // The widest Modbus value type (QWORD) spans four registers. static constexpr uint8_t MAX_REGISTERS_PER_VALUE = 4; diff --git a/esphome/components/mopeka_ble/mopeka_ble.cpp b/esphome/components/mopeka_ble/mopeka_ble.cpp index 0bef1eb6d49..24e1667a4b9 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.cpp +++ b/esphome/components/mopeka_ble/mopeka_ble.cpp @@ -4,7 +4,7 @@ namespace esphome::mopeka_ble { -static const char *const TAG = "mopeka_ble"; +ESPHOME_LOG_TAG(TAG, "mopeka_ble"); // Mopeka Std (CC2540) sensor details static const uint16_t SERVICE_UUID_CC2540 = 0xADA0; diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index fe3178d3aad..ba93c70d15c 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -3,7 +3,7 @@ namespace esphome::mopeka_pro_check { -static const char *const TAG = "mopeka_pro_check"; +ESPHOME_LOG_TAG(TAG, "mopeka_pro_check"); static const uint8_t MANUFACTURER_DATA_LENGTH = 10; static const uint16_t MANUFACTURER_ID = 0x0059; static const double MOPEKA_LPG_COEF[] = {0.573045, -0.002822, -0.00000535}; // Magic numbers provided by Mopeka diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index d70306c97d4..f63f2bc190b 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -5,7 +5,7 @@ namespace esphome::mopeka_std_check { -static const char *const TAG = "mopeka_std_check"; +ESPHOME_LOG_TAG(TAG, "mopeka_std_check"); static const uint16_t SERVICE_UUID = 0xADA0; static const uint8_t MANUFACTURER_DATA_LENGTH = 23; static const uint16_t MANUFACTURER_ID = 0x000D; diff --git a/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp index f663b41adbf..1de535a0ff6 100644 --- a/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp +++ b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::motion { -static const char *const TAG = "motion.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "motion.binary_sensor"); // Thresholds used to decide the device is at rest for face_up / face_down detection. // While moving (shaking, being picked up) the orientation reading is dominated by diff --git a/esphome/components/motion/event/motion_event.cpp b/esphome/components/motion/event/motion_event.cpp index 5568c953fda..9ad2668fd4b 100644 --- a/esphome/components/motion/event/motion_event.cpp +++ b/esphome/components/motion/event/motion_event.cpp @@ -5,7 +5,7 @@ namespace esphome::motion { -static const char *const TAG = "motion.event"; +ESPHOME_LOG_TAG(TAG, "motion.event"); MotionEvent::MotionEvent(MotionComponent *parent) : parent_(parent) {} diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp index 226dc94e638..3171bef4ec9 100644 --- a/esphome/components/motion/motion_component.cpp +++ b/esphome/components/motion/motion_component.cpp @@ -3,7 +3,7 @@ namespace esphome::motion { -static const char *const TAG = "motion"; +ESPHOME_LOG_TAG(TAG, "motion"); static void log_matrix(const float m[9]) { ESP_LOGCONFIG(TAG, " Calibration matrix:"); diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 238e37aff02..db892716258 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -5,7 +5,7 @@ namespace esphome::mpl3115a2 { -static const char *const TAG = "mpl3115a2"; +ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { uint8_t whoami = 0xFF; diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 6b183233d1e..26cdcae6138 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -7,7 +7,7 @@ namespace esphome::mpr121 { -static const char *const TAG = "mpr121"; +ESPHOME_LOG_TAG(TAG, "mpr121"); void MPR121Component::setup() { this->disable_loop(); diff --git a/esphome/components/mpu6050/mpu6050.cpp b/esphome/components/mpu6050/mpu6050.cpp index 8784e8caf81..cf393fd4d10 100644 --- a/esphome/components/mpu6050/mpu6050.cpp +++ b/esphome/components/mpu6050/mpu6050.cpp @@ -3,7 +3,7 @@ namespace esphome::mpu6050 { -static const char *const TAG = "mpu6050"; +ESPHOME_LOG_TAG(TAG, "mpu6050"); const uint8_t MPU6050_REGISTER_WHO_AM_I = 0x75; const uint8_t MPU6050_REGISTER_POWER_MANAGEMENT_1 = 0x6B; diff --git a/esphome/components/mpu6886/mpu6886.cpp b/esphome/components/mpu6886/mpu6886.cpp index b8cbd4635ac..514ff0dd0e7 100644 --- a/esphome/components/mpu6886/mpu6886.cpp +++ b/esphome/components/mpu6886/mpu6886.cpp @@ -3,7 +3,7 @@ namespace esphome::mpu6886 { -static const char *const TAG = "mpu6886"; +ESPHOME_LOG_TAG(TAG, "mpu6886"); const uint8_t MPU6886_REGISTER_WHO_AM_I = 0x75; const uint8_t MPU6886_REGISTER_POWER_MANAGEMENT_1 = 0x6B; diff --git a/esphome/components/mqtt/custom_mqtt_device.cpp b/esphome/components/mqtt/custom_mqtt_device.cpp index 64521f5cf3c..42171fcfb14 100644 --- a/esphome/components/mqtt/custom_mqtt_device.cpp +++ b/esphome/components/mqtt/custom_mqtt_device.cpp @@ -6,7 +6,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.custom"; +ESPHOME_LOG_TAG(TAG, "mqtt.custom"); bool CustomMQTTDevice::publish(const std::string &topic, const std::string &payload, uint8_t qos, bool retain) { return global_mqtt_client->publish(topic, payload, qos, retain); diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index f059360e231..225f8396339 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "mqtt.alarm_control_panel"); using namespace esphome::alarm_control_panel; diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 791e5504965..3b49ca33cdc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt"; +ESPHOME_LOG_TAG(TAG, "mqtt"); bool MQTTBackendESP32::initialize_() { mqtt_cfg_.broker.address.hostname = this->host_.c_str(); diff --git a/esphome/components/mqtt/mqtt_binary_sensor.cpp b/esphome/components/mqtt/mqtt_binary_sensor.cpp index ebb29db44f0..7a75cca6e88 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.cpp +++ b/esphome/components/mqtt/mqtt_binary_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt.binary_sensor"); MQTT_COMPONENT_TYPE(MQTTBinarySensorComponent, "binary_sensor") const EntityBase *MQTTBinarySensorComponent::get_entity() const { return this->binary_sensor_; } diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index 7e0ae7d06e1..906913723cc 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.button"; +ESPHOME_LOG_TAG(TAG, "mqtt.button"); using namespace esphome::button; diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 2ecab47904a..b3d8ab80b14 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -26,7 +26,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt"; +ESPHOME_LOG_TAG(TAG, "mqtt"); // Maximum number of MQTT component resends per loop iteration. // Limits work to avoid triggering the task watchdog on reconnect. diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 0e6a374f9b2..83efc3da9ce 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.climate"; +ESPHOME_LOG_TAG(TAG, "mqtt.climate"); using namespace esphome::climate; diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 59a5d02d977..adb5a958287 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -12,7 +12,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.component"; +ESPHOME_LOG_TAG(TAG, "mqtt.component"); // Entity category MQTT strings indexed by EntityCategory enum: NONE(0) is skipped, CONFIG(1), DIAGNOSTIC(2) PROGMEM_STRING_TABLE(EntityCategoryMqttStrings, "", "config", "diagnostic"); diff --git a/esphome/components/mqtt/mqtt_cover.cpp b/esphome/components/mqtt/mqtt_cover.cpp index ddb4b2d69d2..a7da9a1b228 100644 --- a/esphome/components/mqtt/mqtt_cover.cpp +++ b/esphome/components/mqtt/mqtt_cover.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.cover"; +ESPHOME_LOG_TAG(TAG, "mqtt.cover"); using namespace esphome::cover; diff --git a/esphome/components/mqtt/mqtt_date.cpp b/esphome/components/mqtt/mqtt_date.cpp index c422bb30586..b5f1cf8fb8b 100644 --- a/esphome/components/mqtt/mqtt_date.cpp +++ b/esphome/components/mqtt/mqtt_date.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.datetime"; +ESPHOME_LOG_TAG(TAG, "mqtt.datetime"); using namespace esphome::datetime; diff --git a/esphome/components/mqtt/mqtt_datetime.cpp b/esphome/components/mqtt/mqtt_datetime.cpp index 1492abd011a..82755809387 100644 --- a/esphome/components/mqtt/mqtt_datetime.cpp +++ b/esphome/components/mqtt/mqtt_datetime.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.datetime.datetime"; +ESPHOME_LOG_TAG(TAG, "mqtt.datetime.datetime"); using namespace esphome::datetime; diff --git a/esphome/components/mqtt/mqtt_event.cpp b/esphome/components/mqtt/mqtt_event.cpp index 93ff6971b36..4266722ca82 100644 --- a/esphome/components/mqtt/mqtt_event.cpp +++ b/esphome/components/mqtt/mqtt_event.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.event"; +ESPHOME_LOG_TAG(TAG, "mqtt.event"); using namespace esphome::event; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 3a8658a55fe..90f51cf6cec 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.fan"; +ESPHOME_LOG_TAG(TAG, "mqtt.fan"); using namespace esphome::fan; diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index a8b52a3839f..e9058eb642d 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -10,7 +10,7 @@ #include "esphome/components/light/light_json_schema.h" namespace esphome::mqtt { -static const char *const TAG = "mqtt.light"; +ESPHOME_LOG_TAG(TAG, "mqtt.light"); using namespace esphome::light; diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 369c1c6668b..8d8b4c38e88 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.lock"; +ESPHOME_LOG_TAG(TAG, "mqtt.lock"); using namespace esphome::lock; diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index b0bac8b3d71..3b2bebf291d 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.number"; +ESPHOME_LOG_TAG(TAG, "mqtt.number"); using namespace esphome::number; diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp index 25fd813496c..42c0ae002e4 100644 --- a/esphome/components/mqtt/mqtt_select.cpp +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.select"; +ESPHOME_LOG_TAG(TAG, "mqtt.select"); using namespace esphome::select; diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index a601a9bcc89..bdaaa47e8f4 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -14,7 +14,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt.sensor"); using namespace esphome::sensor; diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index 70cd03a4eb5..b786b4412e6 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.switch"; +ESPHOME_LOG_TAG(TAG, "mqtt.switch"); using namespace esphome::switch_; diff --git a/esphome/components/mqtt/mqtt_text.cpp b/esphome/components/mqtt/mqtt_text.cpp index 200e420c9c4..dd43a8e2cb3 100644 --- a/esphome/components/mqtt/mqtt_text.cpp +++ b/esphome/components/mqtt/mqtt_text.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.text"; +ESPHOME_LOG_TAG(TAG, "mqtt.text"); using namespace esphome::text; diff --git a/esphome/components/mqtt/mqtt_text_sensor.cpp b/esphome/components/mqtt/mqtt_text_sensor.cpp index 3acd71b50d9..d105aaac992 100644 --- a/esphome/components/mqtt/mqtt_text_sensor.cpp +++ b/esphome/components/mqtt/mqtt_text_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.text_sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt.text_sensor"); using namespace esphome::text_sensor; diff --git a/esphome/components/mqtt/mqtt_time.cpp b/esphome/components/mqtt/mqtt_time.cpp index be391ce88c5..889ee2bf349 100644 --- a/esphome/components/mqtt/mqtt_time.cpp +++ b/esphome/components/mqtt/mqtt_time.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.datetime.time"; +ESPHOME_LOG_TAG(TAG, "mqtt.datetime.time"); using namespace esphome::datetime; diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index c01fb9e52e0..a4565058ea2 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.update"; +ESPHOME_LOG_TAG(TAG, "mqtt.update"); using namespace esphome::update; diff --git a/esphome/components/mqtt/mqtt_valve.cpp b/esphome/components/mqtt/mqtt_valve.cpp index b155a4c8972..b41a482de94 100644 --- a/esphome/components/mqtt/mqtt_valve.cpp +++ b/esphome/components/mqtt/mqtt_valve.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.valve"; +ESPHOME_LOG_TAG(TAG, "mqtt.valve"); using namespace esphome::valve; diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index afb725feb64..a79216e04de 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::mqtt_subscribe { -static const char *const TAG = "mqtt_subscribe.sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt_subscribe.sensor"); void MQTTSubscribeSensor::setup() { mqtt::global_mqtt_client->subscribe( diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp index 470e08d59ae..55992f42f99 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::mqtt_subscribe { -static const char *const TAG = "mqtt_subscribe.text_sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt_subscribe.text_sensor"); void MQTTSubscribeTextSensor::setup() { this->parent_->subscribe( diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index 9b7dcbe653e..8b275e53f2e 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -4,7 +4,7 @@ namespace esphome::ms5611 { -static const char *const TAG = "ms5611"; +ESPHOME_LOG_TAG(TAG, "ms5611"); static const uint8_t MS5611_ADDRESS = 0x77; static const uint8_t MS5611_CMD_ADC_READ = 0x00; diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index b9cbdc749b4..832e7ab2d94 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -7,7 +7,7 @@ namespace esphome::ms8607 { /// TAG used for logging calls -static const char *const TAG = "ms8607"; +ESPHOME_LOG_TAG(TAG, "ms8607"); /// Reset the Pressure/Temperature sensor static const uint8_t MS8607_PT_CMD_RESET = 0x1E; diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index ecde0cb1172..78f2b75c9ce 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -5,7 +5,7 @@ namespace esphome::msa3xx { -static const char *const TAG = "msa3xx"; +ESPHOME_LOG_TAG(TAG, "msa3xx"); const uint8_t MSA_3XX_PART_ID = 0x13; diff --git a/esphome/components/my9231/my9231.cpp b/esphome/components/my9231/my9231.cpp index 0072f7196e7..679600eebce 100644 --- a/esphome/components/my9231/my9231.cpp +++ b/esphome/components/my9231/my9231.cpp @@ -4,7 +4,7 @@ namespace esphome::my9231 { -static const char *const TAG = "my9231.output"; +ESPHOME_LOG_TAG(TAG, "my9231.output"); // One-shot select (frame cycle repeat mode / frame cycle One-shot mode) static const uint8_t MY9231_CMD_ONE_SHOT_DISABLE = 0x0 << 6; diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 20924520874..ad1a569e0fa 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -5,7 +5,7 @@ namespace esphome::nau7802 { -static const char *const TAG = "nau7802"; +ESPHOME_LOG_TAG(TAG, "nau7802"); // Only define what we need diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index 7e059837335..fad7a385fdd 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -23,7 +23,7 @@ namespace esphome::network { -static const char *const TAG = "network"; +ESPHOME_LOG_TAG(TAG, "network"); void NetworkComponent::setup() { // Initialize ESP-IDF network interfaces and ensure the default event loop exists diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp index 08e7c58ef10..75e05e20185 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_binarysensor"; +ESPHOME_LOG_TAG(TAG, "nextion_binarysensor"); void NextionBinarySensor::process_bool(const std::string &variable_name, bool state) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 625c915e732..b9194b3f31c 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -11,7 +11,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion"; +ESPHOME_LOG_TAG(TAG, "nextion"); // A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag static bool is_sleep_wake_command(const NextionComponentBase *component) { diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index dd3a97065cb..cac1d605946 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -5,7 +5,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion"; +ESPHOME_LOG_TAG(TAG, "nextion"); // Sleep safe commands void Nextion::soft_reset() { this->send_command_("rest"); } diff --git a/esphome/components/nextion/nextion_upload.cpp b/esphome/components/nextion/nextion_upload.cpp index a49e7f18d6c..f106cdf18f9 100644 --- a/esphome/components/nextion/nextion_upload.cpp +++ b/esphome/components/nextion/nextion_upload.cpp @@ -6,7 +6,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload"; +ESPHOME_LOG_TAG(TAG, "nextion.upload"); bool Nextion::upload_end_(bool successful) { if (successful) { diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 944fa1db477..c755a0e9c50 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -13,7 +13,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload"; +ESPHOME_LOG_TAG(TAG, "nextion.upload"); static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index c4dc74b5d3c..cabd0761c25 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload"; +ESPHOME_LOG_TAG(TAG, "nextion.upload"); static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/sensor/nextion_sensor.cpp b/esphome/components/nextion/sensor/nextion_sensor.cpp index ca657522f96..bb5206e8d8e 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.cpp +++ b/esphome/components/nextion/sensor/nextion_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_sensor"; +ESPHOME_LOG_TAG(TAG, "nextion_sensor"); void NextionSensor::process_sensor(const std::string &variable_name, int state) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nextion/switch/nextion_switch.cpp b/esphome/components/nextion/switch/nextion_switch.cpp index 0018cff0052..f363666dbfe 100644 --- a/esphome/components/nextion/switch/nextion_switch.cpp +++ b/esphome/components/nextion/switch/nextion_switch.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_switch"; +ESPHOME_LOG_TAG(TAG, "nextion_switch"); void NextionSwitch::process_bool(const std::string &variable_name, bool on) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp index 45e46914236..b2ae4366847 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_textsensor"; +ESPHOME_LOG_TAG(TAG, "nextion_textsensor"); void NextionTextSensor::process_text(const std::string &variable_name, const std::string &text_value) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp index 995255d1e45..3595f370165 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "nfc.binary_sensor"); void NfcTagBinarySensor::setup() { this->publish_initial_state(false); } diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index e597b9192d4..552dfe99d30 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -3,7 +3,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_message"; +ESPHOME_LOG_TAG(TAG, "nfc.ndef_message"); NdefMessage::NdefMessage(const std::span data) { ESP_LOGV(TAG, "Building NdefMessage with %zu bytes", data.size()); diff --git a/esphome/components/nfc/ndef_record.cpp b/esphome/components/nfc/ndef_record.cpp index 8b9e7023ed6..0e8e7ff6efd 100644 --- a/esphome/components/nfc/ndef_record.cpp +++ b/esphome/components/nfc/ndef_record.cpp @@ -2,7 +2,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_record"; +ESPHOME_LOG_TAG(TAG, "nfc.ndef_record"); NdefRecord::NdefRecord(std::vector payload_data) { this->payload_ = std::string(payload_data.begin(), payload_data.end()); diff --git a/esphome/components/nfc/ndef_record_text.cpp b/esphome/components/nfc/ndef_record_text.cpp index 8ad687daf8c..f1e81dcd96c 100644 --- a/esphome/components/nfc/ndef_record_text.cpp +++ b/esphome/components/nfc/ndef_record_text.cpp @@ -3,7 +3,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_record_text"; +ESPHOME_LOG_TAG(TAG, "nfc.ndef_record_text"); NdefRecordText::NdefRecordText(const std::vector &payload) { if (payload.empty()) { diff --git a/esphome/components/nfc/ndef_record_uri.cpp b/esphome/components/nfc/ndef_record_uri.cpp index e2c6d21a34e..5d7ebbba48f 100644 --- a/esphome/components/nfc/ndef_record_uri.cpp +++ b/esphome/components/nfc/ndef_record_uri.cpp @@ -2,7 +2,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_record_uri"; +ESPHOME_LOG_TAG(TAG, "nfc.ndef_record_uri"); NdefRecordUri::NdefRecordUri(const std::vector &payload) { if (payload.empty()) { diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index a9fc1c2b8c4..03703bb4216 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -5,7 +5,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc"; +ESPHOME_LOG_TAG(TAG, "nfc"); char *format_uid_to(char *buffer, std::span uid) { return format_hex_pretty_to(buffer, FORMAT_UID_BUFFER_SIZE, uid.data(), uid.size(), '-'); diff --git a/esphome/components/nfc/nfc_helpers.cpp b/esphome/components/nfc/nfc_helpers.cpp index 82f4591bdf4..412cc385c6b 100644 --- a/esphome/components/nfc/nfc_helpers.cpp +++ b/esphome/components/nfc/nfc_helpers.cpp @@ -2,7 +2,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.helpers"; +ESPHOME_LOG_TAG(TAG, "nfc.helpers"); bool has_ha_tag_ndef(const NfcTag &tag) { return !get_ha_tag_ndef(tag).empty(); } diff --git a/esphome/components/nfc/nfc_tag.cpp b/esphome/components/nfc/nfc_tag.cpp index c43210517dc..0d2d2a20765 100644 --- a/esphome/components/nfc/nfc_tag.cpp +++ b/esphome/components/nfc/nfc_tag.cpp @@ -2,6 +2,6 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.tag"; +ESPHOME_LOG_TAG(TAG, "nfc.tag"); } // namespace esphome::nfc diff --git a/esphome/components/noblex/noblex.cpp b/esphome/components/noblex/noblex.cpp index ac8d2641fea..a9fb3935f1f 100644 --- a/esphome/components/noblex/noblex.cpp +++ b/esphome/components/noblex/noblex.cpp @@ -4,7 +4,7 @@ namespace esphome::noblex { -static const char *const TAG = "noblex.climate"; +ESPHOME_LOG_TAG(TAG, "noblex.climate"); const uint16_t NOBLEX_HEADER_MARK = 9000; const uint16_t NOBLEX_HEADER_SPACE = 4500; diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 118bfb234fd..5e70300f215 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -13,7 +13,7 @@ namespace esphome::noise { -static const char *const TAG = "noise"; +ESPHOME_LOG_TAG(TAG, "noise"); void NoiseContext::load_psk(psk_t &out) const { if (this->psk_ == nullptr) { diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index bd1afa198d3..e0b7ca7c372 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -6,7 +6,7 @@ namespace esphome::noise { -static const char *const TAG = "noise"; +ESPHOME_LOG_TAG(TAG, "noise"); // Log the failing noise-c call at the same verbosity the api helper used // before this class existed; callers only see one collapsed error code. diff --git a/esphome/components/npi19/npi19.cpp b/esphome/components/npi19/npi19.cpp index 207acc9d991..2f81bfe3bac 100644 --- a/esphome/components/npi19/npi19.cpp +++ b/esphome/components/npi19/npi19.cpp @@ -5,7 +5,7 @@ namespace esphome::npi19 { -static const char *const TAG = "npi19"; +ESPHOME_LOG_TAG(TAG, "npi19"); static const uint8_t READ_COMMAND = 0xAC; diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index 3d9219e96f8..a1d574a0340 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -10,7 +10,7 @@ namespace esphome::nrf52 { -static const char *const TAG = "dfu"; +ESPHOME_LOG_TAG(TAG, "dfu"); static const uint32_t DFU_DBL_RESET_MAGIC = 0x5A1AD5; // SALADS static const uint8_t DFU_MAGIC_UF2_RESET = 0x57; // Adafruit nRF52 bootloader UF2 magic diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp index 49a42e858e5..d2b095a3d75 100644 --- a/esphome/components/ntc/ntc.cpp +++ b/esphome/components/ntc/ntc.cpp @@ -3,7 +3,7 @@ namespace esphome::ntc { -static const char *const TAG = "ntc"; +ESPHOME_LOG_TAG(TAG, "ntc"); void NTC::setup() { this->sensor_->add_on_state_callback([this](float value) { this->process_(value); }); diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp index a3d49a6ff61..1bca604659c 100644 --- a/esphome/components/number/automation.cpp +++ b/esphome/components/number/automation.cpp @@ -3,7 +3,7 @@ namespace esphome::number { -static const char *const TAG = "number.automation"; +ESPHOME_LOG_TAG(TAG, "number.automation"); union convert { float from; diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index ca5aab64693..1d5533c423b 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -5,7 +5,7 @@ namespace esphome::number { -static const char *const TAG = "number"; +ESPHOME_LOG_TAG(TAG, "number"); // Function implementation of LOG_NUMBER macro to reduce code size void log_number(const char *tag, const char *prefix, const char *type, Number *obj) { diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index e300ca72de8..a928eaf3fa4 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -4,7 +4,7 @@ namespace esphome::number { -static const char *const TAG = "number"; +ESPHOME_LOG_TAG(TAG, "number"); // Helper functions to reduce code size for logging void NumberCall::log_perform_warning_(const LogString *message) { diff --git a/esphome/components/number/number_traits.cpp b/esphome/components/number/number_traits.cpp index 1e4239ceca5..e9559e91778 100644 --- a/esphome/components/number/number_traits.cpp +++ b/esphome/components/number/number_traits.cpp @@ -3,6 +3,6 @@ namespace esphome::number { -static const char *const TAG = "number"; +ESPHOME_LOG_TAG(TAG, "number"); } // namespace esphome::number diff --git a/esphome/components/number/sensor/number_sensor.cpp b/esphome/components/number/sensor/number_sensor.cpp index 227202622ae..e90c349f4fa 100644 --- a/esphome/components/number/sensor/number_sensor.cpp +++ b/esphome/components/number/sensor/number_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::number { -static const char *const TAG = "number.sensor"; +ESPHOME_LOG_TAG(TAG, "number.sensor"); void NumberSensor::setup() { this->source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/one_wire/one_wire.cpp b/esphome/components/one_wire/one_wire.cpp index aeab821f6ad..65a99623490 100644 --- a/esphome/components/one_wire/one_wire.cpp +++ b/esphome/components/one_wire/one_wire.cpp @@ -2,7 +2,7 @@ namespace esphome::one_wire { -static const char *const TAG = "one_wire"; +ESPHOME_LOG_TAG(TAG, "one_wire"); const std::string &OneWireDevice::get_address_name() { if (this->address_name_.empty()) { diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index b62e4f47d4c..0a4880cf733 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -3,7 +3,7 @@ namespace esphome::one_wire { -static const char *const TAG = "one_wire"; +ESPHOME_LOG_TAG(TAG, "one_wire"); static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; static const uint8_t DALLAS_MODEL_DS1822 = 0x22; diff --git a/esphome/components/online_image/download_buffer.cpp b/esphome/components/online_image/download_buffer.cpp index 2ec6365aa17..c4ea9e55332 100644 --- a/esphome/components/online_image/download_buffer.cpp +++ b/esphome/components/online_image/download_buffer.cpp @@ -4,7 +4,7 @@ namespace esphome::online_image { -static const char *const TAG = "online_image.download_buffer"; +ESPHOME_LOG_TAG(TAG, "online_image.download_buffer"); DownloadBuffer::DownloadBuffer(size_t size) : size_(size) { RAMAllocator allocator; diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index a21849d618c..4497d5cad12 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -5,7 +5,7 @@ #include #include -static const char *const TAG = "online_image"; +ESPHOME_LOG_TAG(TAG, "online_image"); static const char *const CONTENT_TYPE_HEADER_NAME = "content-type"; static const char *const ETAG_HEADER_NAME = "etag"; static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match"; diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp index f8b515fa05b..c253d18671d 100644 --- a/esphome/components/opentherm/hub.cpp +++ b/esphome/components/opentherm/hub.cpp @@ -5,7 +5,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm"; +ESPHOME_LOG_TAG(TAG, "opentherm"); namespace message_data { bool parse_flag8_lb_0(OpenthermData &data) { return read_bit(data.valueLB, 0); } bool parse_flag8_lb_1(OpenthermData &data) { return read_bit(data.valueLB, 1); } diff --git a/esphome/components/opentherm/number/opentherm_number.cpp b/esphome/components/opentherm/number/opentherm_number.cpp index d78c6eb38a1..f2d8d76f6f6 100644 --- a/esphome/components/opentherm/number/opentherm_number.cpp +++ b/esphome/components/opentherm/number/opentherm_number.cpp @@ -2,7 +2,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm.number"; +ESPHOME_LOG_TAG(TAG, "opentherm.number"); void OpenthermNumber::control(float value) { this->publish_state(value); diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 3343871cd03..b8b639167d2 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -20,7 +20,7 @@ namespace esphome::opentherm { using std::string; -static const char *const TAG = "opentherm"; +ESPHOME_LOG_TAG(TAG, "opentherm"); #ifdef ESP8266 OpenTherm *OpenTherm::instance = nullptr; diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 9b87cd8d12d..adbb1c40c16 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -3,7 +3,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm.output"; +ESPHOME_LOG_TAG(TAG, "opentherm.output"); void opentherm::OpenthermOutput::write_state(float state) { ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_); diff --git a/esphome/components/opentherm/switch/opentherm_switch.cpp b/esphome/components/opentherm/switch/opentherm_switch.cpp index 2fc99e793e8..6bf06f825e9 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.cpp +++ b/esphome/components/opentherm/switch/opentherm_switch.cpp @@ -2,7 +2,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm.switch"; +ESPHOME_LOG_TAG(TAG, "opentherm.switch"); void OpenthermSwitch::write_state(bool state) { this->publish_state(state); } diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 7d98522cd23..4986850cd4f 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -16,7 +16,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "openthread"; +ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 526fc961f2f..f3860b2cc81 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -17,7 +17,7 @@ #include "esp_vfs_eventfd.h" #include "nvs_flash.h" -static const char *const TAG = "openthread"; +ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { diff --git a/esphome/components/openthread/openthread_zephyr.cpp b/esphome/components/openthread/openthread_zephyr.cpp index 0f474fc040c..c252034c33c 100644 --- a/esphome/components/openthread/openthread_zephyr.cpp +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -7,7 +7,7 @@ #include "esphome/core/helpers.h" #include -static const char *const TAG = "openthread"; +ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { diff --git a/esphome/components/openthread_info/openthread_info_sensor.cpp b/esphome/components/openthread_info/openthread_info_sensor.cpp index 9beb52d64b0..d17dc9a9b6b 100644 --- a/esphome/components/openthread_info/openthread_info_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::openthread_info { -static const char *const TAG = "openthread_info"; +ESPHOME_LOG_TAG(TAG, "openthread_info"); void ParentAverageRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Average RSSI", this); } void ParentLastRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Last RSSI", this); } diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index fc61ad81b20..44eb5468502 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::openthread_info { -static const char *const TAG = "openthread_info"; +ESPHOME_LOG_TAG(TAG, "openthread_info"); void IPAddressOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "IPAddress", this); } void RoleOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Role", this); } diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index ce5fbdfd826..c6692d8dadb 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -3,7 +3,7 @@ namespace esphome::opt3001 { -static const char *const TAG = "opt3001.sensor"; +ESPHOME_LOG_TAG(TAG, "opt3001.sensor"); static const uint8_t OPT3001_REG_RESULT = 0x00; static const uint8_t OPT3001_REG_CONFIGURATION = 0x01; diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 231c4d2dd2e..02a5c5750f8 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -9,7 +9,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 48725b12657..74f44c0b3bc 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 2a6a9e08b1f..5be55a52000 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -46,7 +46,7 @@ static constexpr size_t MIN_BUFFER_SIZE = 256; namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index f33f37bbeb0..2316aad5cf6 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -15,7 +15,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 89e3f99e1e9..456fbb58638 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -27,7 +27,7 @@ namespace esphome::ota { namespace { -const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 5a83d92689f..a59d010e4c6 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); OTAResponseTypes IDFOTABackend::register_and_validate_bootloader_part_() { // Register the bootloader partition diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index d2b1196de65..f9ce1b49e11 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 2192a794410..59ffa9edb0d 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -30,7 +30,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); // Route the "Signature check: " prefix (and its per-block form) through one // shared format string each, so the prefix is pooled once by the linker instead diff --git a/esphome/components/output/button/output_button.cpp b/esphome/components/output/button/output_button.cpp index 5833743586f..6ebbae13d68 100644 --- a/esphome/components/output/button/output_button.cpp +++ b/esphome/components/output/button/output_button.cpp @@ -3,7 +3,7 @@ namespace esphome::output { -static const char *const TAG = "output.button"; +ESPHOME_LOG_TAG(TAG, "output.button"); void OutputButton::dump_config() { LOG_BUTTON("", "Output Button", this); diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index e2b029f3687..8057d0f9f18 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -4,7 +4,7 @@ namespace esphome::output { -static const char *const TAG = "output.float"; +ESPHOME_LOG_TAG(TAG, "output.float"); #ifdef USE_OUTPUT_FLOAT_POWER_SCALING void FloatOutput::set_max_power(float max_power) { diff --git a/esphome/components/output/lock/output_lock.cpp b/esphome/components/output/lock/output_lock.cpp index ee9d9185425..2f90f602f0f 100644 --- a/esphome/components/output/lock/output_lock.cpp +++ b/esphome/components/output/lock/output_lock.cpp @@ -3,7 +3,7 @@ namespace esphome::output { -static const char *const TAG = "output.lock"; +ESPHOME_LOG_TAG(TAG, "output.lock"); void OutputLock::dump_config() { LOG_LOCK("", "Output Lock", this); } diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index a21cbc0f6b9..a7bbbab5e68 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::output { -static const char *const TAG = "output.switch"; +ESPHOME_LOG_TAG(TAG, "output.switch"); void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); } diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 998e1be5fc6..f2dc43654ae 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -50,7 +50,7 @@ static constexpr size_t PACKET_MAX_LOG_BYTES = 168; * Ping key (4 bytes) * */ -static const char *const TAG = "packet_transport"; +ESPHOME_LOG_TAG(TAG, "packet_transport"); static size_t round4(size_t value) { return (value + 3) & ~3; } diff --git a/esphome/components/partition/light_partition.cpp b/esphome/components/partition/light_partition.cpp index 2755f82294a..6f2645b3658 100644 --- a/esphome/components/partition/light_partition.cpp +++ b/esphome/components/partition/light_partition.cpp @@ -3,6 +3,6 @@ namespace esphome::partition { -static const char *const TAG = "partition.light"; +ESPHOME_LOG_TAG(TAG, "partition.light"); } // namespace esphome::partition diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 4c19feab6eb..f805a8da545 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -20,7 +20,7 @@ enum PCA6416AGPIORegisters { PCAL6416A_PULL_DIR1 = 0x49, }; -static const char *const TAG = "pca6416a"; +ESPHOME_LOG_TAG(TAG, "pca6416a"); void PCA6416AComponent::setup() { // Test to see if device exists diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index c3ea6a3c0ce..f367d140678 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -9,7 +9,7 @@ const uint8_t OUTPUT_REG = 1; const uint8_t INVERT_REG = 2; const uint8_t CONFIG_REG = 3; -static const char *const TAG = "pca9554"; +ESPHOME_LOG_TAG(TAG, "pca9554"); void PCA9554Component::setup() { this->reg_width_ = (this->pin_count_ + 7) / 8; diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index 533b3391b14..fe417ed65de 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -5,7 +5,7 @@ namespace esphome::pca9685 { -static const char *const TAG = "pca9685"; +ESPHOME_LOG_TAG(TAG, "pca9685"); // PCA9685 mode constants are now inline constexpr in pca9685_output.h diff --git a/esphome/components/pcd8544/pcd_8544.cpp b/esphome/components/pcd8544/pcd_8544.cpp index c80283ffc98..953c4ac19b6 100644 --- a/esphome/components/pcd8544/pcd_8544.cpp +++ b/esphome/components/pcd8544/pcd_8544.cpp @@ -5,7 +5,7 @@ namespace esphome::pcd8544 { -static const char *const TAG = "pcd_8544"; +ESPHOME_LOG_TAG(TAG, "pcd_8544"); void PCD8544::setup_pins_() { this->spi_setup(); diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 6ec61770f24..8ce76e9781c 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -6,7 +6,7 @@ namespace esphome::pcf85063 { -static const char *const TAG = "pcf85063"; +ESPHOME_LOG_TAG(TAG, "pcf85063"); void PCF85063Component::setup() { if (!this->read_rtc_()) { diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index 93c0f2bdf27..90ea4b3fd10 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -6,7 +6,7 @@ namespace esphome::pcf8563 { -static const char *const TAG = "PCF8563"; +ESPHOME_LOG_TAG(TAG, "PCF8563"); void PCF8563Component::setup() { if (!this->read_rtc_()) { diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 2e054b0683a..727e9a20265 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -3,7 +3,7 @@ namespace esphome::pcf8574 { -static const char *const TAG = "pcf8574"; +ESPHOME_LOG_TAG(TAG, "pcf8574"); void PCF8574Component::setup() { if (!this->read_gpio_()) { diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp index 4f6417f6c0f..0ca851357f8 100644 --- a/esphome/components/pcm5122/pcm5122.cpp +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -7,7 +7,7 @@ namespace esphome::pcm5122 { -static const char *const TAG = "pcm5122"; +ESPHOME_LOG_TAG(TAG, "pcm5122"); void PCM5122::setup() { // Hold XSMT low (soft mute asserted) until init completes diff --git a/esphome/components/pcm5122/pcm5122_gpio.cpp b/esphome/components/pcm5122/pcm5122_gpio.cpp index 1aef1304579..104b2cb5350 100644 --- a/esphome/components/pcm5122/pcm5122_gpio.cpp +++ b/esphome/components/pcm5122/pcm5122_gpio.cpp @@ -5,7 +5,7 @@ namespace esphome::pcm5122 { -static const char *const TAG = "pcm5122.gpio"; +ESPHOME_LOG_TAG(TAG, "pcm5122.gpio"); void PCM5122GPIOPin::setup() { this->pin_mode(this->flags_); } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index e8e9530dba7..9bc10872d28 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -14,7 +14,7 @@ static const uint8_t PI4IOE5V6408_REGISTER_IN_STATE = 0x0F; static const uint8_t PI4IOE5V6408_REGISTER_INTERRUPT_ENABLE_MASK = 0x11; static const uint8_t PI4IOE5V6408_REGISTER_INTERRUPT_STATUS = 0x13; -static const char *const TAG = "pi4ioe5v6408"; +ESPHOME_LOG_TAG(TAG, "pi4ioe5v6408"); void PI4IOE5V6408Component::setup() { if (this->reset_) { diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index a7ae631956a..8b9ce7bbee3 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -5,7 +5,7 @@ namespace esphome::pid { -static const char *const TAG = "pid.autotune"; +ESPHOME_LOG_TAG(TAG, "pid.autotune"); /* * # PID Autotuner diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 1f0d45b0847..67585f65e8c 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -3,7 +3,7 @@ namespace esphome::pid { -static const char *const TAG = "pid.climate"; +ESPHOME_LOG_TAG(TAG, "pid.climate"); bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) { if (threshold_low > threshold_high) { diff --git a/esphome/components/pid/sensor/pid_climate_sensor.cpp b/esphome/components/pid/sensor/pid_climate_sensor.cpp index afc77efde33..1fbb42a0b19 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.cpp +++ b/esphome/components/pid/sensor/pid_climate_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pid { -static const char *const TAG = "pid.sensor"; +ESPHOME_LOG_TAG(TAG, "pid.sensor"); void PIDClimateSensor::setup() { this->parent_->add_on_pid_computed_callback([this]() { this->update_from_parent_(); }); diff --git a/esphome/components/pipsolar/output/pipsolar_output.cpp b/esphome/components/pipsolar/output/pipsolar_output.cpp index 1af753fce33..21e1a4ffcbf 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.cpp +++ b/esphome/components/pipsolar/output/pipsolar_output.cpp @@ -4,7 +4,7 @@ namespace esphome::pipsolar { -static const char *const TAG = "pipsolar.output"; +ESPHOME_LOG_TAG(TAG, "pipsolar.output"); void PipsolarOutput::write_state(float state) { char tmp[16]; diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index bd5733fe747..ade93b87359 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -4,7 +4,7 @@ namespace esphome::pipsolar { -static const char *const TAG = "pipsolar"; +ESPHOME_LOG_TAG(TAG, "pipsolar"); void Pipsolar::setup() { this->state_ = STATE_IDLE; diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.cpp b/esphome/components/pipsolar/switch/pipsolar_switch.cpp index 1eedfed0fd3..266df246300 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.cpp +++ b/esphome/components/pipsolar/switch/pipsolar_switch.cpp @@ -4,7 +4,7 @@ namespace esphome::pipsolar { -static const char *const TAG = "pipsolar.switch"; +ESPHOME_LOG_TAG(TAG, "pipsolar.switch"); void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); } void PipsolarSwitch::write_state(bool state) { diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index aa035be347d..7e9b0bd89bd 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -8,7 +8,7 @@ namespace esphome::pixoo { -static const char *const TAG = "pixoo"; +ESPHOME_LOG_TAG(TAG, "pixoo"); // Divoom LED-board packet protocol. static constexpr uint8_t PACKET_HEAD = 0xAA; diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index d4c6824713c..ebd508b5352 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -3,7 +3,7 @@ namespace esphome::pm1006 { -static const char *const TAG = "pm1006"; +ESPHOME_LOG_TAG(TAG, "pm1006"); static const uint8_t PM1006_RESPONSE_HEADER[] = {0x16, 0x11, 0x0B}; static const uint8_t PM1006_REQUEST[] = {0x11, 0x02, 0x0B, 0x01, 0xE1}; diff --git a/esphome/components/pm2005/pm2005.cpp b/esphome/components/pm2005/pm2005.cpp index 54a98bf3adb..784a555e024 100644 --- a/esphome/components/pm2005/pm2005.cpp +++ b/esphome/components/pm2005/pm2005.cpp @@ -3,7 +3,7 @@ namespace esphome::pm2005 { -static const char *const TAG = "pm2005"; +ESPHOME_LOG_TAG(TAG, "pm2005"); // Converts a sensor situation to a human readable string static const LogString *pm2005_get_situation_string(int status) { diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index f20b93fff80..6885795e8a7 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -5,7 +5,7 @@ namespace esphome::pmsa003i { -static const char *const TAG = "pmsa003i"; +ESPHOME_LOG_TAG(TAG, "pmsa003i"); static const uint8_t COUNT_PAYLOAD_BYTES = 28; static const uint8_t COUNT_PAYLOAD_LENGTH_BYTES = 2; diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index f8d890ac9ec..14b1f60df54 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -4,7 +4,7 @@ namespace esphome::pmsx003 { -static const char *const TAG = "pmsx003"; +ESPHOME_LOG_TAG(TAG, "pmsx003"); static const uint8_t START_CHARACTER_1 = 0x42; static const uint8_t START_CHARACTER_2 = 0x4D; diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 94c0c307665..7493f5d97fb 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -20,7 +20,7 @@ static const uint8_t PMWCS3_REG_RES = 0x0B; static const uint8_t PMWCS3_REG_RC = 0x0C; static const uint8_t PMWCS3_REG_RT = 0x0D; -static const char *const TAG = "pmwcs3"; +ESPHOME_LOG_TAG(TAG, "pmwcs3"); void PMWCS3Component::new_i2c_address(uint8_t address) { if (!this->write_byte(PMWCS3_SET_I2C_ADDRESS, address)) { diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 89392105a24..8fd92c9031f 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -14,7 +14,7 @@ namespace esphome::pn532 { -static const char *const TAG = "pn532"; +ESPHOME_LOG_TAG(TAG, "pn532"); void PN532::setup() { // Get version data diff --git a/esphome/components/pn532/pn532_mifare_classic.cpp b/esphome/components/pn532/pn532_mifare_classic.cpp index 9f937b3e288..878e07825d4 100644 --- a/esphome/components/pn532/pn532_mifare_classic.cpp +++ b/esphome/components/pn532/pn532_mifare_classic.cpp @@ -8,7 +8,7 @@ namespace esphome::pn532 { -static const char *const TAG = "pn532.mifare_classic"; +ESPHOME_LOG_TAG(TAG, "pn532.mifare_classic"); std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid) { uint8_t current_block = 4; diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index 90c85237224..afb60bd843a 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -7,7 +7,7 @@ namespace esphome::pn532 { -static const char *const TAG = "pn532.mifare_ultralight"; +ESPHOME_LOG_TAG(TAG, "pn532.mifare_ultralight"); std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid &uid) { UltralightReadBuffer data; diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 7f00e18d4c0..1687edd5116 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -11,7 +11,7 @@ namespace esphome::pn532_i2c { -static const char *const TAG = "pn532_i2c"; +ESPHOME_LOG_TAG(TAG, "pn532_i2c"); bool PN532I2C::is_read_ready() { uint8_t status; diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 73d65e4330d..e86153be86a 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -11,7 +11,7 @@ namespace esphome::pn532_spi { -static const char *const TAG = "pn532_spi"; +ESPHOME_LOG_TAG(TAG, "pn532_spi"); // Maximum bytes to log in verbose hex output static constexpr size_t PN532_MAX_LOG_BYTES = 64; diff --git a/esphome/components/pn7150/pn7150.cpp b/esphome/components/pn7150/pn7150.cpp index 25bb7a0c241..16bbac38e47 100644 --- a/esphome/components/pn7150/pn7150.cpp +++ b/esphome/components/pn7150/pn7150.cpp @@ -4,7 +4,7 @@ namespace esphome::pn7150 { -static const char *const TAG = "pn7150"; +ESPHOME_LOG_TAG(TAG, "pn7150"); void PN7150::dump_config() { ESP_LOGCONFIG(TAG, "PN7150:"); diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index 58d9daa311e..a87bd7d32e5 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::pn7150_i2c { -static const char *const TAG = "pn7150_i2c"; +ESPHOME_LOG_TAG(TAG, "pn7150_i2c"); uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { if (this->wait_for_irq_(timeout) != nfc::STATUS_OK) { diff --git a/esphome/components/pn7160/pn7160.cpp b/esphome/components/pn7160/pn7160.cpp index 185ff0ce097..7460f7cb6f9 100644 --- a/esphome/components/pn7160/pn7160.cpp +++ b/esphome/components/pn7160/pn7160.cpp @@ -5,7 +5,7 @@ namespace esphome::pn7160 { -static const char *const TAG = "pn7160"; +ESPHOME_LOG_TAG(TAG, "pn7160"); void PN7160::setup() { if (this->dwl_req_pin_ != nullptr) { diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index d559c6c2517..c9da1f4ad2e 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::pn7160_i2c { -static const char *const TAG = "pn7160_i2c"; +ESPHOME_LOG_TAG(TAG, "pn7160_i2c"); uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { if (this->wait_for_irq_(timeout) != nfc::STATUS_OK) { diff --git a/esphome/components/pn7160_spi/pn7160_spi.cpp b/esphome/components/pn7160_spi/pn7160_spi.cpp index 85a6c1fb7d0..2914b11e480 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.cpp +++ b/esphome/components/pn7160_spi/pn7160_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::pn7160_spi { -static const char *const TAG = "pn7160_spi"; +ESPHOME_LOG_TAG(TAG, "pn7160_spi"); void PN7160Spi::setup() { this->spi_setup(); diff --git a/esphome/components/pn71xx/pn71xx.cpp b/esphome/components/pn71xx/pn71xx.cpp index 6a24acd843e..469700e01ed 100644 --- a/esphome/components/pn71xx/pn71xx.cpp +++ b/esphome/components/pn71xx/pn71xx.cpp @@ -10,7 +10,7 @@ namespace esphome::pn71xx { -static const char *const TAG = "pn71xx"; +ESPHOME_LOG_TAG(TAG, "pn71xx"); // Builds a message with a URI record and, optionally, the Home Assistant Android app record static std::unique_ptr build_uri_message(const std::string &uri, diff --git a/esphome/components/pn71xx/pn71xx_mifare_classic.cpp b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp index c1c927634d4..e1e805d1087 100644 --- a/esphome/components/pn71xx/pn71xx_mifare_classic.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp @@ -8,7 +8,7 @@ namespace esphome::pn71xx { -static const char *const TAG = "pn71xx.mifare_classic"; +ESPHOME_LOG_TAG(TAG, "pn71xx.mifare_classic"); uint8_t PN71xx::read_mifare_classic_tag_(nfc::NfcTag &tag) { uint8_t current_block = 4; diff --git a/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp b/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp index 53874e18de5..68b86b6257b 100644 --- a/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp @@ -8,7 +8,7 @@ namespace esphome::pn71xx { -static const char *const TAG = "pn71xx.mifare_ultralight"; +ESPHOME_LOG_TAG(TAG, "pn71xx.mifare_ultralight"); uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { UltralightReadBuffer data; diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index f094f6e2e98..7daee1663a4 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -3,7 +3,7 @@ namespace esphome::power_supply { -static const char *const TAG = "power_supply"; +ESPHOME_LOG_TAG(TAG, "power_supply"); void PowerSupply::setup() { this->pin_->setup(); diff --git a/esphome/components/provisioning/provisioning.cpp b/esphome/components/provisioning/provisioning.cpp index 02c089bfed3..0089bdcbb72 100644 --- a/esphome/components/provisioning/provisioning.cpp +++ b/esphome/components/provisioning/provisioning.cpp @@ -10,7 +10,7 @@ namespace esphome::provisioning { -static const char *const TAG = "provisioning"; +ESPHOME_LOG_TAG(TAG, "provisioning"); ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) nullptr; diff --git a/esphome/components/psram/psram.cpp b/esphome/components/psram/psram.cpp index ab680c96955..55b6873d055 100644 --- a/esphome/components/psram/psram.cpp +++ b/esphome/components/psram/psram.cpp @@ -9,7 +9,7 @@ #include namespace esphome::psram { -static const char *const TAG = "psram"; +ESPHOME_LOG_TAG(TAG, "psram"); void PsramComponent::dump_config() { ESP_LOGCONFIG(TAG, "PSRAM:"); diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.cpp b/esphome/components/pulse_counter/pulse_counter_sensor.cpp index 13bf3baf832..b104016fb8f 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.cpp +++ b/esphome/components/pulse_counter/pulse_counter_sensor.cpp @@ -9,7 +9,7 @@ namespace esphome::pulse_counter { -static const char *const TAG = "pulse_counter"; +ESPHOME_LOG_TAG(TAG, "pulse_counter"); const char *const EDGE_MODE_TO_STRING[] = {"DISABLE", "INCREMENT", "DECREMENT"}; diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index d6959d1a964..c2d4c648ce9 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pulse_meter { -static const char *const TAG = "pulse_meter"; +ESPHOME_LOG_TAG(TAG, "pulse_meter"); void PulseMeterSensor::set_total_pulses(uint32_t pulses) { this->total_pulses_ = pulses; diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index 5209ed5352a..53896cbacdb 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -3,7 +3,7 @@ namespace esphome::pulse_width { -static const char *const TAG = "pulse_width"; +ESPHOME_LOG_TAG(TAG, "pulse_width"); void IRAM_ATTR PulseWidthSensorStore::gpio_intr(PulseWidthSensorStore *arg) { const bool new_level = arg->pin_.digital_read(); diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 64b8974901a..57876f8c074 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -7,7 +7,7 @@ namespace esphome::pvvx_mithermometer { -static const char *const TAG = "display.pvvx_mithermometer"; +ESPHOME_LOG_TAG(TAG, "display.pvvx_mithermometer"); void PVVXDisplay::dump_config() { char service_buf[ble_device_base::UUID_STR_LEN]; diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index 9141d12b16c..0d7f440f15e 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -3,7 +3,7 @@ namespace esphome::pvvx_mithermometer { -static const char *const TAG = "pvvx_mithermometer"; +ESPHOME_LOG_TAG(TAG, "pvvx_mithermometer"); void PVVXMiThermometer::dump_config() { ESP_LOGCONFIG(TAG, "PVVX MiThermometer"); diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 932b71ba55c..0898b924869 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -26,7 +26,7 @@ namespace esphome::pylontech { -static const char *const TAG = "pylontech"; +ESPHOME_LOG_TAG(TAG, "pylontech"); static const int MAX_DATA_LENGTH_BYTES = 256; static const uint8_t ASCII_LF = 0x0A; diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index e2def28be54..dc236f47a55 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pylontech { -static const char *const TAG = "pylontech.sensor"; +ESPHOME_LOG_TAG(TAG, "pylontech.sensor"); PylontechSensor::PylontechSensor(int8_t bat_num) { this->bat_num_ = bat_num; } diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp index a7c9db15996..1beb04064ec 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pylontech { -static const char *const TAG = "pylontech.textsensor"; +ESPHOME_LOG_TAG(TAG, "pylontech.textsensor"); PylontechTextSensor::PylontechTextSensor(int8_t bat_num) { this->bat_num_ = bat_num; } diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index a28b448340a..7809e803c9d 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -5,7 +5,7 @@ namespace esphome::pzem004t { -static const char *const TAG = "pzem004t"; +ESPHOME_LOG_TAG(TAG, "pzem004t"); void PZEM004T::setup() { // Clear UART buffer diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp index f9f97606a82..41524eebd27 100644 --- a/esphome/components/pzem6l24/pzem6l24.cpp +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -8,7 +8,7 @@ namespace esphome::pzem6l24 { -static const char *const TAG = "pzem6l24"; +ESPHOME_LOG_TAG(TAG, "pzem6l24"); // ----------------------------------------------------------------------- // Register map (input registers, starting address 0x0000): diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index c0d163c1dec..e291844d787 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -5,7 +5,7 @@ namespace esphome::pzemac { namespace helpers = modbus::helpers; -static const char *const TAG = "pzemac"; +ESPHOME_LOG_TAG(TAG, "pzemac"); static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index eb9a3558069..76b9c2b2e09 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -5,7 +5,7 @@ namespace esphome::pzemdc { namespace helpers = modbus::helpers; -static const char *const TAG = "pzemdc"; +ESPHOME_LOG_TAG(TAG, "pzemdc"); static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 8; // 8x 16-bit registers diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index ba6a71f97d1..dcfa46abc5b 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -7,7 +7,7 @@ namespace esphome::qmc5883l { -static const char *const TAG = "qmc5883l"; +ESPHOME_LOG_TAG(TAG, "qmc5883l"); static const uint8_t QMC5883L_ADDRESS = 0x0D; diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index 2fd457d2900..4f35d5bf365 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -4,7 +4,7 @@ namespace esphome::qmi8658 { -static const char *const TAG = "qmi8658"; +ESPHOME_LOG_TAG(TAG, "qmi8658"); // Acceleration scale (g per LSB), indexed by accel_range_ >> 4. // Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 547991f75e0..ea3bf19eb4c 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -43,7 +43,7 @@ static const uint8_t QMP6988_CONFIG_REG_FILTER_LEN = 3; static const uint32_t SUBTRACTOR = 8388608; -static const char *const TAG = "qmp6988"; +ESPHOME_LOG_TAG(TAG, "qmp6988"); static const char *oversampling_to_str(QMP6988Oversampling oversampling) { switch (oversampling) { diff --git a/esphome/components/qr_code/qr_code.cpp b/esphome/components/qr_code/qr_code.cpp index edb78b98e1e..78d20d9bfe0 100644 --- a/esphome/components/qr_code/qr_code.cpp +++ b/esphome/components/qr_code/qr_code.cpp @@ -5,7 +5,7 @@ namespace esphome::qr_code { -static const char *const TAG = "qr_code"; +ESPHOME_LOG_TAG(TAG, "qr_code"); void QrCode::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index eb338db772d..fffc3514d03 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -3,7 +3,7 @@ namespace esphome::qwiic_pir { -static const char *const TAG = "qwiic_pir"; +ESPHOME_LOG_TAG(TAG, "qwiic_pir"); void QwiicPIRComponent::setup() { // Verify I2C communcation by reading and verifying the chip ID diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index a2386032177..231d6c3f898 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -6,7 +6,7 @@ namespace esphome::radio_frequency { -static const char *const TAG = "radio_frequency"; +ESPHOME_LOG_TAG(TAG, "radio_frequency"); void RadioFrequency::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp index 9ff279cab9f..bef59757858 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.cpp +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -4,7 +4,7 @@ namespace esphome::radon_eye_ble { -static const char *const TAG = "radon_eye_ble"; +ESPHOME_LOG_TAG(TAG, "radon_eye_ble"); bool RadonEyeListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Radon Eye devices have names starting with "FR:" diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index de5bd3d8d58..84b89f377be 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -7,7 +7,7 @@ namespace esphome::radon_eye_rd200 { -static const char *const TAG = "radon_eye_rd200"; +ESPHOME_LOG_TAG(TAG, "radon_eye_rd200"); static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V1 = esp32_ble_tracker::ESPBTUUID::from_raw("00001523-1212-efde-1523-785feabcd123"); diff --git a/esphome/components/rc522/rc522.cpp b/esphome/components/rc522/rc522.cpp index 7c1b6ae3148..6549ba9846c 100644 --- a/esphome/components/rc522/rc522.cpp +++ b/esphome/components/rc522/rc522.cpp @@ -9,7 +9,7 @@ namespace esphome::rc522 { static const uint8_t WAIT_I_RQ = 0x30; // RxIRq and IdleIRq -static const char *const TAG = "rc522"; +ESPHOME_LOG_TAG(TAG, "rc522"); // Max UID size for RFID tags (4, 7, or 10 bytes) static constexpr size_t RC522_MAX_UID_SIZE = 10; diff --git a/esphome/components/rc522_i2c/rc522_i2c.cpp b/esphome/components/rc522_i2c/rc522_i2c.cpp index dbc86ff8e78..7fc04160302 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.cpp +++ b/esphome/components/rc522_i2c/rc522_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::rc522_i2c { -static const char *const TAG = "rc522_i2c"; +ESPHOME_LOG_TAG(TAG, "rc522_i2c"); void RC522I2C::dump_config() { RC522::dump_config(); diff --git a/esphome/components/rc522_spi/rc522_spi.cpp b/esphome/components/rc522_spi/rc522_spi.cpp index b63ad1cfdc1..2d9bbf5b52c 100644 --- a/esphome/components/rc522_spi/rc522_spi.cpp +++ b/esphome/components/rc522_spi/rc522_spi.cpp @@ -7,7 +7,7 @@ namespace esphome::rc522_spi { -static const char *const TAG = "rc522_spi"; +ESPHOME_LOG_TAG(TAG, "rc522_spi"); void RC522Spi::setup() { this->spi_setup(); diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index 18328def9ff..7a2af571c78 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -8,7 +8,7 @@ namespace esphome::rd03d { -static const char *const TAG = "rd03d"; +ESPHOME_LOG_TAG(TAG, "rd03d"); // Delay before sending configuration commands to allow radar to initialize static constexpr uint32_t SETUP_TIMEOUT_MS = 100; diff --git a/esphome/components/rdm6300/rdm6300.cpp b/esphome/components/rdm6300/rdm6300.cpp index 00c91cbc473..2bdbde91748 100644 --- a/esphome/components/rdm6300/rdm6300.cpp +++ b/esphome/components/rdm6300/rdm6300.cpp @@ -4,7 +4,7 @@ namespace esphome::rdm6300 { -static const char *const TAG = "rdm6300"; +ESPHOME_LOG_TAG(TAG, "rdm6300"); static const uint8_t RDM6300_START_BYTE = 0x02; static const uint8_t RDM6300_END_BYTE = 0x03; diff --git a/esphome/components/remote_base/abbwelcome_protocol.cpp b/esphome/components/remote_base/abbwelcome_protocol.cpp index 2000148ca8f..638f33687a5 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.cpp +++ b/esphome/components/remote_base/abbwelcome_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.abbwelcome"; +ESPHOME_LOG_TAG(TAG, "remote.abbwelcome"); static constexpr uint32_t BIT_ONE_SPACE_US = 102; static constexpr uint32_t BIT_ZERO_MARK_US = 32; // 18-44 diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index 69f91ba90e4..eb05f7d21e8 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.aeha"; +ESPHOME_LOG_TAG(TAG, "remote.aeha"); static constexpr uint16_t BITWISE = 425; static constexpr uint16_t HEADER_HIGH_US = BITWISE * 8; diff --git a/esphome/components/remote_base/beo4_protocol.cpp b/esphome/components/remote_base/beo4_protocol.cpp index 9291501ea3d..e79e05d8393 100644 --- a/esphome/components/remote_base/beo4_protocol.cpp +++ b/esphome/components/remote_base/beo4_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.beo4"; +ESPHOME_LOG_TAG(TAG, "remote.beo4"); // beo4 pulse width, high=carrier_pulse low=data_pulse constexpr uint16_t PW_CARR_US = 200; // carrier pulse length diff --git a/esphome/components/remote_base/brennenstuhl_protocol.cpp b/esphome/components/remote_base/brennenstuhl_protocol.cpp index 35261dc00a0..2537ba55603 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.cpp +++ b/esphome/components/remote_base/brennenstuhl_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.brennenstuhl"; +ESPHOME_LOG_TAG(TAG, "remote.brennenstuhl"); // receiver timing ranges [µs] constexpr uint32_t START_PULSE_MIN = 200; diff --git a/esphome/components/remote_base/byronsx_protocol.cpp b/esphome/components/remote_base/byronsx_protocol.cpp index 11d6d975d14..468b11f6956 100644 --- a/esphome/components/remote_base/byronsx_protocol.cpp +++ b/esphome/components/remote_base/byronsx_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.byronsx"; +ESPHOME_LOG_TAG(TAG, "remote.byronsx"); static constexpr uint32_t BIT_TIME_US = 333; static constexpr uint8_t NBITS_ADDRESS = 8; diff --git a/esphome/components/remote_base/canalsat_protocol.cpp b/esphome/components/remote_base/canalsat_protocol.cpp index eafa98ebcc4..ac83c372275 100644 --- a/esphome/components/remote_base/canalsat_protocol.cpp +++ b/esphome/components/remote_base/canalsat_protocol.cpp @@ -3,8 +3,8 @@ namespace esphome::remote_base { -static const char *const CANALSAT_TAG = "remote.canalsat"; -static const char *const CANALSATLD_TAG = "remote.canalsatld"; +ESPHOME_LOG_TAG(CANALSAT_TAG, "remote.canalsat"); +ESPHOME_LOG_TAG(CANALSATLD_TAG, "remote.canalsatld"); static constexpr uint16_t CANALSAT_FREQ = 55500; static constexpr uint16_t CANALSATLD_FREQ = 56000; diff --git a/esphome/components/remote_base/coolix_protocol.cpp b/esphome/components/remote_base/coolix_protocol.cpp index 53e1d59f135..e2e54afadb6 100644 --- a/esphome/components/remote_base/coolix_protocol.cpp +++ b/esphome/components/remote_base/coolix_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.coolix"; +ESPHOME_LOG_TAG(TAG, "remote.coolix"); static const int32_t TICK_US = 560; static const int32_t HEADER_MARK_US = 8 * TICK_US; diff --git a/esphome/components/remote_base/dish_protocol.cpp b/esphome/components/remote_base/dish_protocol.cpp index 5a89c91a50e..6877d661bdd 100644 --- a/esphome/components/remote_base/dish_protocol.cpp +++ b/esphome/components/remote_base/dish_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.dish"; +ESPHOME_LOG_TAG(TAG, "remote.dish"); static constexpr uint32_t HEADER_HIGH_US = 400; static constexpr uint32_t HEADER_LOW_US = 6100; diff --git a/esphome/components/remote_base/dooya_protocol.cpp b/esphome/components/remote_base/dooya_protocol.cpp index 6dc7926b4a5..5291d69345c 100644 --- a/esphome/components/remote_base/dooya_protocol.cpp +++ b/esphome/components/remote_base/dooya_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.dooya"; +ESPHOME_LOG_TAG(TAG, "remote.dooya"); static constexpr uint32_t HEADER_HIGH_US = 5000; static constexpr uint32_t HEADER_LOW_US = 1500; diff --git a/esphome/components/remote_base/drayton_protocol.cpp b/esphome/components/remote_base/drayton_protocol.cpp index 2261bd04e99..598f1a476ef 100644 --- a/esphome/components/remote_base/drayton_protocol.cpp +++ b/esphome/components/remote_base/drayton_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.drayton"; +ESPHOME_LOG_TAG(TAG, "remote.drayton"); static constexpr uint32_t BIT_TIME_US = 500; static constexpr uint8_t CARRIER_KHZ = 2; diff --git a/esphome/components/remote_base/dyson_protocol.cpp b/esphome/components/remote_base/dyson_protocol.cpp index 942b40d26fc..f6a7943ebd4 100644 --- a/esphome/components/remote_base/dyson_protocol.cpp +++ b/esphome/components/remote_base/dyson_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.dyson"; +ESPHOME_LOG_TAG(TAG, "remote.dyson"); // pulsewidth [µs] constexpr uint32_t PW_MARK_US = 780; diff --git a/esphome/components/remote_base/gobox_protocol.cpp b/esphome/components/remote_base/gobox_protocol.cpp index 1d67be86b86..affafa694ca 100644 --- a/esphome/components/remote_base/gobox_protocol.cpp +++ b/esphome/components/remote_base/gobox_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.gobox"; +ESPHOME_LOG_TAG(TAG, "remote.gobox"); constexpr uint32_t BIT_MARK_US = 580; // 70us seems like a safe time delta for the receiver... constexpr uint32_t BIT_ONE_SPACE_US = 1640; diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index 8801f1049af..3c9ba7b61f0 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.haier"; +ESPHOME_LOG_TAG(TAG, "remote.haier"); constexpr uint32_t HEADER_LOW_US = 3100; constexpr uint32_t HEADER_HIGH_US = 4400; diff --git a/esphome/components/remote_base/hob2hood_protocol.cpp b/esphome/components/remote_base/hob2hood_protocol.cpp index 9e706b9fe66..95683ba8cc0 100644 --- a/esphome/components/remote_base/hob2hood_protocol.cpp +++ b/esphome/components/remote_base/hob2hood_protocol.cpp @@ -7,7 +7,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.hob2hood"; +ESPHOME_LOG_TAG(TAG, "remote.hob2hood"); // A frame is 25 bits: a leading 0, then the command byte, command + 1 and command + 2. Zero bits are marks // and one bits are spaces; equal neighbours merge into one run of n * BIT_TIME_US plus a fixed adjustment. diff --git a/esphome/components/remote_base/jvc_protocol.cpp b/esphome/components/remote_base/jvc_protocol.cpp index 86a47e757de..6fbbf6ee477 100644 --- a/esphome/components/remote_base/jvc_protocol.cpp +++ b/esphome/components/remote_base/jvc_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.jvc"; +ESPHOME_LOG_TAG(TAG, "remote.jvc"); static constexpr uint8_t NBITS = 16; static constexpr uint32_t HEADER_HIGH_US = 8400; diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index 8cc9de94b8d..caa950218ec 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.keeloq"; +ESPHOME_LOG_TAG(TAG, "remote.keeloq"); static constexpr uint32_t BIT_TIME_US = 380; static constexpr uint8_t NBITS_PREAMBLE = 12; diff --git a/esphome/components/remote_base/lg_protocol.cpp b/esphome/components/remote_base/lg_protocol.cpp index e450659b42f..07f4b5a1dca 100644 --- a/esphome/components/remote_base/lg_protocol.cpp +++ b/esphome/components/remote_base/lg_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.lg"; +ESPHOME_LOG_TAG(TAG, "remote.lg"); static constexpr uint32_t HEADER_HIGH_US = 8000; static constexpr uint32_t HEADER_LOW_US = 4000; diff --git a/esphome/components/remote_base/magiquest_protocol.cpp b/esphome/components/remote_base/magiquest_protocol.cpp index f417827b389..5b47f7e438f 100644 --- a/esphome/components/remote_base/magiquest_protocol.cpp +++ b/esphome/components/remote_base/magiquest_protocol.cpp @@ -7,7 +7,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.magiquest"; +ESPHOME_LOG_TAG(TAG, "remote.magiquest"); static constexpr uint32_t MAGIQUEST_UNIT = 288; // us static constexpr uint32_t MAGIQUEST_ONE_MARK = 2 * MAGIQUEST_UNIT; diff --git a/esphome/components/remote_base/midea_protocol.cpp b/esphome/components/remote_base/midea_protocol.cpp index 6889c5d9b4f..57aeda2e90a 100644 --- a/esphome/components/remote_base/midea_protocol.cpp +++ b/esphome/components/remote_base/midea_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.midea"; +ESPHOME_LOG_TAG(TAG, "remote.midea"); static const int32_t TICK_US = 560; static const int32_t HEADER_MARK_US = 8 * TICK_US; diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index 380cfaecb26..c2a487080bc 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.mirage"; +ESPHOME_LOG_TAG(TAG, "remote.mirage"); constexpr uint32_t HEADER_MARK_US = 8360; constexpr uint32_t HEADER_SPACE_US = 4248; diff --git a/esphome/components/remote_base/nec_protocol.cpp b/esphome/components/remote_base/nec_protocol.cpp index e639248b4e4..3edb50141c4 100644 --- a/esphome/components/remote_base/nec_protocol.cpp +++ b/esphome/components/remote_base/nec_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.nec"; +ESPHOME_LOG_TAG(TAG, "remote.nec"); static constexpr uint32_t HEADER_HIGH_US = 9000; static constexpr uint32_t HEADER_LOW_US = 4500; diff --git a/esphome/components/remote_base/nexa_protocol.cpp b/esphome/components/remote_base/nexa_protocol.cpp index e691a695987..2d0d2cf7579 100644 --- a/esphome/components/remote_base/nexa_protocol.cpp +++ b/esphome/components/remote_base/nexa_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.nexa"; +ESPHOME_LOG_TAG(TAG, "remote.nexa"); static constexpr uint8_t NBITS = 32; static constexpr uint32_t HEADER_HIGH_US = 319; diff --git a/esphome/components/remote_base/panasonic_protocol.cpp b/esphome/components/remote_base/panasonic_protocol.cpp index b0262130804..c2fc35d3ec9 100644 --- a/esphome/components/remote_base/panasonic_protocol.cpp +++ b/esphome/components/remote_base/panasonic_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.panasonic"; +ESPHOME_LOG_TAG(TAG, "remote.panasonic"); static constexpr uint32_t HEADER_HIGH_US = 3502; static constexpr uint32_t HEADER_LOW_US = 1750; diff --git a/esphome/components/remote_base/pioneer_protocol.cpp b/esphome/components/remote_base/pioneer_protocol.cpp index f4d6aa4026c..20603848f1f 100644 --- a/esphome/components/remote_base/pioneer_protocol.cpp +++ b/esphome/components/remote_base/pioneer_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.pioneer"; +ESPHOME_LOG_TAG(TAG, "remote.pioneer"); static constexpr uint32_t HEADER_HIGH_US = 9000; static constexpr uint32_t HEADER_LOW_US = 4500; diff --git a/esphome/components/remote_base/pronto_protocol.cpp b/esphome/components/remote_base/pronto_protocol.cpp index dc128d4622d..e47ba892917 100644 --- a/esphome/components/remote_base/pronto_protocol.cpp +++ b/esphome/components/remote_base/pronto_protocol.cpp @@ -37,7 +37,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.pronto"; +ESPHOME_LOG_TAG(TAG, "remote.pronto"); bool ProntoData::operator==(const ProntoData &rhs) const { std::vector data1 = encode_pronto(data); diff --git a/esphome/components/remote_base/raw_protocol.cpp b/esphome/components/remote_base/raw_protocol.cpp index 02c29168496..134204aa2a0 100644 --- a/esphome/components/remote_base/raw_protocol.cpp +++ b/esphome/components/remote_base/raw_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.raw"; +ESPHOME_LOG_TAG(TAG, "remote.raw"); bool RawDumper::dump(RemoteReceiveData src) { char buffer[256]; diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index fd136a4e6d9..cec747eb480 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.rc5"; +ESPHOME_LOG_TAG(TAG, "remote.rc5"); static constexpr uint32_t BIT_TIME_US = 889; static constexpr uint8_t NBITS = 14; diff --git a/esphome/components/remote_base/rc6_protocol.cpp b/esphome/components/remote_base/rc6_protocol.cpp index ddd9e532580..067b8b812e4 100644 --- a/esphome/components/remote_base/rc6_protocol.cpp +++ b/esphome/components/remote_base/rc6_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const RC6_TAG = "remote.rc6"; +ESPHOME_LOG_TAG(RC6_TAG, "remote.rc6"); static constexpr uint16_t RC6_FREQ = 36000; static constexpr uint16_t RC6_UNIT = 444; diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index bdd6615d432..a469cf2d454 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -6,7 +6,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.rc_switch"; +ESPHOME_LOG_TAG(TAG, "remote.rc_switch"); RCSwitchBase rc_switch_protocol(uint8_t index) { // entry 0 is the all-zero protocol, so an out of range index from a lambda transmits nothing diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index a685b733b46..7205fb6221a 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote_base"; +ESPHOME_LOG_TAG(TAG, "remote_base"); /* RemoteReceiveData */ diff --git a/esphome/components/remote_base/roomba_protocol.cpp b/esphome/components/remote_base/roomba_protocol.cpp index 8053792a60c..bd40a7a5448 100644 --- a/esphome/components/remote_base/roomba_protocol.cpp +++ b/esphome/components/remote_base/roomba_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.roomba"; +ESPHOME_LOG_TAG(TAG, "remote.roomba"); static constexpr uint8_t NBITS = 8; static constexpr uint32_t BIT_ONE_HIGH_US = 3000; diff --git a/esphome/components/remote_base/samsung36_protocol.cpp b/esphome/components/remote_base/samsung36_protocol.cpp index ded8c71aa38..c59ccf0106f 100644 --- a/esphome/components/remote_base/samsung36_protocol.cpp +++ b/esphome/components/remote_base/samsung36_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.samsung36"; +ESPHOME_LOG_TAG(TAG, "remote.samsung36"); static constexpr uint8_t NBITS = 78; diff --git a/esphome/components/remote_base/samsung_protocol.cpp b/esphome/components/remote_base/samsung_protocol.cpp index 7190e974038..7fce46313b8 100644 --- a/esphome/components/remote_base/samsung_protocol.cpp +++ b/esphome/components/remote_base/samsung_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.samsung"; +ESPHOME_LOG_TAG(TAG, "remote.samsung"); static constexpr uint32_t HEADER_HIGH_US = 4500; static constexpr uint32_t HEADER_LOW_US = 4500; diff --git a/esphome/components/remote_base/sony_protocol.cpp b/esphome/components/remote_base/sony_protocol.cpp index 0abb7fc0e02..00d5aee9c8c 100644 --- a/esphome/components/remote_base/sony_protocol.cpp +++ b/esphome/components/remote_base/sony_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.sony"; +ESPHOME_LOG_TAG(TAG, "remote.sony"); static constexpr uint32_t HEADER_HIGH_US = 2400; static constexpr uint32_t HEADER_LOW_US = 600; diff --git a/esphome/components/remote_base/symphony_protocol.cpp b/esphome/components/remote_base/symphony_protocol.cpp index 8b39abf9ad9..962cf12ff68 100644 --- a/esphome/components/remote_base/symphony_protocol.cpp +++ b/esphome/components/remote_base/symphony_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.symphony"; +ESPHOME_LOG_TAG(TAG, "remote.symphony"); // Reference implementation and timing details: // IRremoteESP8266 ir_Symphony.cpp diff --git a/esphome/components/remote_base/toshiba_ac_protocol.cpp b/esphome/components/remote_base/toshiba_ac_protocol.cpp index 077b4340fa6..addcd6f9cbf 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.cpp +++ b/esphome/components/remote_base/toshiba_ac_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.toshibaac"; +ESPHOME_LOG_TAG(TAG, "remote.toshibaac"); static constexpr uint32_t HEADER_HIGH_US = 4500; static constexpr uint32_t HEADER_LOW_US = 4500; diff --git a/esphome/components/remote_base/toto_protocol.cpp b/esphome/components/remote_base/toto_protocol.cpp index 042efcbc363..c94fce87be0 100644 --- a/esphome/components/remote_base/toto_protocol.cpp +++ b/esphome/components/remote_base/toto_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.toto"; +ESPHOME_LOG_TAG(TAG, "remote.toto"); static constexpr uint32_t PREAMBLE_HIGH_US = 6200; static constexpr uint32_t PREAMBLE_LOW_US = 2800; diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index b3e4649096b..58c72a8bb8c 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -7,7 +7,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver"; +ESPHOME_LOG_TAG(TAG, "remote_receiver"); static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_t delta, bool level) { // convert level to -1 or +1 and write the delta to the buffer diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 64392aa7eeb..2b76ab8989b 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -10,7 +10,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver"; +ESPHOME_LOG_TAG(TAG, "remote_receiver"); static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 29ef9bc2cc2..34eb67b3c53 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -7,7 +7,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); void RemoteTransmitterComponent::setup() { this->pin_->setup(); diff --git a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp index 822389ccf95..dced3720b44 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp @@ -19,7 +19,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); #ifdef REMOTE_TRANSMITTER_BK_PWM diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index 26e9a1baa30..55f18277cef 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -12,7 +12,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); // Margin past a transmission's expected duration before the chain is declared stalled static constexpr uint32_t STALL_MARGIN_MS = 1000; diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 91bd339f210..f6550d107c6 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -9,7 +9,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); // Maximum RMT symbol duration (15-bit field) static constexpr uint32_t RMT_SYMBOL_DURATION_MAX = 0x7FFF; diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index 799f46401c6..62be203ee32 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -15,7 +15,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); // PWM peripheral carrier, envelope paced by a gtimer interrupt chain. Bit-banging would need // interrupts disabled for the whole frame, but this core's micros() derives from the FreeRTOS diff --git a/esphome/components/resampler/microphone/resampler_microphone.cpp b/esphome/components/resampler/microphone/resampler_microphone.cpp index 0dcd5233800..a50f6adea80 100644 --- a/esphome/components/resampler/microphone/resampler_microphone.cpp +++ b/esphome/components/resampler/microphone/resampler_microphone.cpp @@ -9,7 +9,7 @@ namespace esphome::resampler { -static const char *const TAG = "resampler.microphone"; +ESPHOME_LOG_TAG(TAG, "resampler.microphone"); // Duration of audio the resampler converts per step; longer source chunks are processed in several steps static constexpr uint32_t BUFFER_DURATION_MS = 16; diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index 16d2d5dc9e2..99b00ec0948 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -22,7 +22,7 @@ static const uint32_t TASK_STACK_SIZE = 3072; static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; -static const char *const TAG = "resampler_speaker"; +ESPHOME_LOG_TAG(TAG, "resampler_speaker"); enum ResamplingEventGroupBits : uint32_t { COMMAND_STOP = (1 << 0), // signals stop request diff --git a/esphome/components/resistance/resistance_sensor.cpp b/esphome/components/resistance/resistance_sensor.cpp index 7522d026a4e..d6bc324fa42 100644 --- a/esphome/components/resistance/resistance_sensor.cpp +++ b/esphome/components/resistance/resistance_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::resistance { -static const char *const TAG = "resistance"; +ESPHOME_LOG_TAG(TAG, "resistance"); void ResistanceSensor::dump_config() { LOG_SENSOR("", "Resistance Sensor", this); diff --git a/esphome/components/restart/button/restart_button.cpp b/esphome/components/restart/button/restart_button.cpp index d6404315ea9..cffa47896ce 100644 --- a/esphome/components/restart/button/restart_button.cpp +++ b/esphome/components/restart/button/restart_button.cpp @@ -5,7 +5,7 @@ namespace esphome::restart { -static const char *const TAG = "restart.button"; +ESPHOME_LOG_TAG(TAG, "restart.button"); void RestartButton::press_action() { ESP_LOGI(TAG, "Restarting device"); diff --git a/esphome/components/restart/switch/restart_switch.cpp b/esphome/components/restart/switch/restart_switch.cpp index 96a4fc40f5c..50102366fc3 100644 --- a/esphome/components/restart/switch/restart_switch.cpp +++ b/esphome/components/restart/switch/restart_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::restart { -static const char *const TAG = "restart"; +ESPHOME_LOG_TAG(TAG, "restart"); void RestartSwitch::write_state(bool state) { // Acknowledge diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index a4a4da5d8c0..86daaf4b08b 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -7,7 +7,7 @@ namespace esphome::rf_bridge { -static const char *const TAG = "rf_bridge"; +ESPHOME_LOG_TAG(TAG, "rf_bridge"); void RFBridgeComponent::ack_() { ESP_LOGV(TAG, "Sending ACK"); diff --git a/esphome/components/ring_buffer/ring_buffer.cpp b/esphome/components/ring_buffer/ring_buffer.cpp index dfcc53ebb2e..7197a073481 100644 --- a/esphome/components/ring_buffer/ring_buffer.cpp +++ b/esphome/components/ring_buffer/ring_buffer.cpp @@ -7,7 +7,7 @@ namespace esphome::ring_buffer { -static const char *const TAG = "ring_buffer"; +ESPHOME_LOG_TAG(TAG, "ring_buffer"); RingBuffer::~RingBuffer() { // create() only builds the ring buffer once storage_ is allocated diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0734ca87d36..4c438308888 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -4,7 +4,7 @@ namespace esphome::rotary_encoder { -static const char *const TAG = "rotary_encoder"; +ESPHOME_LOG_TAG(TAG, "rotary_encoder"); // based on https://github.com/jkDesignDE/MechInputs/blob/master/QEIx4.cpp static const uint8_t STATE_LUT_MASK = 0x1C; // clears upper counter increment/decrement bits and pin states diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp index dd2428e4df4..5fdc1ac716a 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -12,7 +12,7 @@ namespace esphome::router { -static const char *const TAG = "router.speaker"; +ESPHOME_LOG_TAG(TAG, "router.speaker"); // Maximum time to wait for the active output to report running after start() before giving up static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index 9bcdc8bee4e..0741cc24e39 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -53,7 +53,7 @@ static constexpr size_t MAX_BACKTRACE = 4; namespace esphome::rp2 { -static const char *const TAG = "rp2.crash"; +ESPHOME_LOG_TAG(TAG, "rp2.crash"); // Filled from the watchdog scratch registers on the first read. static struct CrashData { diff --git a/esphome/components/rp2/gpio.cpp b/esphome/components/rp2/gpio.cpp index b95da9bcdbc..93bdba1fca6 100644 --- a/esphome/components/rp2/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -6,7 +6,7 @@ namespace esphome { namespace rp2 { -static const char *const TAG = "rp2"; +ESPHOME_LOG_TAG(TAG, "rp2"); static int flags_to_mode(gpio::Flags flags, uint8_t pin) { flags = diff --git a/esphome/components/rp2/preferences.cpp b/esphome/components/rp2/preferences.cpp index d1e0bc555f5..dfe19f4bf4f 100644 --- a/esphome/components/rp2/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -14,7 +14,7 @@ namespace esphome::rp2 { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t RP2040_FLASH_STORAGE_SIZE = 512; diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 80e8bf94158..fca0c45d6f5 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -11,7 +11,7 @@ namespace esphome::rp2040_ble { -static const char *const TAG = "rp2040_ble"; +ESPHOME_LOG_TAG(TAG, "rp2040_ble"); // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) RP2040BLE *global_ble = nullptr; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 1f4bea9ecd8..8ea9880c7c0 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -14,7 +14,7 @@ namespace esphome::rp2040_pio_led_strip { -static const char *const TAG = "rp2040_pio_led_strip"; +ESPHOME_LOG_TAG(TAG, "rp2040_pio_led_strip"); // DMA interrupt service routine void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.cpp b/esphome/components/rp2040_pwm/rp2040_pwm.cpp index 270cc335519..8324da3dcfc 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.cpp +++ b/esphome/components/rp2040_pwm/rp2040_pwm.cpp @@ -13,7 +13,7 @@ namespace esphome::rp2040_pwm { -static const char *const TAG = "rp2040_pwm"; +ESPHOME_LOG_TAG(TAG, "rp2040_pwm"); void RP2040PWM::setup() { this->setup_pwm_(); } diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index a0a04b981d0..192e19455ed 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -9,7 +9,7 @@ namespace esphome::rp2_ble_tracker { -static const char *const TAG = "rp2_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "rp2_ble_tracker"); // Floor between controller start attempts; insurance against a failing // scan_start() being retried every loop. diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index a5f8567c9da..fb2bf63a646 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -6,7 +6,7 @@ namespace esphome::rtttl { -static const char *const TAG = "rtttl"; +ESPHOME_LOG_TAG(TAG, "rtttl"); static constexpr uint8_t SONG_NAME_LENGTH_LIMIT = 64; static constexpr uint8_t SEMITONES_IN_OCTAVE = 12; diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 204d6cc14b4..9260860f408 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -10,7 +10,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "image_decoder.bmp"; +ESPHOME_LOG_TAG(TAG, "image_decoder.bmp"); void BmpDecoder::reset() { ImageDecoder::reset(); diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index f2c4f5c8cdc..7056ce35aa8 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -6,7 +6,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "image_decoder"; +ESPHOME_LOG_TAG(TAG, "image_decoder"); bool ImageDecoder::set_size(int width, int height) { bool success = this->image_->resize(width, height) > 0; diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index 85ec945259d..74ced63a4e8 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -11,7 +11,7 @@ #include "esp_task_wdt.h" #endif -static const char *const TAG = "image_decoder.jpeg"; +ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg"); namespace esphome::runtime_image { diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 106f25bbe18..ae763471188 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -6,7 +6,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "image_decoder.png"; +ESPHOME_LOG_TAG(TAG, "image_decoder.png"); namespace esphome::runtime_image { diff --git a/esphome/components/runtime_image/qoi_decoder.cpp b/esphome/components/runtime_image/qoi_decoder.cpp index b0fabb5233a..dacaace3fd2 100644 --- a/esphome/components/runtime_image/qoi_decoder.cpp +++ b/esphome/components/runtime_image/qoi_decoder.cpp @@ -10,7 +10,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "image_decoder.qoi"; +ESPHOME_LOG_TAG(TAG, "image_decoder.qoi"); constexpr uint8_t QOI_OP_RGB = 0b11111110; constexpr uint8_t QOI_OP_RGBA = 0b11111111; diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 5c14d398071..6c101945746 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -21,7 +21,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "runtime_image"; +ESPHOME_LOG_TAG(TAG, "runtime_image"); // Widest supported format is 4 bytes/pixel, so 32767 * 32767 * 4 still fits a 32-bit size_t static constexpr int MAX_IMAGE_DIMENSION = 32767; diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index 19753b3c9ea..c781e282fe7 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -3,7 +3,7 @@ namespace esphome::ruuvi_ble { -static const char *const TAG = "ruuvi_ble"; +ESPHOME_LOG_TAG(TAG, "ruuvi_ble"); bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviParseResult &result) { const uint8_t data_type = adv_data[0]; diff --git a/esphome/components/ruuvitag/ruuvitag.cpp b/esphome/components/ruuvitag/ruuvitag.cpp index 1536befb1b2..3dc4a3a223a 100644 --- a/esphome/components/ruuvitag/ruuvitag.cpp +++ b/esphome/components/ruuvitag/ruuvitag.cpp @@ -3,7 +3,7 @@ namespace esphome::ruuvitag { -static const char *const TAG = "ruuvitag"; +ESPHOME_LOG_TAG(TAG, "ruuvitag"); void RuuviTag::dump_config() { ESP_LOGCONFIG(TAG, "RuuviTag"); diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index fee2c83a5fe..502f6f265d6 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -11,7 +11,7 @@ static constexpr uint8_t RX8025T_REG_FLAG = 0x0E; static constexpr uint8_t RX8025T_FLAG_VDET = 0x01; static constexpr uint8_t RX8025T_FLAG_VLF = 0x02; -static const char *const TAG = "rx8025t"; +ESPHOME_LOG_TAG(TAG, "rx8025t"); constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 1b5b71f0e0e..63153c5db3f 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -21,7 +21,7 @@ static const uint8_t RX8130_BIT_CTRL_STOP = 0x40; static const uint8_t RX8130_BAT_FLAGS = 0x30; static const uint8_t RX8130_CLEAR_FLAGS = 0x00; -static const char *const TAG = "rx8130"; +ESPHOME_LOG_TAG(TAG, "rx8130"); constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } diff --git a/esphome/components/safe_mode/button/safe_mode_button.cpp b/esphome/components/safe_mode/button/safe_mode_button.cpp index 982ecf8402d..e9779953f02 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.cpp +++ b/esphome/components/safe_mode/button/safe_mode_button.cpp @@ -5,7 +5,7 @@ namespace esphome::safe_mode { -static const char *const TAG = "safe_mode.button"; +ESPHOME_LOG_TAG(TAG, "safe_mode.button"); void SafeModeButton::press_action() { ESP_LOGI(TAG, "Restarting in safe mode"); diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 8fd5911ab5c..3099df58601 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -21,7 +21,7 @@ namespace esphome::safe_mode { -static const char *const TAG = "safe_mode"; +ESPHOME_LOG_TAG(TAG, "safe_mode"); #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS) // Find a non-running app partition. If verify is true, only returns a partition diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.cpp b/esphome/components/safe_mode/switch/safe_mode_switch.cpp index b4b97357572..c60c59dbfcc 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.cpp +++ b/esphome/components/safe_mode/switch/safe_mode_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::safe_mode { -static const char *const TAG = "safe_mode.switch"; +ESPHOME_LOG_TAG(TAG, "safe_mode.switch"); void SafeModeSwitch::write_state(bool state) { // Acknowledge diff --git a/esphome/components/scd30/scd30.cpp b/esphome/components/scd30/scd30.cpp index a7802977562..3e8794e65c7 100644 --- a/esphome/components/scd30/scd30.cpp +++ b/esphome/components/scd30/scd30.cpp @@ -8,7 +8,7 @@ namespace esphome::scd30 { -static const char *const TAG = "scd30"; +ESPHOME_LOG_TAG(TAG, "scd30"); static const uint16_t SCD30_CMD_GET_FIRMWARE_VERSION = 0xd100; static const uint16_t SCD30_CMD_START_CONTINUOUS_MEASUREMENTS = 0x0010; diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index d9a2439bb90..8249a6feb1e 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -4,7 +4,7 @@ namespace esphome::scd4x { -static const char *const TAG = "scd4x"; +ESPHOME_LOG_TAG(TAG, "scd4x"); static const uint16_t SCD41_ID = 0x1408; static const uint16_t SCD40_ID = 0x440; diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index bfe96e1b23a..7174a9a0b42 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -3,7 +3,7 @@ namespace esphome::script { -static const char *const TAG = "script"; +ESPHOME_LOG_TAG(TAG, "script"); void ScriptLogger::esp_log_(int level, int line, ProgmemStr format, const char *param) { esp_log_printf_(level, TAG, line, format, param); diff --git a/esphome/components/sdm_meter/sdm_meter.cpp b/esphome/components/sdm_meter/sdm_meter.cpp index c1b359cc97a..5389325cc22 100644 --- a/esphome/components/sdm_meter/sdm_meter.cpp +++ b/esphome/components/sdm_meter/sdm_meter.cpp @@ -6,7 +6,7 @@ namespace esphome::sdm_meter { namespace helpers = modbus::helpers; -static const char *const TAG = "sdm_meter"; +ESPHOME_LOG_TAG(TAG, "sdm_meter"); static const uint8_t MODBUS_REGISTER_COUNT = 80; // 80 x 16-bit registers (40 float values) diff --git a/esphome/components/sdp3x/sdp3x.cpp b/esphome/components/sdp3x/sdp3x.cpp index 7fcd47a265e..98b5d9940a3 100644 --- a/esphome/components/sdp3x/sdp3x.cpp +++ b/esphome/components/sdp3x/sdp3x.cpp @@ -5,7 +5,7 @@ namespace esphome::sdp3x { -static const char *const TAG = "sdp3x.sensor"; +ESPHOME_LOG_TAG(TAG, "sdp3x.sensor"); static const uint16_t SDP3X_SOFT_RESET = 0x0006; static const uint16_t SDP3X_READ_ID1 = 0x367C; static const uint16_t SDP3X_READ_ID2 = 0xE102; diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index dfc7857266d..3d46f0613e0 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -4,7 +4,7 @@ namespace esphome::sds011 { -static const char *const TAG = "sds011"; +ESPHOME_LOG_TAG(TAG, "sds011"); static const uint8_t SDS011_MSG_REQUEST_LENGTH = 19; static const uint8_t SDS011_MSG_RESPONSE_LENGTH = 10; diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index a0dc7ae4cb1..31c8e89af3a 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -7,7 +7,7 @@ namespace esphome::seeed_mr24hpc1 { -static const char *const TAG = "seeed_mr24hpc1"; +ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index cbf0415f6c8..c5d12924583 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -7,7 +7,7 @@ namespace esphome::seeed_mr60bha2 { -static const char *const TAG = "seeed_mr60bha2"; +ESPHOME_LOG_TAG(TAG, "seeed_mr60bha2"); // Maximum bytes to log in verbose hex output static constexpr size_t MR60BHA2_MAX_LOG_BYTES = 64; diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 2d1cd0fbb4a..332c59061b1 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -7,7 +7,7 @@ namespace esphome::seeed_mr60fda2 { -static const char *const TAG = "seeed_mr60fda2"; +ESPHOME_LOG_TAG(TAG, "seeed_mr60fda2"); // Maximum bytes to log in verbose hex output static constexpr size_t MR60FDA2_MAX_LOG_BYTES = 64; diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index 3ad1f8b87c5..a11cc6a008a 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -6,7 +6,7 @@ namespace esphome::selec_meter { namespace helpers = modbus::helpers; -static const char *const TAG = "selec_meter"; +ESPHOME_LOG_TAG(TAG, "selec_meter"); static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 05a0ee1ed91..4d77c6d6f6c 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -6,7 +6,7 @@ namespace esphome::select { -static const char *const TAG = "select"; +ESPHOME_LOG_TAG(TAG, "select"); void Select::publish_state(const char *state) { auto index = this->index_of(state); diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 9d2fb725f31..167e6769a4a 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -4,7 +4,7 @@ namespace esphome::select { -static const char *const TAG = "select"; +ESPHOME_LOG_TAG(TAG, "select"); SelectCall &SelectCall::set_option(const char *option, size_t len) { return this->with_option(option, len); } diff --git a/esphome/components/sen0321/sen0321.cpp b/esphome/components/sen0321/sen0321.cpp index e074934d0f0..564e05e0394 100644 --- a/esphome/components/sen0321/sen0321.cpp +++ b/esphome/components/sen0321/sen0321.cpp @@ -4,7 +4,7 @@ namespace esphome::sen0321_sensor { -static const char *const TAG = "sen0321_sensor.sensor"; +ESPHOME_LOG_TAG(TAG, "sen0321_sensor.sensor"); void Sen0321Sensor::setup() { if (!this->write_byte(SENSOR_MODE_REGISTER, SENSOR_MODE_AUTO)) { diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index 3f6212e7f2b..29175d845cc 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -3,7 +3,7 @@ namespace esphome::sen21231_sensor { -static const char *const TAG = "sen21231_sensor.sensor"; +ESPHOME_LOG_TAG(TAG, "sen21231_sensor.sensor"); void Sen21231Sensor::update() { this->read_data_(); } diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index f8df89ee33f..f192a402887 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -7,7 +7,7 @@ namespace esphome::sen5x { -static const char *const TAG = "sen5x"; +ESPHOME_LOG_TAG(TAG, "sen5x"); static const uint16_t SEN5X_CMD_AUTO_CLEANING_INTERVAL = 0x8004; static const uint16_t SEN5X_CMD_GET_DATA_READY_STATUS = 0x0202; diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index ed6cb24c527..91762e0f079 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -5,7 +5,7 @@ namespace esphome::sen6x { -static const char *const TAG = "sen6x"; +ESPHOME_LOG_TAG(TAG, "sen6x"); static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 8605048f331..4787abc7601 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -8,7 +8,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.image"; +ESPHOME_LOG_TAG(TAG, "sendspin.image"); // How long a displayed frame may wait for sendspin.image.transition_finished before a warning // names the missing ack. Generous next to a typical fade of a second or two. diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index ec5d5b2cbc0..8c82022f1e8 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -16,7 +16,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.media_player"; +ESPHOME_LOG_TAG(TAG, "sendspin.media_player"); // THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop, // since SendspinHub dispatches group updates and controller state from client_->loop(). diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index f1706a260bd..5e5cf187994 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -9,7 +9,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.media_source"; +ESPHOME_LOG_TAG(TAG, "sendspin.media_source"); static constexpr char URI_PREFIX[] = "sendspin://"; diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 927e0e49ad2..12617e00818 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -22,7 +22,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.hub"; +ESPHOME_LOG_TAG(TAG, "sendspin.hub"); namespace { diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 0562f329011..bacb7f66ad8 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.sensor"; +ESPHOME_LOG_TAG(TAG, "sendspin.sensor"); // --- SendspinTrackProgressSensor --- diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index af4d0538543..343202e7f4b 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -6,7 +6,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.switch"; +ESPHOME_LOG_TAG(TAG, "sendspin.switch"); void SendspinSwitch::setup() { // The hub waits for this request, so a restore mode without a state still has to answer. diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 69dd32e7857..cda987f2e3b 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.text_sensor"; +ESPHOME_LOG_TAG(TAG, "sendspin.text_sensor"); void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index f5017cff75b..858510f4277 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -4,7 +4,7 @@ namespace esphome::senseair { -static const char *const TAG = "senseair"; +ESPHOME_LOG_TAG(TAG, "senseair"); static const uint8_t SENSEAIR_REQUEST_LENGTH = 8; static const uint8_t SENSEAIR_PPM_STATUS_RESPONSE_LENGTH = 13; static const uint8_t SENSEAIR_ABC_PERIOD_RESPONSE_LENGTH = 7; diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index 3325c277722..19172e68d2c 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -6,7 +6,7 @@ namespace esphome::sensirion_common { -static const char *const TAG = "sensirion_i2c"; +ESPHOME_LOG_TAG(TAG, "sensirion_i2c"); // To avoid memory allocations for small writes a stack buffer is used static const size_t BUFFER_STACK_SIZE = 16; diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 9b9d7c033ae..28f7e6100d3 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -11,7 +11,7 @@ namespace esphome::sensor { -static const char *const TAG = "sensor.filter"; +ESPHOME_LOG_TAG(TAG, "sensor.filter"); // Filter void Filter::input(float value) { diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index bee5d7c6d33..df73e2c481a 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::sensor { -static const char *const TAG = "sensor"; +ESPHOME_LOG_TAG(TAG, "sensor"); // Function implementation of LOG_SENSOR macro to reduce code size void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj) { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 16e0f0b984c..d512362fa01 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -20,7 +20,7 @@ namespace esphome::serial_proxy { -static const char *const TAG = "serial_proxy"; +ESPHOME_LOG_TAG(TAG, "serial_proxy"); uint32_t SerialProxy::stall_loop_time = 0; uint32_t SerialProxy::stall_spent_ms = 0; diff --git a/esphome/components/servo/servo.cpp b/esphome/components/servo/servo.cpp index 8d5344cf440..a415fdb8f77 100644 --- a/esphome/components/servo/servo.cpp +++ b/esphome/components/servo/servo.cpp @@ -5,7 +5,7 @@ namespace esphome::servo { -static const char *const TAG = "servo"; +ESPHOME_LOG_TAG(TAG, "servo"); uint32_t global_servo_id = 1911044085ULL; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index 960806e98b0..efa3d8dc61f 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -3,7 +3,7 @@ namespace esphome::sfa30 { -static const char *const TAG = "sfa30"; +ESPHOME_LOG_TAG(TAG, "sfa30"); static const uint16_t SFA30_CMD_GET_DEVICE_MARKING = 0xD060; static const uint16_t SFA30_CMD_START_CONTINUOUS_MEASUREMENTS = 0x0006; diff --git a/esphome/components/sfa40/sfa40.cpp b/esphome/components/sfa40/sfa40.cpp index 0d6bee3f9a8..31a25a4f384 100644 --- a/esphome/components/sfa40/sfa40.cpp +++ b/esphome/components/sfa40/sfa40.cpp @@ -4,7 +4,7 @@ namespace esphome::sfa40 { -static const char *const TAG = "sfa40"; +ESPHOME_LOG_TAG(TAG, "sfa40"); // SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index fd007c92cc8..72827ad51b5 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -8,7 +8,7 @@ namespace esphome::sgp30 { -static const char *const TAG = "sgp30"; +ESPHOME_LOG_TAG(TAG, "sgp30"); static const uint16_t SGP30_CMD_GET_SERIAL_ID = 0x3682; static const uint16_t SGP30_CMD_GET_FEATURESET = 0x202f; diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 0cf5c314830..854dddba059 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -7,7 +7,7 @@ namespace esphome::sgp4x { -static const char *const TAG = "sgp4x"; +ESPHOME_LOG_TAG(TAG, "sgp4x"); void SGP4xComponent::setup() { // Serial Number identification diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index b69e4175912..eedf7ff7ce2 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -19,7 +19,7 @@ namespace { -constexpr char TAG[] = "shelly_dimmer"; +ESPHOME_LOG_TAG(TAG, "shelly_dimmer"); constexpr uint8_t SHELLY_DIMMER_ACK_TIMEOUT = 200; // ms constexpr uint8_t SHELLY_DIMMER_MAX_RETRIES = 3; diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index beb015851ea..c33876e4943 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -108,7 +108,7 @@ constexpr uint8_t STM_OBL_LAUNCH_CODE[] = { constexpr uint32_t STM_OBL_LAUNCH_CODE_SIZE = sizeof(STM_OBL_LAUNCH_CODE); -constexpr char TAG[] = "stm32flash"; +ESPHOME_LOG_TAG(TAG, "stm32flash"); } // Anonymous namespace diff --git a/esphome/components/sht3xd/sht3xd.cpp b/esphome/components/sht3xd/sht3xd.cpp index eba6be65d58..672d119aed9 100644 --- a/esphome/components/sht3xd/sht3xd.cpp +++ b/esphome/components/sht3xd/sht3xd.cpp @@ -3,7 +3,7 @@ namespace esphome::sht3xd { -static const char *const TAG = "sht3xd"; +ESPHOME_LOG_TAG(TAG, "sht3xd"); // https://sensirion.com/media/documents/E5762713/63D103C2/Sensirion_electronic_identification_code_SHT3x.pdf // indicates two possible read serial number registers either with clock stretching enabled or disabled. diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 4a3df5a91f2..45de59fe0dc 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -4,7 +4,7 @@ namespace esphome::sht4x { -static const char *const TAG = "sht4x"; +ESPHOME_LOG_TAG(TAG, "sht4x"); static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0}; static const uint8_t SERIAL_NUMBER_COMMAND = 0x89; diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 9ec0a2cdb7b..68f2c4ea21e 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -4,7 +4,7 @@ namespace esphome::shtcx { -static const char *const TAG = "shtcx"; +ESPHOME_LOG_TAG(TAG, "shtcx"); static constexpr uint16_t SHTCX_COMMAND_SLEEP = 0xB098; static constexpr uint16_t SHTCX_COMMAND_WAKEUP = 0x3517; diff --git a/esphome/components/shutdown/button/shutdown_button.cpp b/esphome/components/shutdown/button/shutdown_button.cpp index 6394c6c14e6..7a1380469bb 100644 --- a/esphome/components/shutdown/button/shutdown_button.cpp +++ b/esphome/components/shutdown/button/shutdown_button.cpp @@ -12,7 +12,7 @@ namespace esphome::shutdown { -static const char *const TAG = "shutdown.button"; +ESPHOME_LOG_TAG(TAG, "shutdown.button"); void ShutdownButton::dump_config() { LOG_BUTTON("", "Shutdown Button", this); } void ShutdownButton::press_action() { diff --git a/esphome/components/shutdown/switch/shutdown_switch.cpp b/esphome/components/shutdown/switch/shutdown_switch.cpp index a44a572aa1c..6e0a70ff82d 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.cpp +++ b/esphome/components/shutdown/switch/shutdown_switch.cpp @@ -12,7 +12,7 @@ namespace esphome::shutdown { -static const char *const TAG = "shutdown.switch"; +ESPHOME_LOG_TAG(TAG, "shutdown.switch"); void ShutdownSwitch::dump_config() { LOG_SWITCH("", "Shutdown Switch", this); } void ShutdownSwitch::write_state(bool state) { diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.cpp b/esphome/components/sigma_delta_output/sigma_delta_output.cpp index 6cc131c68ff..37412c5d9b1 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.cpp +++ b/esphome/components/sigma_delta_output/sigma_delta_output.cpp @@ -3,7 +3,7 @@ namespace esphome::sigma_delta_output { -static const char *const TAG = "output.sigma_delta"; +ESPHOME_LOG_TAG(TAG, "output.sigma_delta"); void SigmaDeltaOutput::setup() { if (this->pin_) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 13b9888e052..e75e5e95dd3 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -5,7 +5,7 @@ namespace esphome::sim800l { -static const char *const TAG = "sim800l"; +ESPHOME_LOG_TAG(TAG, "sim800l"); const char ASCII_CR = 0x0D; const char ASCII_LF = 0x0A; diff --git a/esphome/components/slow_pwm/slow_pwm_output.cpp b/esphome/components/slow_pwm/slow_pwm_output.cpp index e695ab95403..f41455bc805 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.cpp +++ b/esphome/components/slow_pwm/slow_pwm_output.cpp @@ -5,7 +5,7 @@ namespace esphome::slow_pwm { -static const char *const TAG = "output.slow_pwm"; +ESPHOME_LOG_TAG(TAG, "output.slow_pwm"); void SlowPWMOutput::setup() { if (this->pin_) diff --git a/esphome/components/sm10bit_base/sm10bit_base.cpp b/esphome/components/sm10bit_base/sm10bit_base.cpp index 45de3c457d2..d01ec5dbdba 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.cpp +++ b/esphome/components/sm10bit_base/sm10bit_base.cpp @@ -3,7 +3,7 @@ namespace esphome::sm10bit_base { -static const char *const TAG = "sm10bit_base"; +ESPHOME_LOG_TAG(TAG, "sm10bit_base"); static const uint8_t SM10BIT_ADDR_STANDBY = 0x0; static const uint8_t SM10BIT_ADDR_START_3CH = 0x8; diff --git a/esphome/components/sm16716/sm16716.cpp b/esphome/components/sm16716/sm16716.cpp index 59e9f1b7120..647a40dc47c 100644 --- a/esphome/components/sm16716/sm16716.cpp +++ b/esphome/components/sm16716/sm16716.cpp @@ -3,7 +3,7 @@ namespace esphome::sm16716 { -static const char *const TAG = "sm16716"; +ESPHOME_LOG_TAG(TAG, "sm16716"); void SM16716::setup() { this->data_pin_->setup(); diff --git a/esphome/components/sm2135/sm2135.cpp b/esphome/components/sm2135/sm2135.cpp index 0086a63878b..c8a4287b65a 100644 --- a/esphome/components/sm2135/sm2135.cpp +++ b/esphome/components/sm2135/sm2135.cpp @@ -5,7 +5,7 @@ namespace esphome::sm2135 { -static const char *const TAG = "sm2135"; +ESPHOME_LOG_TAG(TAG, "sm2135"); static const uint8_t SM2135_ADDR_MC = 0xC0; // Max current register static const uint8_t SM2135_ADDR_CH = 0xC1; // RGB or CW channel select register diff --git a/esphome/components/sm2235/sm2235.cpp b/esphome/components/sm2235/sm2235.cpp index e981bd0f71f..3bd302047bc 100644 --- a/esphome/components/sm2235/sm2235.cpp +++ b/esphome/components/sm2235/sm2235.cpp @@ -3,7 +3,7 @@ namespace esphome::sm2235 { -static const char *const TAG = "sm2235"; +ESPHOME_LOG_TAG(TAG, "sm2235"); void SM2235::setup() { this->data_pin_->setup(); diff --git a/esphome/components/sm2335/sm2335.cpp b/esphome/components/sm2335/sm2335.cpp index 93f1096800f..6e0d7b35887 100644 --- a/esphome/components/sm2335/sm2335.cpp +++ b/esphome/components/sm2335/sm2335.cpp @@ -3,7 +3,7 @@ namespace esphome::sm2335 { -static const char *const TAG = "sm2335"; +ESPHOME_LOG_TAG(TAG, "sm2335"); void SM2335::setup() { this->data_pin_->setup(); diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 882959a4542..0660e0f20e4 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -3,7 +3,7 @@ namespace esphome::sm300d2 { -static const char *const TAG = "sm300d2"; +ESPHOME_LOG_TAG(TAG, "sm300d2"); static const uint8_t SM300D2_RESPONSE_LENGTH = 17; void SM300D2Sensor::update() { diff --git a/esphome/components/sml/sensor/sml_sensor.cpp b/esphome/components/sml/sensor/sml_sensor.cpp index 047a13e88d8..9861edcf8f7 100644 --- a/esphome/components/sml/sensor/sml_sensor.cpp +++ b/esphome/components/sml/sensor/sml_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::sml { -static const char *const TAG = "sml_sensor"; +ESPHOME_LOG_TAG(TAG, "sml_sensor"); SmlSensor::SmlSensor(std::string server_id, std::string obis_code) : SmlListener(std::move(server_id), std::move(obis_code)) {} diff --git a/esphome/components/sml/sml.cpp b/esphome/components/sml/sml.cpp index bacfbcccef0..a4bc4373d7c 100644 --- a/esphome/components/sml/sml.cpp +++ b/esphome/components/sml/sml.cpp @@ -5,7 +5,7 @@ namespace esphome::sml { -static const char *const TAG = "sml"; +ESPHOME_LOG_TAG(TAG, "sml"); const char START_BYTES_DETECTED = 1; const char END_BYTES_DETECTED = 2; diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.cpp b/esphome/components/sml/text_sensor/sml_text_sensor.cpp index d0965e3dcaa..9bd31923ecd 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.cpp +++ b/esphome/components/sml/text_sensor/sml_text_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::sml { -static const char *const TAG = "sml_text_sensor"; +ESPHOME_LOG_TAG(TAG, "sml_text_sensor"); SmlTextSensor::SmlTextSensor(std::string server_id, std::string obis_code, SmlType format) : SmlListener(std::move(server_id), std::move(obis_code)), format_(format) {} diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 2889a9fb4d9..7785bbca2d7 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -3,7 +3,7 @@ namespace esphome::smt100 { -static const char *const TAG = "smt100"; +ESPHOME_LOG_TAG(TAG, "smt100"); void SMT100Component::update() { ESP_LOGV(TAG, "Sending measurement request"); diff --git a/esphome/components/sn74hc165/sn74hc165.cpp b/esphome/components/sn74hc165/sn74hc165.cpp index fde789e90c7..318390e8e22 100644 --- a/esphome/components/sn74hc165/sn74hc165.cpp +++ b/esphome/components/sn74hc165/sn74hc165.cpp @@ -3,7 +3,7 @@ namespace esphome::sn74hc165 { -static const char *const TAG = "sn74hc165"; +ESPHOME_LOG_TAG(TAG, "sn74hc165"); void SN74HC165Component::setup() { // initialize pins diff --git a/esphome/components/sn74hc595/sn74hc595.cpp b/esphome/components/sn74hc595/sn74hc595.cpp index 710e51ad128..4691ffcf6d7 100644 --- a/esphome/components/sn74hc595/sn74hc595.cpp +++ b/esphome/components/sn74hc595/sn74hc595.cpp @@ -4,7 +4,7 @@ namespace esphome::sn74hc595 { -static const char *const TAG = "sn74hc595"; +ESPHOME_LOG_TAG(TAG, "sn74hc595"); void SN74HC595Component::pre_setup_() { if (this->have_oe_pin_) { // disable output diff --git a/esphome/components/snapshot/display/snapshot_display.cpp b/esphome/components/snapshot/display/snapshot_display.cpp index 6297e3e18f7..6618d9ae74a 100644 --- a/esphome/components/snapshot/display/snapshot_display.cpp +++ b/esphome/components/snapshot/display/snapshot_display.cpp @@ -7,7 +7,7 @@ namespace esphome::snapshot { -static const char *const TAG = "snapshot.display"; +ESPHOME_LOG_TAG(TAG, "snapshot.display"); namespace { diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 096ec48eb3b..58406f17ffd 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -22,7 +22,7 @@ namespace esphome::snapshot { namespace { -constexpr const char *const TAG = "snapshot"; +ESPHOME_LOG_TAG(TAG, "snapshot"); // Longest name we will build a path from. NAME_MAX is 255 and we may append a collision suffix. constexpr size_t MAX_NAME_LENGTH = 200; diff --git a/esphome/components/sntp/sntp_component.cpp b/esphome/components/sntp/sntp_component.cpp index f8d48b40981..de951483bba 100644 --- a/esphome/components/sntp/sntp_component.cpp +++ b/esphome/components/sntp/sntp_component.cpp @@ -11,7 +11,7 @@ namespace esphome::sntp { -static const char *const TAG = "sntp"; +ESPHOME_LOG_TAG(TAG, "sntp"); #if defined(USE_ESP32) SNTPComponent *SNTPComponent::instance = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/socket/ipv4_resolve.cpp b/esphome/components/socket/ipv4_resolve.cpp index d2316cd064b..d5da8745b97 100644 --- a/esphome/components/socket/ipv4_resolve.cpp +++ b/esphome/components/socket/ipv4_resolve.cpp @@ -16,7 +16,7 @@ namespace esphome::socket { -static const char *const TAG = "socket"; +ESPHOME_LOG_TAG(TAG, "socket"); bool Ipv4Resolve::consume_failure() { #if defined(IPV4_RESOLVE_ATOMIC_STATE) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b411445b98a..2b21291c30b 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -43,7 +43,7 @@ namespace esphome::socket { // (Ethernet). On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT -static const char *const TAG = "socket"; +ESPHOME_LOG_TAG(TAG, "socket"); #ifdef USE_ESP8266 // optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index 3e5af2b51ff..02282fef9ca 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -46,7 +46,7 @@ namespace esphome::sonoff_d1 { -static const char *const TAG = "sonoff_d1"; +ESPHOME_LOG_TAG(TAG, "sonoff_d1"); // Protocol constants static constexpr size_t SONOFF_D1_ACK_SIZE = 7; diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index 99ab7932d68..c5eee3665c2 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -9,7 +9,7 @@ namespace esphome::sound_level { -static const char *const TAG = "sound_level"; +ESPHOME_LOG_TAG(TAG, "sound_level"); static const uint32_t MAX_FILL_DURATION_MS = 30; static const uint32_t RING_BUFFER_DURATION_MS = 120; diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index d3de5168e48..987a14c66a8 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -6,7 +6,7 @@ namespace esphome::spa06_base { -static const char *const TAG = "spa06"; +ESPHOME_LOG_TAG(TAG, "spa06"); // Sign extension function for <=16 bit types inline int16_t decode16(uint8_t msb, uint8_t lsb, size_t bits, size_t head = 0) { diff --git a/esphome/components/spa06_i2c/spa06_i2c.cpp b/esphome/components/spa06_i2c/spa06_i2c.cpp index 4970b0822d7..cdb0818d39e 100644 --- a/esphome/components/spa06_i2c/spa06_i2c.cpp +++ b/esphome/components/spa06_i2c/spa06_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::spa06_i2c { -static const char *const TAG = "spa06_i2c"; +ESPHOME_LOG_TAG(TAG, "spa06_i2c"); void SPA06I2CComponent::dump_config() { LOG_I2C_DEVICE(this); diff --git a/esphome/components/spa06_spi/spa06_spi.cpp b/esphome/components/spa06_spi/spa06_spi.cpp index 9f3683d2197..08d725d4f8d 100644 --- a/esphome/components/spa06_spi/spa06_spi.cpp +++ b/esphome/components/spa06_spi/spa06_spi.cpp @@ -13,7 +13,7 @@ inline constexpr uint8_t SPA06_SPI_WRITE = 0x7F; namespace esphome::spa06_spi { -static const char *const TAG = "spa06_spi"; +ESPHOME_LOG_TAG(TAG, "spa06_spi"); void SPA06SPIComponent::dump_config() { SPA06Component::dump_config(); diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index f150a485735..a39a1ecbbef 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -21,7 +21,7 @@ static const uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024; static const uint32_t INFO_ERROR_QUEUE_COUNT = 5; -static const char *const TAG = "speaker_media_player.pipeline"; +ESPHOME_LOG_TAG(TAG, "speaker_media_player.pipeline"); enum EventGroupBits : uint32_t { // MESSAGE_* bits are only set by their respective tasks diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index ab98935bdad..e4aa78d10db 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -50,7 +50,7 @@ static const uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; static const UBaseType_t MEDIA_PIPELINE_TASK_PRIORITY = 1; static const UBaseType_t ANNOUNCEMENT_PIPELINE_TASK_PRIORITY = 1; -static const char *const TAG = "speaker_media_player"; +ESPHOME_LOG_TAG(TAG, "speaker_media_player"); void SpeakerMediaPlayer::setup() { #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 6a000b8f215..657c26af80a 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -11,7 +11,7 @@ namespace esphome::speaker_source { static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; -static const char *const TAG = "speaker_source_media_player"; +ESPHOME_LOG_TAG(TAG, "speaker_source_media_player"); // SourceBinding method implementations (defined here because SpeakerSourceMediaPlayer is forward-declared in the // header) diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index af46ef52e1c..322c638d4ba 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::speed { -static const char *const TAG = "speed.fan"; +ESPHOME_LOG_TAG(TAG, "speed.fan"); void SpeedFan::setup() { // Construct traits before restore so preset modes can be looked up by index diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index dfdc9fa6245..343fcd5488a 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { -const char *const TAG = "spi"; +ESPHOME_LOG_TAG(TAG, "spi"); SPIDelegate *const SPIDelegate::NULL_DELEGATE = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) new SPIDelegateDummy(); diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index ae2d2906edf..8ad868ad2e3 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const char *const TAG = "spi"; +ESPHOME_LOG_TAG(TAG, "spi"); class SPIDelegateHw : public SPIDelegate { public: SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 95b5e4f14bf..7b51bfaa698 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -8,7 +8,7 @@ namespace esphome::spi { #ifdef USE_ESP32 -static const char *const TAG = "spi"; +ESPHOME_LOG_TAG(TAG, "spi"); static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. #ifdef USE_SPI_PSRAM_DMA diff --git a/esphome/components/spi_device/spi_device.cpp b/esphome/components/spi_device/spi_device.cpp index bdf5978bc7d..f89b9f624f2 100644 --- a/esphome/components/spi_device/spi_device.cpp +++ b/esphome/components/spi_device/spi_device.cpp @@ -5,7 +5,7 @@ namespace esphome::spi_device { -static const char *const TAG = "spi_device"; +ESPHOME_LOG_TAG(TAG, "spi_device"); void SPIDeviceComponent::setup() { this->spi_setup(); } diff --git a/esphome/components/split_buffer/split_buffer.cpp b/esphome/components/split_buffer/split_buffer.cpp index 63a0f0d9195..56b1d326a38 100644 --- a/esphome/components/split_buffer/split_buffer.cpp +++ b/esphome/components/split_buffer/split_buffer.cpp @@ -7,7 +7,7 @@ #include "esphome/core/log.h" namespace esphome::split_buffer { -static constexpr const char *const TAG = "split_buffer"; +ESPHOME_LOG_TAG(TAG, "split_buffer"); SplitBuffer::~SplitBuffer() { this->free(); } diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index cdec1581266..a3c264da582 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -10,7 +10,7 @@ namespace esphome::sprinkler { -static const char *const TAG = "sprinkler"; +ESPHOME_LOG_TAG(TAG, "sprinkler"); void SprinklerControllerNumber::setup() { float value; diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 73fa4ef4633..3a2ebb14c39 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -6,7 +6,7 @@ namespace esphome::sps30 { -static const char *const TAG = "sps30"; +ESPHOME_LOG_TAG(TAG, "sps30"); static const uint16_t SPS30_CMD_GET_ARTICLE_CODE = 0xD025; static const uint16_t SPS30_CMD_GET_SERIAL_NUMBER = 0xD033; diff --git a/esphome/components/ssd1306_base/ssd1306_base.cpp b/esphome/components/ssd1306_base/ssd1306_base.cpp index 5f1c6fca8f4..ef7fa46e73a 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.cpp +++ b/esphome/components/ssd1306_base/ssd1306_base.cpp @@ -5,7 +5,7 @@ namespace esphome::ssd1306_base { -static const char *const TAG = "ssd1306"; +ESPHOME_LOG_TAG(TAG, "ssd1306"); static const uint8_t SSD1306_MAX_CONTRAST = 255; static const uint8_t SSD1305_MAX_BRIGHTNESS = 255; diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 00c864a217d..1cdfed9f502 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::ssd1306_i2c { -static const char *const TAG = "ssd1306_i2c"; +ESPHOME_LOG_TAG(TAG, "ssd1306_i2c"); void I2CSSD1306::setup() { this->init_reset_(); diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.cpp b/esphome/components/ssd1306_spi/ssd1306_spi.cpp index 0534deeb03b..8e2dcebc0ba 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.cpp +++ b/esphome/components/ssd1306_spi/ssd1306_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1306_spi { -static const char *const TAG = "ssd1306_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1306_spi"); void SPISSD1306::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1322_base/ssd1322_base.cpp b/esphome/components/ssd1322_base/ssd1322_base.cpp index a8a39b9ab01..30ca4109efb 100644 --- a/esphome/components/ssd1322_base/ssd1322_base.cpp +++ b/esphome/components/ssd1322_base/ssd1322_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1322_base { -static const char *const TAG = "ssd1322"; +ESPHOME_LOG_TAG(TAG, "ssd1322"); static const uint8_t SSD1322_MAX_CONTRAST = 255; static const uint8_t SSD1322_COLORMASK = 0x0f; diff --git a/esphome/components/ssd1322_spi/ssd1322_spi.cpp b/esphome/components/ssd1322_spi/ssd1322_spi.cpp index 79f15508c34..38426b9eafc 100644 --- a/esphome/components/ssd1322_spi/ssd1322_spi.cpp +++ b/esphome/components/ssd1322_spi/ssd1322_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1322_spi { -static const char *const TAG = "ssd1322_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1322_spi"); void SPISSD1322::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1325_base/ssd1325_base.cpp b/esphome/components/ssd1325_base/ssd1325_base.cpp index b8d8f999995..17175447d92 100644 --- a/esphome/components/ssd1325_base/ssd1325_base.cpp +++ b/esphome/components/ssd1325_base/ssd1325_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1325_base { -static const char *const TAG = "ssd1325"; +ESPHOME_LOG_TAG(TAG, "ssd1325"); static const uint8_t SSD1325_MAX_CONTRAST = 127; static const uint8_t SSD1325_COLORMASK = 0x0f; diff --git a/esphome/components/ssd1325_spi/ssd1325_spi.cpp b/esphome/components/ssd1325_spi/ssd1325_spi.cpp index af6a01c7f39..cb21eb09fe4 100644 --- a/esphome/components/ssd1325_spi/ssd1325_spi.cpp +++ b/esphome/components/ssd1325_spi/ssd1325_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1325_spi { -static const char *const TAG = "ssd1325_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1325_spi"); void SPISSD1325::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1327_base/ssd1327_base.cpp b/esphome/components/ssd1327_base/ssd1327_base.cpp index ae02b836bf4..7c8b490f610 100644 --- a/esphome/components/ssd1327_base/ssd1327_base.cpp +++ b/esphome/components/ssd1327_base/ssd1327_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1327_base { -static const char *const TAG = "ssd1327"; +ESPHOME_LOG_TAG(TAG, "ssd1327"); static const uint8_t SSD1327_MAX_CONTRAST = 127; static const uint8_t SSD1327_COLORMASK = 0x0f; diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp index 90fbcbd05cf..98328c800c1 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::ssd1327_i2c { -static const char *const TAG = "ssd1327_i2c"; +ESPHOME_LOG_TAG(TAG, "ssd1327_i2c"); void I2CSSD1327::setup() { this->init_reset_(); diff --git a/esphome/components/ssd1327_spi/ssd1327_spi.cpp b/esphome/components/ssd1327_spi/ssd1327_spi.cpp index c6a64227fc4..8816aadf6d0 100644 --- a/esphome/components/ssd1327_spi/ssd1327_spi.cpp +++ b/esphome/components/ssd1327_spi/ssd1327_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1327_spi { -static const char *const TAG = "ssd1327_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1327_spi"); void SPISSD1327::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1331_base/ssd1331_base.cpp b/esphome/components/ssd1331_base/ssd1331_base.cpp index 7755e5a2a03..8d5ebb4dbbe 100644 --- a/esphome/components/ssd1331_base/ssd1331_base.cpp +++ b/esphome/components/ssd1331_base/ssd1331_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1331_base { -static const char *const TAG = "ssd1331"; +ESPHOME_LOG_TAG(TAG, "ssd1331"); static const uint16_t SSD1331_COLORMASK = 0xffff; static const uint8_t SSD1331_MAX_CONTRASTA = 0x91; diff --git a/esphome/components/ssd1331_spi/ssd1331_spi.cpp b/esphome/components/ssd1331_spi/ssd1331_spi.cpp index a5118cdfbef..d36f9b75af2 100644 --- a/esphome/components/ssd1331_spi/ssd1331_spi.cpp +++ b/esphome/components/ssd1331_spi/ssd1331_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1331_spi { -static const char *const TAG = "ssd1331_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1331_spi"); void SPISSD1331::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1351_base/ssd1351_base.cpp b/esphome/components/ssd1351_base/ssd1351_base.cpp index 3df575499d9..7ab3a64ea84 100644 --- a/esphome/components/ssd1351_base/ssd1351_base.cpp +++ b/esphome/components/ssd1351_base/ssd1351_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1351_base { -static const char *const TAG = "ssd1351"; +ESPHOME_LOG_TAG(TAG, "ssd1351"); static const uint16_t SSD1351_COLORMASK = 0xffff; static const uint8_t SSD1351_MAX_CONTRAST = 15; diff --git a/esphome/components/ssd1351_spi/ssd1351_spi.cpp b/esphome/components/ssd1351_spi/ssd1351_spi.cpp index f6d2323e9cc..06d95e74a4d 100644 --- a/esphome/components/ssd1351_spi/ssd1351_spi.cpp +++ b/esphome/components/ssd1351_spi/ssd1351_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1351_spi { -static const char *const TAG = "ssd1351_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1351_spi"); void SPISSD1351::setup() { this->spi_setup(); diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp index 117f9752645..5cd986f6976 100644 --- a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::st7123 { -static const char *const TAG = "st7123.touchscreen"; +ESPHOME_LOG_TAG(TAG, "st7123.touchscreen"); void ST7123Touchscreen::setup() { if (this->reset_pin_ != nullptr) { diff --git a/esphome/components/st7567_base/st7567_base.cpp b/esphome/components/st7567_base/st7567_base.cpp index fff3aa7e039..aa83de6a8c2 100644 --- a/esphome/components/st7567_base/st7567_base.cpp +++ b/esphome/components/st7567_base/st7567_base.cpp @@ -4,7 +4,7 @@ namespace esphome::st7567_base { -static const char *const TAG = "st7567"; +ESPHOME_LOG_TAG(TAG, "st7567"); void ST7567::setup() { this->init_internal_(this->get_buffer_length_()); diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index c99815ecd69..657a60f9549 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::st7567_i2c { -static const char *const TAG = "st7567_i2c"; +ESPHOME_LOG_TAG(TAG, "st7567_i2c"); void I2CST7567::setup() { this->init_reset_(); diff --git a/esphome/components/st7567_spi/st7567_spi.cpp b/esphome/components/st7567_spi/st7567_spi.cpp index fae8c313814..fa9fb792dd3 100644 --- a/esphome/components/st7567_spi/st7567_spi.cpp +++ b/esphome/components/st7567_spi/st7567_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::st7567_spi { -static const char *const TAG = "st7567_spi"; +ESPHOME_LOG_TAG(TAG, "st7567_spi"); void SPIST7567::setup() { this->spi_setup(); diff --git a/esphome/components/st7920/st7920.cpp b/esphome/components/st7920/st7920.cpp index 429ed9e57be..19a96aad602 100644 --- a/esphome/components/st7920/st7920.cpp +++ b/esphome/components/st7920/st7920.cpp @@ -5,7 +5,7 @@ namespace esphome::st7920 { -static const char *const TAG = "st7920"; +ESPHOME_LOG_TAG(TAG, "st7920"); // ST7920 COMMANDS static const uint8_t LCD_DATA = 0xFA; diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 2a56551255b..12af8345f13 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -10,7 +10,7 @@ namespace esphome::statsd { // this is needed since statsD does not support fragmented UDP packets static const uint16_t SEND_THRESHOLD = 1024; -static const char *const TAG = "statsD"; +ESPHOME_LOG_TAG(TAG, "statsD"); void StatsdComponent::setup() { #ifndef USE_ESP8266 diff --git a/esphome/components/status/status_binary_sensor.cpp b/esphome/components/status/status_binary_sensor.cpp index 2c95be85690..192c03219fc 100644 --- a/esphome/components/status/status_binary_sensor.cpp +++ b/esphome/components/status/status_binary_sensor.cpp @@ -12,7 +12,7 @@ namespace esphome::status { -static const char *const TAG = "status"; +ESPHOME_LOG_TAG(TAG, "status"); void StatusBinarySensor::update() { bool status = network::is_connected(); diff --git a/esphome/components/status_led/light/status_led_light.cpp b/esphome/components/status_led/light/status_led_light.cpp index 341d3bfce53..6eeb9732a47 100644 --- a/esphome/components/status_led/light/status_led_light.cpp +++ b/esphome/components/status_led/light/status_led_light.cpp @@ -5,7 +5,7 @@ namespace esphome::status_led { -static const char *const TAG = "status_led"; +ESPHOME_LOG_TAG(TAG, "status_led"); void StatusLEDLightOutput::loop() { uint8_t new_state = App.get_app_state() & STATUS_LED_MASK; diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index ae37b3fae78..81b3b9e3af7 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -4,7 +4,7 @@ namespace esphome::status_led { -static const char *const TAG = "status_led"; +ESPHOME_LOG_TAG(TAG, "status_led"); static constexpr uint32_t ERROR_PERIOD_MS = 250; static constexpr uint32_t ERROR_ON_MS = 150; diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp index efc7a2b8ee7..ac7a998e5d6 100644 --- a/esphome/components/stcc4/stcc4.cpp +++ b/esphome/components/stcc4/stcc4.cpp @@ -6,7 +6,7 @@ namespace esphome::stcc4 { -static const char *const TAG = "stcc4"; +ESPHOME_LOG_TAG(TAG, "stcc4"); // I2C Commands static constexpr uint16_t STCC4_CMD_START_CONTINUOUS_MEASUREMENT = 0x218b; diff --git a/esphome/components/stepper/stepper.cpp b/esphome/components/stepper/stepper.cpp index 54df83782ec..dd3040d8987 100644 --- a/esphome/components/stepper/stepper.cpp +++ b/esphome/components/stepper/stepper.cpp @@ -4,7 +4,7 @@ namespace esphome::stepper { -static const char *const TAG = "stepper"; +ESPHOME_LOG_TAG(TAG, "stepper"); void Stepper::calculate_speed_(uint32_t now) { // delta t since last calculation in seconds diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index ff2a7748bf0..8b97b2f4997 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -3,7 +3,7 @@ namespace esphome::sts3x { -static const char *const TAG = "sts3x"; +ESPHOME_LOG_TAG(TAG, "sts3x"); static const uint16_t STS3X_COMMAND_READ_SERIAL_NUMBER = 0x3780; static const uint16_t STS3X_COMMAND_READ_STATUS = 0xF32D; diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 2b2559c8436..7d66dd0a183 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -3,7 +3,7 @@ namespace esphome::stts22h { -static const char *const TAG = "stts22h"; +ESPHOME_LOG_TAG(TAG, "stts22h"); static const uint8_t WHOAMI_REG = 0x01; static const uint8_t CTRL_REG = 0x04; diff --git a/esphome/components/sun/sensor/sun_sensor.cpp b/esphome/components/sun/sensor/sun_sensor.cpp index d788e19ea41..b3cdacddaed 100644 --- a/esphome/components/sun/sensor/sun_sensor.cpp +++ b/esphome/components/sun/sensor/sun_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::sun { -static const char *const TAG = "sun.sensor"; +ESPHOME_LOG_TAG(TAG, "sun.sensor"); void SunSensor::dump_config() { LOG_SENSOR("", "Sun Sensor", this); } diff --git a/esphome/components/sun/sun.cpp b/esphome/components/sun/sun.cpp index d03ff07981e..f4c9d830b4d 100644 --- a/esphome/components/sun/sun.cpp +++ b/esphome/components/sun/sun.cpp @@ -16,7 +16,7 @@ namespace esphome::sun { using namespace esphome::sun::internal; -static const char *const TAG = "sun"; +ESPHOME_LOG_TAG(TAG, "sun"); #undef degrees #undef radians diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.cpp b/esphome/components/sun/text_sensor/sun_text_sensor.cpp index 21aa4b86e0f..15a3fc21be5 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.cpp +++ b/esphome/components/sun/text_sensor/sun_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::sun { -static const char *const TAG = "sun.text_sensor"; +ESPHOME_LOG_TAG(TAG, "sun.text_sensor"); void SunTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sun Text Sensor", this); } diff --git a/esphome/components/sun_gtil2/sun_gtil2.cpp b/esphome/components/sun_gtil2/sun_gtil2.cpp index 78e398d0868..f89797cc106 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.cpp +++ b/esphome/components/sun_gtil2/sun_gtil2.cpp @@ -3,7 +3,7 @@ namespace esphome::sun_gtil2 { -static const char *const TAG = "sun_gtil2"; +ESPHOME_LOG_TAG(TAG, "sun_gtil2"); static const double NTC_A = 0.0011591051055979914; static const double NTC_B = 0.00022878183547845582; diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp b/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp index 19995fb1ae3..c5b93e1dbb2 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::switch_ { -static const char *const TAG = "switch.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "switch.binary_sensor"); void SwitchBinarySensor::setup() { source_->add_on_state_callback([this](bool value) { this->publish_state(value); }); diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 2cc8ad782d9..179ef2ce35d 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -5,7 +5,7 @@ namespace esphome::switch_ { -static const char *const TAG = "switch"; +ESPHOME_LOG_TAG(TAG, "switch"); Switch::Switch() : state(false) {} diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 5e992cc731b..70dc818495d 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -4,7 +4,7 @@ namespace esphome::sx126x { -static const char *const TAG = "sx126x_transport"; +ESPHOME_LOG_TAG(TAG, "sx126x_transport"); void SX126xTransport::setup() { PacketTransport::setup(); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 673464d5eb7..6c1281c89b1 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -4,7 +4,7 @@ namespace esphome::sx126x { -static const char *const TAG = "sx126x"; +ESPHOME_LOG_TAG(TAG, "sx126x"); static const uint16_t RAMP[8] = {10, 20, 40, 80, 200, 800, 1700, 3400}; static const uint32_t BW_HZ[31] = {4800, 5800, 7300, 9700, 11700, 14600, 19500, 23400, 29300, 39000, 46900, 58600, 78200, 93800, 117300, 156200, 187200, 234300, 312000, 373600, 467000, 7810, diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index 52d5631791c..77d1ea28801 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -4,7 +4,7 @@ namespace esphome::sx127x { -static const char *const TAG = "sx127x_transport"; +ESPHOME_LOG_TAG(TAG, "sx127x_transport"); void SX127xTransport::setup() { PacketTransport::setup(); diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 9af30400a7c..16c3d1dd21f 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -4,7 +4,7 @@ namespace esphome::sx127x { -static const char *const TAG = "sx127x"; +ESPHOME_LOG_TAG(TAG, "sx127x"); static const uint32_t FXOSC = 32000000u; static const uint16_t RAMP[16] = {3400, 2000, 1000, 500, 250, 125, 100, 62, 50, 40, 31, 25, 20, 15, 12, 10}; static const uint32_t BW_HZ[22] = {2604, 3125, 3906, 5208, 6250, 7812, 10416, 12500, 15625, 20833, 25000, diff --git a/esphome/components/sx1509/output/sx1509_float_output.cpp b/esphome/components/sx1509/output/sx1509_float_output.cpp index 528de1fde5a..5bf47e97bde 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.cpp +++ b/esphome/components/sx1509/output/sx1509_float_output.cpp @@ -4,7 +4,7 @@ namespace esphome::sx1509 { -static const char *const TAG = "sx1509_float_channel"; +ESPHOME_LOG_TAG(TAG, "sx1509_float_channel"); void SX1509FloatOutputChannel::write_state(float state) { const uint16_t max_duty = 255; diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index ee8606db96d..169d35331f8 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -4,7 +4,7 @@ namespace esphome::sx1509 { -static const char *const TAG = "sx1509"; +ESPHOME_LOG_TAG(TAG, "sx1509"); void SX1509Component::setup() { ESP_LOGV(TAG, " Resetting devices"); diff --git a/esphome/components/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp index 28ef1c5830f..41f967b6672 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ b/esphome/components/sx1509/sx1509_gpio_pin.cpp @@ -5,7 +5,7 @@ namespace esphome::sx1509 { -static const char *const TAG = "sx1509_gpio_pin"; +ESPHOME_LOG_TAG(TAG, "sx1509_gpio_pin"); void SX1509GPIOPin::setup() { pin_mode(flags_); } void SX1509GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 1f1648cfa77..3f381002cbd 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -4,7 +4,7 @@ namespace esphome::sy6970 { -static const char *const TAG = "sy6970"; +ESPHOME_LOG_TAG(TAG, "sy6970"); bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp index 08a46771805..5d9890eca51 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::systa_bus { -static const char *const TAG = "systa_bus.sensor"; +ESPHOME_LOG_TAG(TAG, "systa_bus.sensor"); static int16_t get_i16be(std::span message, size_t start) { return static_cast(encode_uint16(message[start], message[start + 1])); diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 3a2f6717e9b..4c537b75cbf 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -6,7 +6,7 @@ namespace esphome::systa_bus { -static const char *const TAG = "systa_bus"; +ESPHOME_LOG_TAG(TAG, "systa_bus"); void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index 982cc181b76..d3997528890 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -4,7 +4,7 @@ namespace esphome::t6615 { -static const char *const TAG = "t6615"; +ESPHOME_LOG_TAG(TAG, "t6615"); static const uint32_t T6615_TIMEOUT = 1000; static const uint8_t T6615_MAGIC = 0xFF; diff --git a/esphome/components/tas2780/tas2780.cpp b/esphome/components/tas2780/tas2780.cpp index 191dbf823c3..d69bd4dca13 100644 --- a/esphome/components/tas2780/tas2780.cpp +++ b/esphome/components/tas2780/tas2780.cpp @@ -8,7 +8,7 @@ namespace esphome::tas2780 { -static const char *const TAG = "tas2780"; +ESPHOME_LOG_TAG(TAG, "tas2780"); static constexpr uint8_t TAS2780_PAGE_SELECT = 0x00; // Page Select static constexpr uint8_t TAS2780_PAGE_0 = 0x00; // Page 0 diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index d042d42e24e..3addbfda00d 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -8,7 +8,7 @@ namespace esphome::tas58xx { -static const char *const TAG = "tas58xx"; +ESPHOME_LOG_TAG(TAG, "tas58xx"); static constexpr uint8_t TAS58XX_PAGE_SELECT = 0x00; // Page Select, in every book static constexpr uint8_t TAS58XX_BOOK_SELECT = 0x7F; // Book Select, on page 0 of every book diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index bc522d1b745..98428cd1225 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -5,7 +5,7 @@ namespace esphome::tc74 { -static const char *const TAG = "tc74"; +ESPHOME_LOG_TAG(TAG, "tc74"); static const uint8_t TC74_REGISTER_TEMPERATURE = 0x00; static const uint8_t TC74_REGISTER_CONFIGURATION = 0x01; diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index 3fc91a3bbb9..f10578c97c2 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -3,7 +3,7 @@ namespace esphome::tca9548a { -static const char *const TAG = "tca9548a"; +ESPHOME_LOG_TAG(TAG, "tca9548a"); i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) { diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index b210c082dda..7a59c403f31 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -12,7 +12,7 @@ static const uint8_t TCA9555_CONFIGURATION_PORT_1 = 0x07; namespace esphome::tca9555 { -static const char *const TAG = "tca9555"; +ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { if (!this->read_gpio_modes_()) { diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index cd819e5b16b..5e7f0ef29c7 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -3,7 +3,7 @@ namespace esphome::tcl112 { -static const char *const TAG = "tcl112.climate"; +ESPHOME_LOG_TAG(TAG, "tcl112.climate"); const uint16_t TCL112_STATE_LENGTH = 14; const uint16_t TCL112_BITS = TCL112_STATE_LENGTH * 8; diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index efc85804e99..3dead1fb65c 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -8,7 +8,7 @@ namespace esphome::tcp_uart { -static const char *const TAG = "tcp_uart"; +ESPHOME_LOG_TAG(TAG, "tcp_uart"); static constexpr uint32_t DROP_LOG_INTERVAL_MS = 5000; diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index b5853927900..8e79ac1c162 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -6,7 +6,7 @@ namespace esphome::tcs34725 { -static const char *const TAG = "tcs34725"; +ESPHOME_LOG_TAG(TAG, "tcs34725"); static const uint8_t TCS34725_ADDRESS = 0x29; static const uint8_t TCS34725_COMMAND_BIT = 0x80; diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index c198ff10813..74906ba5c45 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -4,7 +4,7 @@ namespace esphome::tee501 { -static const char *const TAG = "tee501"; +ESPHOME_LOG_TAG(TAG, "tee501"); static constexpr size_t TEE501_SERIAL_NUMBER_SIZE = 7; diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp index 3878a3967b2..443d4be8a10 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::teleinfo { -static const char *const TAG = "teleinfo_sensor"; +ESPHOME_LOG_TAG(TAG, "teleinfo_sensor"); TeleInfoSensor::TeleInfoSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoSensor::publish_val(const std::string &val) { auto newval = parse_number(val).value_or(0.0f); diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 17d3d6c0999..6b68dacd31b 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -3,7 +3,7 @@ namespace esphome::teleinfo { -static const char *const TAG = "teleinfo"; +ESPHOME_LOG_TAG(TAG, "teleinfo"); /* Helpers */ static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int max_len) { diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp index 7c638d8545b..a2646e5bb7b 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::teleinfo { -static const char *const TAG = "teleinfo_text_sensor"; +ESPHOME_LOG_TAG(TAG, "teleinfo_text_sensor"); TeleInfoTextSensor::TeleInfoTextSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoTextSensor::publish_val(const std::string &val) { publish_state(val); } void TeleInfoTextSensor::dump_config() { LOG_TEXT_SENSOR(" ", "Teleinfo Text Sensor", this); } diff --git a/esphome/components/tem3200/tem3200.cpp b/esphome/components/tem3200/tem3200.cpp index 72cf31e0a67..e964814bbb9 100644 --- a/esphome/components/tem3200/tem3200.cpp +++ b/esphome/components/tem3200/tem3200.cpp @@ -5,7 +5,7 @@ namespace esphome::tem3200 { -static const char *const TAG = "tem3200"; +ESPHOME_LOG_TAG(TAG, "tem3200"); enum ErrorCode { NONE = 0, diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index a224ab84599..293f0255278 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -11,7 +11,7 @@ namespace esphome::template_ { using namespace esphome::alarm_control_panel; -static const char *const TAG = "template.alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "template.alarm_control_panel"); TemplateAlarmControlPanel::TemplateAlarmControlPanel(){}; diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.cpp b/esphome/components/template/binary_sensor/template_binary_sensor.cpp index b63121d2dbc..8ce51aa4ca8 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.cpp +++ b/esphome/components/template/binary_sensor/template_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "template.binary_sensor"); void TemplateBinarySensor::setup() { if (!this->f_.has_value()) { diff --git a/esphome/components/template/climate/template_climate.cpp b/esphome/components/template/climate/template_climate.cpp index a7a4d2ccabf..a731f037e32 100644 --- a/esphome/components/template/climate/template_climate.cpp +++ b/esphome/components/template/climate/template_climate.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.climate"; +ESPHOME_LOG_TAG(TAG, "template.climate"); void TemplateClimate::setup() { if (this->restore_mode_ == TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE) { diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 1bf057da5b5..88a0af10b5c 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { using namespace esphome::cover; -static const char *const TAG = "template.cover"; +ESPHOME_LOG_TAG(TAG, "template.cover"); TemplateCover::TemplateCover() = default; void TemplateCover::setup() { diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index c0f5d96c3da..4ae7429e96a 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -6,7 +6,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.date"; +ESPHOME_LOG_TAG(TAG, "template.date"); void TemplateDate::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index 5b8b308c008..0776a6968a2 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -6,7 +6,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.datetime"; +ESPHOME_LOG_TAG(TAG, "template.datetime"); void TemplateDateTime::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index b5efa62ae78..fd676e66a6e 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -6,7 +6,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.time"; +ESPHOME_LOG_TAG(TAG, "template.time"); void TemplateTime::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 431be846544..93b02f12453 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.fan"; +ESPHOME_LOG_TAG(TAG, "template.fan"); void TemplateFan::setup() { // Construct traits before restore so preset modes can be looked up by index diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 4a293aab858..54e4e49ce93 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { using namespace esphome::lock; -static const char *const TAG = "template.lock"; +ESPHOME_LOG_TAG(TAG, "template.lock"); TemplateLock::TemplateLock() = default; diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index 64c2deb281a..93eb42708ec 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.number"; +ESPHOME_LOG_TAG(TAG, "template.number"); void TemplateNumber::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index e68729c2d4e..8c46b3a58c9 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.select"; +ESPHOME_LOG_TAG(TAG, "template.select"); void dump_config_helper(BaseTemplateSelect *sel_comp, bool optimistic, bool has_lambda, const size_t initial_option_index, bool restore_value) { diff --git a/esphome/components/template/sensor/template_sensor.cpp b/esphome/components/template/sensor/template_sensor.cpp index 313a163e389..cc12d7bbeca 100644 --- a/esphome/components/template/sensor/template_sensor.cpp +++ b/esphome/components/template/sensor/template_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.sensor"; +ESPHOME_LOG_TAG(TAG, "template.sensor"); void TemplateSensor::update() { if (!this->f_.has_value()) diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 729db370531..456e93d13dd 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.switch"; +ESPHOME_LOG_TAG(TAG, "template.switch"); TemplateSwitch::TemplateSwitch() = default; diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index af134e6ed4a..d892afade4d 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.text"; +ESPHOME_LOG_TAG(TAG, "template.text"); void TemplateText::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/text_sensor/template_text_sensor.cpp b/esphome/components/template/text_sensor/template_text_sensor.cpp index 89a15b6081e..38de4ea8de9 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.cpp +++ b/esphome/components/template/text_sensor/template_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.text_sensor"; +ESPHOME_LOG_TAG(TAG, "template.text_sensor"); void TemplateTextSensor::update() { if (!this->f_.has_value()) diff --git a/esphome/components/template/time/template_real_time_clock.cpp b/esphome/components/template/time/template_real_time_clock.cpp index 4b523def251..4e8a272285d 100644 --- a/esphome/components/template/time/template_real_time_clock.cpp +++ b/esphome/components/template/time/template_real_time_clock.cpp @@ -2,7 +2,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.time"; +ESPHOME_LOG_TAG(TAG, "template.time"); time_t TemplateRealTimeClock::timestamp_now() { auto val = this->f_.call(); diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 50906876399..77e65f04570 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { using namespace esphome::valve; -static const char *const TAG = "template.valve"; +ESPHOME_LOG_TAG(TAG, "template.valve"); TemplateValve::TemplateValve() = default; diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 7718688d68a..0a71225de30 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.water_heater"; +ESPHOME_LOG_TAG(TAG, "template.water_heater"); TemplateWaterHeater::TemplateWaterHeater() = default; diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index a1df6286c70..58ab300b904 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -6,7 +6,7 @@ namespace esphome::text { -static const char *const TAG = "text"; +ESPHOME_LOG_TAG(TAG, "text"); void Text::publish_state(const char *state, size_t len) { this->set_has_state(true); diff --git a/esphome/components/text/text_call.cpp b/esphome/components/text/text_call.cpp index b7692658af1..93f0610d7d6 100644 --- a/esphome/components/text/text_call.cpp +++ b/esphome/components/text/text_call.cpp @@ -4,7 +4,7 @@ namespace esphome::text { -static const char *const TAG = "text"; +ESPHOME_LOG_TAG(TAG, "text"); TextCall &TextCall::set_value(const std::string &value) { this->value_ = value; diff --git a/esphome/components/text/text_sensor/text_text_sensor.cpp b/esphome/components/text/text_sensor/text_text_sensor.cpp index 50504c605ea..6574141e9f8 100644 --- a/esphome/components/text/text_sensor/text_text_sensor.cpp +++ b/esphome/components/text/text_sensor/text_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::text { -static const char *const TAG = "text.text_sensor"; +ESPHOME_LOG_TAG(TAG, "text.text_sensor"); void TextTextSensor::setup() { this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); }); diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index d4e6b5b9bbf..9d18fb9b1a6 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -8,7 +8,7 @@ namespace esphome::text_sensor { -static const char *const TAG = "text_sensor.filter"; +ESPHOME_LOG_TAG(TAG, "text_sensor.filter"); // Filter void Filter::input(std::string value) { diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 17c606d2530..af4c5fd0816 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::text_sensor { -static const char *const TAG = "text_sensor"; +ESPHOME_LOG_TAG(TAG, "text_sensor"); void log_text_sensor(const char *tag, const char *prefix, const char *type, TextSensor *obj) { if (obj == nullptr) { diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index 1466ba89470..0936a07f49c 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -16,7 +16,7 @@ static constexpr uint8_t MODE_TRIGGER = 0x01; static constexpr uint8_t TRIGGER_ONESHOT_REGISTER = 0x24; static constexpr uint8_t MAX_READ_ATTEMPTS = 5; static constexpr uint8_t READ_RETRY_MS = 5; -static const char *const TAG = "tfluna"; +ESPHOME_LOG_TAG(TAG, "tfluna"); static const char *const READ_RETRY_TIMEOUT = "read_retry"; void TFLuna::dump_config() { diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index d10a6c33cd0..38a58137aec 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -19,7 +19,7 @@ static optional parse_tp972(const uint8_t *data, std::size_t data_s static optional parse_tp96(const uint8_t *data, std::size_t data_size); static optional parse_tp3(const uint8_t *data, std::size_t data_size); -static const char *const TAG = "thermopro_ble"; +ESPHOME_LOG_TAG(TAG, "thermopro_ble"); static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = { {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}}; diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index f64673e13fa..abfe1b027a5 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -6,7 +6,7 @@ namespace esphome::thermostat { -static const char *const TAG = "thermostat.climate"; +ESPHOME_LOG_TAG(TAG, "thermostat.climate"); void ThermostatClimate::setup() { if (this->use_startup_delay_) { diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index b91f8b03604..26956703e7f 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -7,7 +7,7 @@ namespace esphome::time { -static const char *const TAG = "automation"; +ESPHOME_LOG_TAG(TAG, "automation"); static const int MAX_TIMESTAMP_DRIFT = 900; // how far can the clock drift before we consider // there has been a drastic time synchronization diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4f325605250..5f6b7d150f9 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -19,7 +19,7 @@ namespace esphome::time { -static const char *const TAG = "time"; +ESPHOME_LOG_TAG(TAG, "time"); RealTimeClock::RealTimeClock() = default; diff --git a/esphome/components/time_based/cover/time_based_cover.cpp b/esphome/components/time_based/cover/time_based_cover.cpp index 613b190cf3c..4be933dd46c 100644 --- a/esphome/components/time_based/cover/time_based_cover.cpp +++ b/esphome/components/time_based/cover/time_based_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::time_based { -static const char *const TAG = "time_based.cover"; +ESPHOME_LOG_TAG(TAG, "time_based.cover"); using namespace esphome::cover; diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 3fab9de0086..14291fb3284 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -7,7 +7,7 @@ namespace esphome::tinyusb { -static const char *const TAG = "tinyusb"; +ESPHOME_LOG_TAG(TAG, "tinyusb"); // Runs on the TinyUSB task: only wake the main loop, which reads the state and runs // the automations. diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index def337befc0..d76845b4f03 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -5,7 +5,7 @@ namespace esphome::tlc59208f { -static const char *const TAG = "tlc59208f"; +ESPHOME_LOG_TAG(TAG, "tlc59208f"); // * marks register defaults // 0*: Register auto increment disabled, 1: Register auto increment enabled diff --git a/esphome/components/tlc5947/tlc5947.cpp b/esphome/components/tlc5947/tlc5947.cpp index f886118a088..5502679c9d8 100644 --- a/esphome/components/tlc5947/tlc5947.cpp +++ b/esphome/components/tlc5947/tlc5947.cpp @@ -3,7 +3,7 @@ namespace esphome::tlc5947 { -static const char *const TAG = "tlc5947"; +ESPHOME_LOG_TAG(TAG, "tlc5947"); void TLC5947::setup() { this->data_pin_->setup(); diff --git a/esphome/components/tlc5971/tlc5971.cpp b/esphome/components/tlc5971/tlc5971.cpp index 5818eace679..2c4ec567321 100644 --- a/esphome/components/tlc5971/tlc5971.cpp +++ b/esphome/components/tlc5971/tlc5971.cpp @@ -3,7 +3,7 @@ namespace esphome::tlc5971 { -static const char *const TAG = "tlc5971"; +ESPHOME_LOG_TAG(TAG, "tlc5971"); void TLC5971::setup() { this->data_pin_->setup(); diff --git a/esphome/components/tm1621/tm1621.cpp b/esphome/components/tm1621/tm1621.cpp index 68d16e38116..5ca1c83c48e 100644 --- a/esphome/components/tm1621/tm1621.cpp +++ b/esphome/components/tm1621/tm1621.cpp @@ -5,7 +5,7 @@ namespace esphome::tm1621 { -static const char *const TAG = "tm1621"; +ESPHOME_LOG_TAG(TAG, "tm1621"); const uint8_t TM1621_PULSE_WIDTH = 10; // microseconds (Sonoff = 100) diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index a1604fa60e8..03eb427e76b 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -5,7 +5,7 @@ namespace esphome::tm1637 { -static const char *const TAG = "display.tm1637"; +ESPHOME_LOG_TAG(TAG, "display.tm1637"); const uint8_t TM1637_CMD_DATA = 0x40; //!< Display data command const uint8_t TM1637_CMD_CTRL = 0x80; //!< Display control command const uint8_t TM1637_CMD_ADDR = 0xc0; //!< Display address command diff --git a/esphome/components/tm1638/output/tm1638_output_led.cpp b/esphome/components/tm1638/output/tm1638_output_led.cpp index e32826fa930..8e2020459ce 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.cpp +++ b/esphome/components/tm1638/output/tm1638_output_led.cpp @@ -3,7 +3,7 @@ namespace esphome::tm1638 { -static const char *const TAG = "tm1638.led"; +ESPHOME_LOG_TAG(TAG, "tm1638.led"); void TM1638OutputLed::write_state(bool state) { tm1638_->set_led(led_, state); } diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.cpp b/esphome/components/tm1638/switch/tm1638_switch_led.cpp index 743d0af5075..f5573a35921 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.cpp +++ b/esphome/components/tm1638/switch/tm1638_switch_led.cpp @@ -3,7 +3,7 @@ namespace esphome::tm1638 { -static const char *const TAG = "tm1638.led"; +ESPHOME_LOG_TAG(TAG, "tm1638.led"); void TM1638SwitchLed::write_state(bool state) { tm1638_->set_led(led_, state); diff --git a/esphome/components/tm1638/tm1638.cpp b/esphome/components/tm1638/tm1638.cpp index 1f0692479c2..ffb0ead0cd8 100644 --- a/esphome/components/tm1638/tm1638.cpp +++ b/esphome/components/tm1638/tm1638.cpp @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -static const char *const TAG = "display.tm1638"; +ESPHOME_LOG_TAG(TAG, "display.tm1638"); static const uint8_t TM1638_REGISTER_FIXEDADDRESS = 0x44; static const uint8_t TM1638_REGISTER_AUTOADDRESS = 0x40; static const uint8_t TM1638_REGISTER_READBUTTONS = 0x42; diff --git a/esphome/components/tm1651/tm1651.cpp b/esphome/components/tm1651/tm1651.cpp index 282b0dcf76b..467695b4b43 100644 --- a/esphome/components/tm1651/tm1651.cpp +++ b/esphome/components/tm1651/tm1651.cpp @@ -53,7 +53,7 @@ namespace esphome::tm1651 { -static const char *const TAG = "tm1651.display"; +ESPHOME_LOG_TAG(TAG, "tm1651.display"); static const bool LINE_HIGH = true; static const bool LINE_LOW = false; diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index cb2462858b6..4f10555324e 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -4,7 +4,7 @@ namespace esphome::tmp102 { -static const char *const TAG = "tmp102"; +ESPHOME_LOG_TAG(TAG, "tmp102"); static const uint8_t TMP102_ADDRESS = 0x48; static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 681603d1136..9b152c1ad34 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -3,7 +3,7 @@ namespace esphome::tmp1075 { -static const char *const TAG = "tmp1075"; +ESPHOME_LOG_TAG(TAG, "tmp1075"); constexpr uint8_t REG_TEMP = 0x0; // Temperature result constexpr uint8_t REG_CFGR = 0x1; // Configuration diff --git a/esphome/components/tmp117/tmp117.cpp b/esphome/components/tmp117/tmp117.cpp index b3e900f5b65..5925b297c2b 100644 --- a/esphome/components/tmp117/tmp117.cpp +++ b/esphome/components/tmp117/tmp117.cpp @@ -6,7 +6,7 @@ namespace esphome::tmp117 { -static const char *const TAG = "tmp117"; +ESPHOME_LOG_TAG(TAG, "tmp117"); void TMP117Component::update() { int16_t data; diff --git a/esphome/components/tof10120/tof10120_sensor.cpp b/esphome/components/tof10120/tof10120_sensor.cpp index 290bc76a521..3943b37150c 100644 --- a/esphome/components/tof10120/tof10120_sensor.cpp +++ b/esphome/components/tof10120/tof10120_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::tof10120 { -static const char *const TAG = "tof10120"; +ESPHOME_LOG_TAG(TAG, "tof10120"); static const uint8_t TOF10120_READ_DISTANCE_CMD[] = {0x00}; static const uint8_t TOF10120_DEFAULT_DELAY = 30; diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 5c8d6623b64..fe7b4cf112e 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -7,7 +7,7 @@ using namespace std; namespace esphome::tormatic { -static const char *const TAG = "tormatic.cover"; +ESPHOME_LOG_TAG(TAG, "tormatic.cover"); // Time to poll the UART when flushing after desync. At 9600 baud, a full // 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived. diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 19950bbd159..f9f09a98bc4 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -11,7 +11,7 @@ struct RacPt1411hwruFanSpeed { uint8_t code2; }; -static const char *const TAG = "toshiba.climate"; +ESPHOME_LOG_TAG(TAG, "toshiba.climate"); // Timings for IR bits/data const uint16_t TOSHIBA_HEADER_MARK = 4380; const uint16_t TOSHIBA_HEADER_SPACE = 4370; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 7c9dbb604f7..c8e6bdd04aa 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -4,7 +4,7 @@ namespace esphome::total_daily_energy { -static const char *const TAG = "total_daily_energy"; +ESPHOME_LOG_TAG(TAG, "total_daily_energy"); static constexpr uint32_t TIMEOUT_ID_MIDNIGHT = 1; static constexpr uint8_t SECONDS_PER_MINUTE = 60; static constexpr uint8_t MINUTES_PER_HOUR = 60; diff --git a/esphome/components/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index cb36733e2f5..7072b88d554 100644 --- a/esphome/components/touchscreen/touchscreen.cpp +++ b/esphome/components/touchscreen/touchscreen.cpp @@ -4,7 +4,7 @@ namespace esphome::touchscreen { -static const char *const TAG = "touchscreen"; +ESPHOME_LOG_TAG(TAG, "touchscreen"); static constexpr uint32_t TOUCH_TIMEOUT_ID = 0; void TouchscreenInterrupt::gpio_intr(TouchscreenInterrupt *store) { store->touched = true; } diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 4e4d403488d..989a15f054b 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -3,7 +3,7 @@ namespace esphome::tsl2561 { -static const char *const TAG = "tsl2561"; +ESPHOME_LOG_TAG(TAG, "tsl2561"); static const uint8_t TSL2561_COMMAND_BIT = 0x80; static const uint8_t TSL2561_WORD_BIT = 0x20; diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index d147aae88ab..dfc0dfedf4e 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -4,7 +4,7 @@ namespace esphome::tsl2591 { -static const char *const TAG = "tsl2591.sensor"; +ESPHOME_LOG_TAG(TAG, "tsl2591.sensor"); // Various constants used in TSL2591 register manipulation #define TSL2591_COMMAND_BIT (0xA0) // 1010 0000: bits 7 and 5 for 'command, normal' diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.cpp b/esphome/components/tt21100/binary_sensor/tt21100_button.cpp index ccf6e53d665..ea388703e08 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.cpp +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.cpp @@ -3,7 +3,7 @@ namespace esphome::tt21100 { -static const char *const TAG = "tt21100.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "tt21100.binary_sensor"); void TT21100Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/tt21100/touchscreen/tt21100.cpp b/esphome/components/tt21100/touchscreen/tt21100.cpp index 018094df737..b850dbc696a 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.cpp +++ b/esphome/components/tt21100/touchscreen/tt21100.cpp @@ -3,7 +3,7 @@ namespace esphome::tt21100 { -static const char *const TAG = "tt21100"; +ESPHOME_LOG_TAG(TAG, "tt21100"); static const uint8_t MAX_BUTTONS = 4; static const uint8_t MAX_TOUCH_POINTS = 5; diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.cpp b/esphome/components/ttp229_bsf/ttp229_bsf.cpp index 1c7fa3531f0..807d7365572 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.cpp +++ b/esphome/components/ttp229_bsf/ttp229_bsf.cpp @@ -3,7 +3,7 @@ namespace esphome::ttp229_bsf { -static const char *const TAG = "ttp229_bsf"; +ESPHOME_LOG_TAG(TAG, "ttp229_bsf"); void TTP229BSFComponent::setup() { this->sdo_pin_->setup(); diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index eaef33d7935..7ed06c6d6f2 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -3,7 +3,7 @@ namespace esphome::ttp229_lsf { -static const char *const TAG = "ttp229_lsf"; +ESPHOME_LOG_TAG(TAG, "ttp229_lsf"); void TTP229LSFComponent::setup() { uint8_t data[2]; diff --git a/esphome/components/tuya/automation.cpp b/esphome/components/tuya/automation.cpp index 5c84f36b832..a7eacf8e9a3 100644 --- a/esphome/components/tuya/automation.cpp +++ b/esphome/components/tuya/automation.cpp @@ -2,7 +2,7 @@ #include "automation.h" -static const char *const TAG = "tuya.automation"; +ESPHOME_LOG_TAG(TAG, "tuya.automation"); namespace esphome::tuya { diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp index f93bd31b9d9..9d0589305ed 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "tuya.binary_sensor"); void TuyaBinarySensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index 111d090c3e1..e07806326c5 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.climate"; +ESPHOME_LOG_TAG(TAG, "tuya.climate"); void TuyaClimate::setup() { auto switch_id = this->switch_id_; diff --git a/esphome/components/tuya/cover/tuya_cover.cpp b/esphome/components/tuya/cover/tuya_cover.cpp index dd268388d00..b23dde75db9 100644 --- a/esphome/components/tuya/cover/tuya_cover.cpp +++ b/esphome/components/tuya/cover/tuya_cover.cpp @@ -9,7 +9,7 @@ const uint8_t COMMAND_STOP = 0x01; using namespace esphome::cover; -static const char *const TAG = "tuya.cover"; +ESPHOME_LOG_TAG(TAG, "tuya.cover"); void TuyaCover::setup() { this->value_range_ = this->max_value_ - this->min_value_; diff --git a/esphome/components/tuya/fan/tuya_fan.cpp b/esphome/components/tuya/fan/tuya_fan.cpp index 0b5fc19038c..8e4694baed2 100644 --- a/esphome/components/tuya/fan/tuya_fan.cpp +++ b/esphome/components/tuya/fan/tuya_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.fan"; +ESPHOME_LOG_TAG(TAG, "tuya.fan"); void TuyaFan::setup() { auto speed_id = this->speed_id_; diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 32cbca9a390..4852e5ff445 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -4,7 +4,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.light"; +ESPHOME_LOG_TAG(TAG, "tuya.light"); void TuyaLight::setup() { if (this->color_temperature_id_.has_value()) { diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index b0bbfce6490..538021bca24 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -5,7 +5,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.number"; +ESPHOME_LOG_TAG(TAG, "tuya.number"); void TuyaNumber::setup() { if (this->restore_value_) { diff --git a/esphome/components/tuya/select/tuya_select.cpp b/esphome/components/tuya/select/tuya_select.cpp index 057f7f8ea69..7c00c7c0f97 100644 --- a/esphome/components/tuya/select/tuya_select.cpp +++ b/esphome/components/tuya/select/tuya_select.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.select"; +ESPHOME_LOG_TAG(TAG, "tuya.select"); void TuyaSelect::setup() { this->parent_->register_listener(this->select_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/sensor/tuya_sensor.cpp b/esphome/components/tuya/sensor/tuya_sensor.cpp index c9489847864..6b16d930804 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.cpp +++ b/esphome/components/tuya/sensor/tuya_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.sensor"; +ESPHOME_LOG_TAG(TAG, "tuya.sensor"); void TuyaSensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/switch/tuya_switch.cpp b/esphome/components/tuya/switch/tuya_switch.cpp index 8d4d183d5b6..204ee84170a 100644 --- a/esphome/components/tuya/switch/tuya_switch.cpp +++ b/esphome/components/tuya/switch/tuya_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.switch"; +ESPHOME_LOG_TAG(TAG, "tuya.switch"); void TuyaSwitch::setup() { this->parent_->register_listener(this->switch_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp index ebb35cead7f..2e64fb1dea1 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.text_sensor"; +ESPHOME_LOG_TAG(TAG, "tuya.text_sensor"); void TuyaTextSensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index f9b4fe24532..992dde15336 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -18,7 +18,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya"; +ESPHOME_LOG_TAG(TAG, "tuya"); static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; diff --git a/esphome/components/tuya/water_heater/tuya_water_heater.cpp b/esphome/components/tuya/water_heater/tuya_water_heater.cpp index e1c78530e33..4ece2b0a03d 100644 --- a/esphome/components/tuya/water_heater/tuya_water_heater.cpp +++ b/esphome/components/tuya/water_heater/tuya_water_heater.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.water_heater"; +ESPHOME_LOG_TAG(TAG, "tuya.water_heater"); void TuyaWaterHeater::setup() { if (this->switch_id_.has_value()) { diff --git a/esphome/components/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 3574bd7c2d5..ce4c20ede41 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -6,7 +6,7 @@ namespace esphome::tx20 { -static const char *const TAG = "tx20"; +ESPHOME_LOG_TAG(TAG, "tx20"); static const uint8_t MAX_BUFFER_SIZE = 41; static const uint16_t TX20_MAX_TIME = MAX_BUFFER_SIZE * 1200 + 5000; static const uint16_t TX20_BIT_TIME = 1200; diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index 809ceaabb03..b55207cb46e 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -3,7 +3,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.button"; +ESPHOME_LOG_TAG(TAG, "uart.button"); void UARTButton::press_action() { ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); diff --git a/esphome/components/uart/event/uart_event.cpp b/esphome/components/uart/event/uart_event.cpp index 02c5f2e631d..22bcee9c83f 100644 --- a/esphome/components/uart/event/uart_event.cpp +++ b/esphome/components/uart/event/uart_event.cpp @@ -4,7 +4,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.event"; +ESPHOME_LOG_TAG(TAG, "uart.event"); void UARTEvent::setup() {} diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 6b8eae611c0..215cdded990 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -4,7 +4,7 @@ namespace esphome::uart { -static const char *const TAG = "uart_transport"; +ESPHOME_LOG_TAG(TAG, "uart_transport"); void UARTTransport::loop() { PacketTransport::loop(); diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 642bd19772f..6b6aa273f7a 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -4,7 +4,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.switch"; +ESPHOME_LOG_TAG(TAG, "uart.switch"); void UARTSwitch::loop() { if (this->send_every_) { diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 337fa352c14..20f6385b6a7 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -8,7 +8,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); // UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN"); diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 762e56c3990..65161746351 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -2,7 +2,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); bool UARTComponent::check_read_timeout_(size_t len) { if (this->available() >= len) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0f6d7f6577c..a32677b3daa 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -15,7 +15,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 5d90fc1c8a6..41fae283b4d 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -23,7 +23,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); /// Check if a pin number matches one of the default UART0 GPIO pins. /// These pins may have residual IOMUX state from the ROM bootloader that diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 63b5631564d..9e2f273f47c 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -98,7 +98,7 @@ speed_t get_baud(int baud) { namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); HostUartComponent::~HostUartComponent() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 293cb9e786f..5a77bf26d9e 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -18,7 +18,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); static const char *const UART_TYPE[] = { "hardware", diff --git a/esphome/components/uart/uart_component_rp2.cpp b/esphome/components/uart/uart_component_rp2.cpp index ffb9bc0f2d2..07245bc230c 100644 --- a/esphome/components/uart/uart_component_rp2.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -13,7 +13,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); uint16_t RP2UartComponent::get_config() { uint16_t config = 0; diff --git a/esphome/components/uart/uart_debugger.cpp b/esphome/components/uart/uart_debugger.cpp index e0be599d8db..53c6c0406af 100644 --- a/esphome/components/uart/uart_debugger.cpp +++ b/esphome/components/uart/uart_debugger.cpp @@ -8,7 +8,7 @@ namespace esphome::uart { -static const char *const TAG = "uart_debug"; +ESPHOME_LOG_TAG(TAG, "uart_debug"); UARTDebugger::UARTDebugger(UARTComponent *parent) { parent->add_debug_callback([this](UARTDirection direction, uint8_t byte) { diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp index 953e81d5337..11057ac078c 100644 --- a/esphome/components/uart_mux/uart_mux.cpp +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -4,7 +4,7 @@ namespace esphome::uart_mux { -static const char *const TAG = "uart_mux"; +ESPHOME_LOG_TAG(TAG, "uart_mux"); void UARTMux::setup() { // A failed UART never assigned its port; nothing behind the mux can work. diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index 4f241f85671..bc75eb04c77 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -9,7 +9,7 @@ namespace esphome::uart_tcp { -static const char *const TAG = "uart_tcp"; +ESPHOME_LOG_TAG(TAG, "uart_tcp"); // Keeps the pacing product in 32 bits up to about 10 Mbaud. static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index 40ce46d74e9..2e49b676ce8 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -5,7 +5,7 @@ namespace esphome::udp { -static const char *const TAG = "udp_transport"; +ESPHOME_LOG_TAG(TAG, "udp_transport"); bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 904ed97dc0d..3918ddc1fa8 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -7,7 +7,7 @@ namespace esphome::udp { -static const char *const TAG = "udp"; +ESPHOME_LOG_TAG(TAG, "udp"); void UDPComponent::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index 72bf837409f..afa806d2466 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -4,7 +4,7 @@ namespace esphome::ufire_ec { -static const char *const TAG = "ufire_ec"; +ESPHOME_LOG_TAG(TAG, "ufire_ec"); void UFireECComponent::setup() { uint8_t version; diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index d595b37a83c..c0fa7154859 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -5,7 +5,7 @@ namespace esphome::ufire_ise { -static const char *const TAG = "ufire_ise"; +ESPHOME_LOG_TAG(TAG, "ufire_ise"); void UFireISEComponent::setup() { uint8_t version; diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index fbacd84db0f..cd4388372cd 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -9,7 +9,7 @@ namespace esphome::ufm01 { -static const char *const TAG = "ufm01"; +ESPHOME_LOG_TAG(TAG, "ufm01"); static constexpr uint8_t COMMAND_ACK = 0xE5; static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; diff --git a/esphome/components/uln2003/uln2003.cpp b/esphome/components/uln2003/uln2003.cpp index a2244eadaaa..64db28a5306 100644 --- a/esphome/components/uln2003/uln2003.cpp +++ b/esphome/components/uln2003/uln2003.cpp @@ -3,7 +3,7 @@ namespace esphome::uln2003 { -static const char *const TAG = "uln2003.stepper"; +ESPHOME_LOG_TAG(TAG, "uln2003.stepper"); static const LogString *step_mode_to_log_string(ULN2003StepMode mode) { switch (mode) { diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.cpp b/esphome/components/ultrasonic/ultrasonic_sensor.cpp index a354b198b4b..039d08eaae7 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.cpp +++ b/esphome/components/ultrasonic/ultrasonic_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ultrasonic { -static const char *const TAG = "ultrasonic.sensor"; +ESPHOME_LOG_TAG(TAG, "ultrasonic.sensor"); static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us of each other (noise filtering) static constexpr uint32_t START_DELAY_US = 100; // Ignore edges within 100us of trigger (filters bleed-through) diff --git a/esphome/components/update/update_entity.cpp b/esphome/components/update/update_entity.cpp index b0d4c01cc96..33c33ccb621 100644 --- a/esphome/components/update/update_entity.cpp +++ b/esphome/components/update/update_entity.cpp @@ -6,7 +6,7 @@ namespace esphome::update { -static const char *const TAG = "update"; +ESPHOME_LOG_TAG(TAG, "update"); // Update state strings indexed by UpdateState enum (0-3): UNKNOWN, NO UPDATE, UPDATE AVAILABLE, INSTALLING PROGMEM_STRING_TABLE(UpdateStateStrings, "UNKNOWN", "NO UPDATE", "UPDATE AVAILABLE", "INSTALLING"); diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp index 0400888511d..7b6717c682b 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp @@ -7,7 +7,7 @@ namespace esphome::uponor_smatrix { -static const char *const TAG = "uponor_smatrix.climate"; +ESPHOME_LOG_TAG(TAG, "uponor_smatrix.climate"); void UponorSmatrixClimate::dump_config() { LOG_CLIMATE("", "Uponor Smatrix Climate", this); diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp index 97e9c275700..9b5f7df87a2 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::uponor_smatrix { -static const char *const TAG = "uponor_smatrix.sensor"; +ESPHOME_LOG_TAG(TAG, "uponor_smatrix.sensor"); void UponorSmatrixSensor::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 74974548af9..fd6eda73a0c 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -7,7 +7,7 @@ namespace esphome::uponor_smatrix { -static const char *const TAG = "uponor_smatrix"; +ESPHOME_LOG_TAG(TAG, "uponor_smatrix"); // Maximum bytes to log in verbose hex output static constexpr size_t UPONOR_MAX_LOG_BYTES = 36; diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp b/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp index 552e4e5da42..c5dbf742a16 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::uptime { -static const char *const TAG = "uptime.sensor"; +ESPHOME_LOG_TAG(TAG, "uptime.sensor"); void UptimeSecondsSensor::update() { const uint64_t uptime = millis_64(); diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp index e8b8a21562b..43c8d1cf752 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::uptime { -static const char *const TAG = "uptime.sensor"; +ESPHOME_LOG_TAG(TAG, "uptime.sensor"); void UptimeTimestampSensor::setup() { this->time_->add_on_time_sync_callback([this]() { diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index 7a56654804b..33cde23b946 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::uptime { -static const char *const TAG = "uptime.sensor"; +ESPHOME_LOG_TAG(TAG, "uptime.sensor"); static void append_unit(char *buf, size_t buf_size, size_t &pos, const char *separator, unsigned value, const char *label) { diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index 454c049da3c..f24339b4bf3 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -6,7 +6,7 @@ #include "esphome/core/log.h" namespace esphome::usb_cdc_acm { -static const char *const TAG = "usb_cdc_acm"; +ESPHOME_LOG_TAG(TAG, "usb_cdc_acm"); // Global component instance for managing USB device USBCDCACMComponent *global_usb_cdc_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 7aa7b46b7b9..9ac94802e17 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -17,7 +17,7 @@ namespace esphome::usb_cdc_acm { -static const char *const TAG = "usb_cdc_acm"; +ESPHOME_LOG_TAG(TAG, "usb_cdc_acm"); // Maximum bytes to log in very verbose hex output (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t USB_CDC_MAX_LOG_BYTES = 168; diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index d8fb18b1b72..a5ad9817df7 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -8,7 +8,7 @@ namespace esphome::valve { -static const char *const TAG = "valve"; +ESPHOME_LOG_TAG(TAG, "valve"); const float VALVE_OPEN = 1.0f; const float VALVE_CLOSED = 0.0f; diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp index ddb6b530686..60822c52833 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::vbus { -static const char *const TAG = "vbus.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "vbus.binary_sensor"); void DeltaSolBSPlusBSensor::dump_config() { ESP_LOGCONFIG(TAG, "Deltasol BS Plus:"); diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 773e1435e29..5c111c22942 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::vbus { -static const char *const TAG = "vbus.sensor"; +ESPHOME_LOG_TAG(TAG, "vbus.sensor"); static inline uint16_t get_u16(std::vector &message, int start) { return (message[start + 1] << 8) + message[start]; diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index 080567e7f95..6b6a3393ee4 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -6,7 +6,7 @@ namespace esphome::vbus { -static const char *const TAG = "vbus"; +ESPHOME_LOG_TAG(TAG, "vbus"); // Maximum bytes to log in verbose hex output (16 frames * 4 bytes = 64 bytes typical) static constexpr size_t VBUS_MAX_LOG_BYTES = 64; diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index b3170469b91..3d555bc469a 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -4,7 +4,7 @@ namespace esphome::veml3235 { -static const char *const TAG = "veml3235.sensor"; +ESPHOME_LOG_TAG(TAG, "veml3235.sensor"); // ADC counts at or above this value (98% of full scale) are treated as clipped: the true light level cannot // be estimated from such a reading, so auto-gain restarts from minimum sensitivity instead diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 6c609f4fe7c..236b4bb2a98 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -5,7 +5,7 @@ namespace esphome::veml7700 { -static const char *const TAG = "veml7700"; +ESPHOME_LOG_TAG(TAG, "veml7700"); static const size_t VEML_REG_SIZE = 2; static float reduce_to_zero(float a, float b) { return (a > b) ? (a - b) : 0; } diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 15e6b0d0883..1be7920a4c3 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::version { -static const char *const TAG = "version.text_sensor"; +ESPHOME_LOG_TAG(TAG, "version.text_sensor"); void VersionTextSensor::setup() { static const char HASH_PREFIX[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 49eb3d00a10..68cb98b0534 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -14,7 +14,7 @@ namespace esphome::vl53l0x { -static const char *const TAG = "vl53l0x"; +ESPHOME_LOG_TAG(TAG, "vl53l0x"); std::list VL53L0XSensor::vl53_sensors; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool VL53L0XSensor::enable_pin_setup_complete = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index dba9b925d00..e9523084370 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -12,7 +12,7 @@ namespace esphome::voice_assistant { -static const char *const TAG = "voice_assistant"; +ESPHOME_LOG_TAG(TAG, "voice_assistant"); #ifdef SAMPLE_RATE_HZ #undef SAMPLE_RATE_HZ diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index fd94f56b755..52dfe98aad7 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -6,7 +6,7 @@ namespace esphome::wake_on_lan { -static const char *const TAG = "wake_on_lan.button"; +ESPHOME_LOG_TAG(TAG, "wake_on_lan.button"); static const uint8_t PREFIX[6] = {255, 255, 255, 255, 255, 255}; void WakeOnLanButton::set_macaddr(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e, uint8_t f) { diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index 2063faeb91e..61c0baf6863 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -16,7 +16,7 @@ namespace esphome::watchdog { -static const char *const TAG = "http_request.watchdog"; +ESPHOME_LOG_TAG(TAG, "http_request.watchdog"); WatchdogManager::WatchdogManager(uint32_t timeout_ms) : timeout_ms_(timeout_ms) { if (timeout_ms == 0) { diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 4b65e907de7..e5236a04916 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -11,7 +11,7 @@ namespace esphome::water_heater { -static const char *const TAG = "water_heater"; +ESPHOME_LOG_TAG(TAG, "water_heater"); void log_water_heater(const char *tag, const char *prefix, const char *type, WaterHeater *obj) { if (obj != nullptr) { diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp index 30458310cef..cef569b9058 100644 --- a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp @@ -4,7 +4,7 @@ namespace esphome::waveshare_io_ch32v003 { -static const char *const TAG = "waveshare_io_ch32v003.output"; +ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003.output"); void WaveshareIOCH32V003Output::write_state(float state) { uint8_t pwm_value = static_cast(state * 255.0f); diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp index 82da7451f94..71cb9ba79fd 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::waveshare_io_ch32v003 { -static const char *const TAG = "waveshare_io_ch32v003.sensor"; +ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003.sensor"); float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 8a58c7e7bb4..159390086f5 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -10,7 +10,7 @@ static const uint8_t IO_EXTENSION_PWM_ADDR = 0x05; static const uint8_t IO_EXTENSION_ADC_ADDR = 0x06; static const uint8_t IO_EXTENSION_RTC_INT_ADDR = 0x07; -static const char *const TAG = "waveshare_io_ch32v003"; +ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003"); void WaveshareIOCH32V003Component::setup() { this->mode_mask_ = 0xFF; // Set all pins to output mode diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95763e2daf0..3cf1d9802ef 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -23,7 +23,7 @@ using PlatformString = String; namespace esphome::web_server { -static const char *const TAG = "web_server.ota"; +ESPHOME_LOG_TAG(TAG, "web_server.ota"); class OTARequestHandler : public AsyncWebHandler { public: diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index fde86502d4a..062007b02e0 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -55,7 +55,7 @@ namespace esphome::web_server { -static const char *const TAG = "web_server"; +ESPHOME_LOG_TAG(TAG, "web_server"); // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index 7744272a5cd..3b0da4fb566 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -8,7 +8,7 @@ namespace esphome::web_server_idf { -static const char *const TAG = "multipart"; +ESPHOME_LOG_TAG(TAG, "multipart"); // ========== MultipartReader Implementation ========== diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index a35ad652141..9a6e419e439 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -55,7 +55,7 @@ namespace esphome::web_server_idf { #define CRLF_STR "\r\n" #define CRLF_LEN (sizeof(CRLF_STR) - 1) -static const char *const TAG = "web_server_idf"; +ESPHOME_LOG_TAG(TAG, "web_server_idf"); // Only send_json_() may hold the JSON arena: every other frame in this file is capped below one // arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it. diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index b95d474fd38..2576856e2f6 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -8,7 +8,7 @@ namespace esphome::weikai { -static const char *const TAG = "weikai"; +ESPHOME_LOG_TAG(TAG, "weikai"); /// @brief measure the time elapsed between two calls /// @param last_time time of the previous call diff --git a/esphome/components/weikai_i2c/weikai_i2c.cpp b/esphome/components/weikai_i2c/weikai_i2c.cpp index af5bd7f313b..bafa6c4e03a 100644 --- a/esphome/components/weikai_i2c/weikai_i2c.cpp +++ b/esphome/components/weikai_i2c/weikai_i2c.cpp @@ -6,7 +6,7 @@ #include "weikai_i2c.h" namespace esphome::weikai_i2c { -static const char *const TAG = "weikai_i2c"; +ESPHOME_LOG_TAG(TAG, "weikai_i2c"); /// @brief Display a buffer in hexadecimal format (32 hex values / line). void print_buffer(const uint8_t *data, size_t length) { diff --git a/esphome/components/weikai_spi/weikai_spi.cpp b/esphome/components/weikai_spi/weikai_spi.cpp index 03bf66e07be..6a44fad6f3b 100644 --- a/esphome/components/weikai_spi/weikai_spi.cpp +++ b/esphome/components/weikai_spi/weikai_spi.cpp @@ -7,7 +7,7 @@ namespace esphome::weikai_spi { using namespace weikai; -static const char *const TAG = "weikai_spi"; +ESPHOME_LOG_TAG(TAG, "weikai_spi"); /// @brief measure the time elapsed between two calls /// @param last_time time of the previous call diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index f560917f41e..73fab843fc4 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -3,7 +3,7 @@ namespace esphome::whirlpool { -static const char *const TAG = "whirlpool.climate"; +ESPHOME_LOG_TAG(TAG, "whirlpool.climate"); const uint16_t WHIRLPOOL_HEADER_MARK = 9000; const uint16_t WHIRLPOOL_HEADER_SPACE = 4494; diff --git a/esphome/components/whynter/whynter.cpp b/esphome/components/whynter/whynter.cpp index f5f304aa17e..5aee703feb7 100644 --- a/esphome/components/whynter/whynter.cpp +++ b/esphome/components/whynter/whynter.cpp @@ -3,7 +3,7 @@ namespace esphome::whynter { -static const char *const TAG = "climate.whynter"; +ESPHOME_LOG_TAG(TAG, "climate.whynter"); const uint16_t BITS = 32; diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index df64cd48aa7..4f576e4c9d0 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -5,7 +5,7 @@ namespace esphome::wiegand { -static const char *const TAG = "wiegand"; +ESPHOME_LOG_TAG(TAG, "wiegand"); static const char *const KEYS = "0123456789*#"; void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fd982c0d312..ef8b22d2395 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -51,7 +51,7 @@ namespace esphome::wifi { -static const char *const TAG = "wifi"; +ESPHOME_LOG_TAG(TAG, "wifi"); // CompactString implementation CompactString::CompactString(const char *str, size_t len) { diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index aa1fa67b7bc..41b51460006 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -40,7 +40,7 @@ extern "C" { namespace esphome::wifi { -static const char *const TAG = "wifi_esp8266"; +ESPHOME_LOG_TAG(TAG, "wifi_esp8266"); enum class ESP8266WiFiSTAState : uint8_t { IDLE, // Not connecting diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c44ee5b1de9..3769e26831b 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -44,7 +44,7 @@ namespace esphome::wifi { -static const char *const TAG = "wifi_esp32"; +ESPHOME_LOG_TAG(TAG, "wifi_esp32"); static EventGroupHandle_t s_wifi_event_group; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 28b30628fdc..2a9e682440e 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -52,7 +52,7 @@ extern "C" int __wrap_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cf namespace esphome::wifi { -static const char *const TAG = "wifi_lt"; +ESPHOME_LOG_TAG(TAG, "wifi_lt"); // Thread-safe event handling for LibreTiny WiFi // diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 325bcf26529..5b05132f4f7 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -18,7 +18,7 @@ namespace esphome::wifi { -static const char *const TAG = "wifi_pico_w"; +ESPHOME_LOG_TAG(TAG, "wifi_pico_w"); // Check if STA is fully connected (WiFi joined + has IP address). // Do NOT use WiFi.status() or WiFi.connected() for this — in AP-only mode they diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 5d4e77eaad8..98b0cc48142 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -9,7 +9,7 @@ namespace esphome::wifi_info { -static const char *const TAG = "wifi_info"; +ESPHOME_LOG_TAG(TAG, "wifi_info"); #ifdef USE_WIFI_IP_STATE_LISTENERS diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.cpp b/esphome/components/wifi_signal/wifi_signal_sensor.cpp index 11d816a9097..57cbf9ae9b6 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.cpp +++ b/esphome/components/wifi_signal/wifi_signal_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::wifi_signal { -static const char *const TAG = "wifi_signal.sensor"; +ESPHOME_LOG_TAG(TAG, "wifi_signal.sensor"); void WiFiSignalSensor::dump_config() { LOG_SENSOR("", "WiFi Signal", this); } diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index 1fd610f4e88..73e34b6592e 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -14,7 +14,7 @@ namespace esphome::wireguard { -static const char *const TAG = "wireguard"; +ESPHOME_LOG_TAG(TAG, "wireguard"); /* * Cannot use `static const char*` for LOGMSG_PEER_STATUS on esp8266 platform diff --git a/esphome/components/wl_134/wl_134.cpp b/esphome/components/wl_134/wl_134.cpp index 858f974f2b5..745f9ea3e62 100644 --- a/esphome/components/wl_134/wl_134.cpp +++ b/esphome/components/wl_134/wl_134.cpp @@ -6,7 +6,7 @@ namespace esphome::wl_134 { -static const char *const TAG = "wl_134.sensor"; +ESPHOME_LOG_TAG(TAG, "wl_134.sensor"); static const uint8_t ASCII_CR = 0x0D; static const uint8_t ASCII_NBSP = 0xFF; static const int MAX_DATA_LENGTH_BYTES = 6; diff --git a/esphome/components/wled/wled_light_effect.cpp b/esphome/components/wled/wled_light_effect.cpp index 5150cda2a51..da0accc9a95 100644 --- a/esphome/components/wled/wled_light_effect.cpp +++ b/esphome/components/wled/wled_light_effect.cpp @@ -25,7 +25,7 @@ enum Protocol { WLED_NOTIFIER = 0, WARLS = 1, DRGB = 2, DRGBW = 3, DNRGB = 4 }; constexpr uint32_t DEFAULT_BLANK_TIME = 1000; -static const char *const TAG = "wled_light_effect"; +ESPHOME_LOG_TAG(TAG, "wled_light_effect"); WLEDLightEffect::WLEDLightEffect(const char *name) : AddressableLightEffect(name) {} diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index cc7ee980790..2888fc0abb3 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -9,7 +9,7 @@ constexpr uint8_t HEADER_2 = 0x01; constexpr uint8_t HEADER_3 = 0x01; constexpr uint8_t HEADER_4 = 0x04; -static const char *const TAG = "wts01"; +ESPHOME_LOG_TAG(TAG, "wts01"); void WTS01Sensor::loop() { // Process all available data at once diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index b0ad79e51cd..a9e9e9cdae9 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -3,7 +3,7 @@ namespace esphome::x9c { -static const char *const TAG = "x9c.output"; +ESPHOME_LOG_TAG(TAG, "x9c.output"); void X9cOutput::trim_value(int32_t change_amount) { if (change_amount == 0) { diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 6deab23828d..5539a1318fd 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -4,7 +4,7 @@ namespace esphome::xdb401 { -static const char *const TAG = "xdb401"; +ESPHOME_LOG_TAG(TAG, "xdb401"); static const uint8_t REG_PRESSURE = 0x06; static const uint8_t REG_TEMPERATURE = 0x09; diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 0eefd594d09..35e1ed950b8 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -8,7 +8,7 @@ namespace esphome::xgzp68xx { -static const char *const TAG = "xgzp68xx.sensor"; +ESPHOME_LOG_TAG(TAG, "xgzp68xx.sensor"); static const uint8_t CMD_ADDRESS = 0x30; static const uint8_t SYSCONFIG_ADDRESS = 0xA5; diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 06c3a7ab7a0..57f26b6acc7 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -21,7 +21,7 @@ namespace esphome::xiaomi_ble { -static const char *const TAG = "xiaomi_ble"; +ESPHOME_LOG_TAG(TAG, "xiaomi_ble"); // Maximum bytes to log in very verbose hex output (covers largest packet of ~24 bytes) static constexpr size_t XIAOMI_MAX_LOG_BYTES = 32; diff --git a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp index 7f9cd17d1f1..b59c662e806 100644 --- a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp +++ b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp @@ -7,7 +7,7 @@ namespace esphome::xiaomi_body_scale { -static const char *const TAG = "xiaomi_body_scale"; +ESPHOME_LOG_TAG(TAG, "xiaomi_body_scale"); // Encrypted MiBeacon frame without a MAC: frame control, device id, frame count, cipher, counter, tag static constexpr size_t DEVICE_ID_POS = 2; diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp index 0159314f4d6..d64e66e660c 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgd1 { -static const char *const TAG = "xiaomi_cgd1"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgd1"); static constexpr size_t CGD1_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp index 01912c37782..7ac6d50e167 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgdk2 { -static const char *const TAG = "xiaomi_cgdk2"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgdk2"); static constexpr size_t CGDK2_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp index 679cb76198b..64207868b74 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgg1 { -static const char *const TAG = "xiaomi_cgg1"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgg1"); static constexpr size_t CGG1_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp index 019de54ad86..1515e43dea0 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgpr1 { -static const char *const TAG = "xiaomi_cgpr1"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgpr1"); void XiaomiCGPR1::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi CGPR1"); diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp index 27effd64bb1..11cb3a98d31 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_gcls002 { -static const char *const TAG = "xiaomi_gcls002"; +ESPHOME_LOG_TAG(TAG, "xiaomi_gcls002"); void XiaomiGCLS002::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi GCLS002"); diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp index 5e2369a6d95..80de9a6cbc8 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_hhccjcy01 { -static const char *const TAG = "xiaomi_hhccjcy01"; +ESPHOME_LOG_TAG(TAG, "xiaomi_hhccjcy01"); void XiaomiHHCCJCY01::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi HHCCJCY01"); diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index 680eb04e777..ac1541c386d 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_hhccjcy10 { -static const char *const TAG = "xiaomi_hhccjcy10"; +ESPHOME_LOG_TAG(TAG, "xiaomi_hhccjcy10"); void XiaomiHHCCJCY10::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi HHCCJCY10"); diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index fc8d15228d1..f502cb1f660 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_hhccpot002 { -static const char *const TAG = "xiaomi_hhccpot002"; +ESPHOME_LOG_TAG(TAG, "xiaomi_hhccpot002"); void XiaomiHHCCPOT002 ::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi HHCCPOT002"); diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp index f7a1318d7c3..ad2383f107b 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_jqjcy01ym { -static const char *const TAG = "xiaomi_jqjcy01ym"; +ESPHOME_LOG_TAG(TAG, "xiaomi_jqjcy01ym"); void XiaomiJQJCY01YM::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi JQJCY01YM"); diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp index d465f2fec08..0762d97eb09 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_lywsd02 { -static const char *const TAG = "xiaomi_lywsd02"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsd02"); void XiaomiLYWSD02::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi LYWSD02"); diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp index dca5f73909b..15d9f32bd55 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_lywsd02mmc { -static const char *const TAG = "xiaomi_lywsd02mmc"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsd02mmc"); static constexpr size_t LYWSD02MMC_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index 356a4ffd4e1..3bf8b7328b1 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_lywsd03mmc { -static const char *const TAG = "xiaomi_lywsd03mmc"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsd03mmc"); static constexpr size_t LYWSD03MMC_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp index 1ddf7ec235f..1c56ed34f62 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_lywsdcgq { -static const char *const TAG = "xiaomi_lywsdcgq"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsdcgq"); void XiaomiLYWSDCGQ::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi LYWSDCGQ"); diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp index 88b9865e476..519358270d3 100644 --- a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp @@ -7,7 +7,7 @@ namespace esphome::xiaomi_mccgq02hl { -static const char *const TAG = "xiaomi_mccgq02hl"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mccgq02hl"); static constexpr uint16_t PRODUCT_ID = 0x098b; diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp index 9706e508618..23f20f380af 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_mhoc303 { -static const char *const TAG = "xiaomi_mhoc303"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mhoc303"); void XiaomiMHOC303::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MHOC303"); diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index d7259784187..97266c86e75 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_mhoc401 { -static const char *const TAG = "xiaomi_mhoc401"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mhoc401"); static constexpr size_t MHOC401_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 482c0ed3958..438b9b55298 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_miscale { -static const char *const TAG = "xiaomi_miscale"; +ESPHOME_LOG_TAG(TAG, "xiaomi_miscale"); void XiaomiMiscale::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi Miscale"); diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp index 233f5f57832..767bcd8da04 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_mjyd02yla { -static const char *const TAG = "xiaomi_mjyd02yla"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mjyd02yla"); void XiaomiMJYD02YLA::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MJYD02YL-A"); diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index eca83c09124..afd8715ec5a 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_mue4094rt { -static const char *const TAG = "xiaomi_mue4094rt"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mue4094rt"); void XiaomiMUE4094RT::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MUE4094RT"); diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index f349dfa7971..8bf098a676f 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_rtcgq02lm { -static const char *const TAG = "xiaomi_rtcgq02lm"; +ESPHOME_LOG_TAG(TAG, "xiaomi_rtcgq02lm"); static constexpr size_t RTCGQ02LM_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp index ae37d630964..420b67e7883 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_wx08zm { -static const char *const TAG = "xiaomi_wx08zm"; +ESPHOME_LOG_TAG(TAG, "xiaomi_wx08zm"); void XiaomiWX08ZM::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi WX08ZM"); diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index aba954fd915..65ab9e342db 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_xmwsdj04mmc { -static const char *const TAG = "xiaomi_xmwsdj04mmc"; +ESPHOME_LOG_TAG(TAG, "xiaomi_xmwsdj04mmc"); static constexpr size_t XMWSDJ04MMC_BINDKEY_SIZE = 16; diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index d189d9b5c71..228274b3211 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -3,7 +3,7 @@ namespace esphome::xl9535 { -static const char *const TAG = "xl9535"; +ESPHOME_LOG_TAG(TAG, "xl9535"); void XL9535Component::setup() { // Check to see if the device can read from the register diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index 83a73320054..afb8f6cd5c8 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -13,7 +13,7 @@ static constexpr uint8_t XPT_READ_Y = 0x90; static constexpr uint8_t XPT_ADC_ON = 0x01; static constexpr uint8_t XPT_VREF_ON = 0x02; -static const char *const TAG = "xpt2046"; +ESPHOME_LOG_TAG(TAG, "xpt2046"); void XPT2046Component::setup() { if (this->irq_pin_ != nullptr) { diff --git a/esphome/components/yashima/yashima.cpp b/esphome/components/yashima/yashima.cpp index ba0a3a64040..986237b2d69 100644 --- a/esphome/components/yashima/yashima.cpp +++ b/esphome/components/yashima/yashima.cpp @@ -3,7 +3,7 @@ namespace esphome::yashima { -static const char *const TAG = "yashima.climate"; +ESPHOME_LOG_TAG(TAG, "yashima.climate"); const uint16_t YASHIMA_STATE_LENGTH = 9; const uint16_t YASHIMA_BITS = YASHIMA_STATE_LENGTH * 8; diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 23da2cafac7..4400f0a0c5c 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -7,7 +7,7 @@ namespace esphome { namespace zephyr { -static const char *const TAG = "zephyr"; +ESPHOME_LOG_TAG(TAG, "zephyr"); static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) { gpio_flags_t ret = 0; diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index ed22613625f..6be5f1ce4a5 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -10,7 +10,7 @@ namespace esphome::zephyr { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); bool ZephyrPreferenceBackend::save(const uint8_t *data, size_t len) { this->data.resize(len); diff --git a/esphome/components/zephyr/reset_reason.cpp b/esphome/components/zephyr/reset_reason.cpp index 24b32196db5..f3f126537df 100644 --- a/esphome/components/zephyr/reset_reason.cpp +++ b/esphome/components/zephyr/reset_reason.cpp @@ -7,7 +7,7 @@ namespace esphome::zephyr { -static const char *const TAG = "zephyr"; +ESPHOME_LOG_TAG(TAG, "zephyr"); static size_t append_reset_reason(char *buf, size_t size, size_t pos, bool set, const char *reason) { if (!set) { diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index 15993abcce4..dd6ecd3b20d 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -7,7 +7,7 @@ namespace esphome::zephyr_ble_server { -static const char *const TAG = "zephyr_ble_server"; +ESPHOME_LOG_TAG(TAG, "zephyr_ble_server"); static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp b/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp index f1eac462bc3..af126a29c7a 100644 --- a/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp +++ b/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp @@ -27,7 +27,7 @@ static_assert(sizeof(struct img_mgmt_upload_req) == 8, "ABI mismatch"); static_assert(offsetof(struct img_mgmt_upload_req, image) == 0, "ABI mismatch"); static_assert(offsetof(struct img_mgmt_upload_req, off) == 4, "ABI mismatch"); -static const char *const TAG = "zephyr_mcumgr"; +ESPHOME_LOG_TAG(TAG, "zephyr_mcumgr"); static OTAComponent *global_ota_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static enum mgmt_cb_return mcumgr_img_mgmt_cb(uint32_t event, enum mgmt_cb_return prev_status, int32_t *rc, diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.cpp b/esphome/components/zephyr_pwm/zephyr_pwm.cpp index aa1393388ab..71d54e8e48c 100644 --- a/esphome/components/zephyr_pwm/zephyr_pwm.cpp +++ b/esphome/components/zephyr_pwm/zephyr_pwm.cpp @@ -6,7 +6,7 @@ namespace esphome::zephyr_pwm { -static const char *const TAG = "zephyr_pwm"; +ESPHOME_LOG_TAG(TAG, "zephyr_pwm"); void ZephyrPWMChannel::setup() { if (!device_is_ready(this->device_)) { diff --git a/esphome/components/zhlt01/zhlt01.cpp b/esphome/components/zhlt01/zhlt01.cpp index 2585ac1b572..afca7bf8603 100644 --- a/esphome/components/zhlt01/zhlt01.cpp +++ b/esphome/components/zhlt01/zhlt01.cpp @@ -3,7 +3,7 @@ namespace esphome::zhlt01 { -static const char *const TAG = "zhlt01.climate"; +ESPHOME_LOG_TAG(TAG, "zhlt01.climate"); void ZHLT01Climate::transmit_state() { uint8_t ir_message[12] = {0}; diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp index 5567295782f..a6a25ef2f3e 100644 --- a/esphome/components/zigbee/time/zigbee_time_esp32.cpp +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -5,7 +5,7 @@ namespace esphome::zigbee { -static const char *const TAG = "zigbee.time"; +ESPHOME_LOG_TAG(TAG, "zigbee.time"); // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 3f14d0a62d2..f0ef3f2af2c 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -5,7 +5,7 @@ namespace esphome::zigbee { -static const char *const TAG = "zigbee.time"; +ESPHOME_LOG_TAG(TAG, "zigbee.time"); // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index acf0321b12b..b73c26a8361 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::zigbee { -static const char *const TAG = "zigbee.attribute"; +ESPHOME_LOG_TAG(TAG, "zigbee.attribute"); void ZigbeeAttribute::set_attr_() { if (!this->zb_->is_started()) { diff --git a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp index 464cc04d620..911159d091d 100644 --- a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp @@ -10,7 +10,7 @@ extern "C" { } namespace esphome::zigbee { -static const char *const TAG = "zigbee.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "zigbee.binary_sensor"); ZigbeeBinarySensor::ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor) : binary_sensor_(binary_sensor) {} diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index c6597ebfd69..676407d751c 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -17,7 +17,7 @@ namespace esphome::zigbee { -static const char *const TAG = "zigbee"; +ESPHOME_LOG_TAG(TAG, "zigbee"); static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/zigbee/zigbee_number_zephyr.cpp b/esphome/components/zigbee/zigbee_number_zephyr.cpp index ceb318480cb..49b03d3ec9d 100644 --- a/esphome/components/zigbee/zigbee_number_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_number_zephyr.cpp @@ -10,7 +10,7 @@ extern "C" { } namespace esphome::zigbee { -static const char *const TAG = "zigbee.number"; +ESPHOME_LOG_TAG(TAG, "zigbee.number"); void ZigbeeNumber::setup() { this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); diff --git a/esphome/components/zigbee/zigbee_sensor_zephyr.cpp b/esphome/components/zigbee/zigbee_sensor_zephyr.cpp index 25e1e083e00..9e16cb6a0c5 100644 --- a/esphome/components/zigbee/zigbee_sensor_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_sensor_zephyr.cpp @@ -10,7 +10,7 @@ extern "C" { } namespace esphome::zigbee { -static const char *const TAG = "zigbee.sensor"; +ESPHOME_LOG_TAG(TAG, "zigbee.sensor"); ZigbeeSensor::ZigbeeSensor(sensor::Sensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.cpp b/esphome/components/zigbee/zigbee_switch_zephyr.cpp index 935140e9df0..286a036fc6e 100644 --- a/esphome/components/zigbee/zigbee_switch_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_switch_zephyr.cpp @@ -13,7 +13,7 @@ extern "C" { namespace esphome::zigbee { -static const char *const TAG = "zigbee_on_off.switch"; +ESPHOME_LOG_TAG(TAG, "zigbee_on_off.switch"); void ZigbeeSwitch::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 286c83b8f56..f1eadbe6f06 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -16,7 +16,7 @@ extern "C" { namespace esphome::zigbee { -static const char *const TAG = "zigbee"; +ESPHOME_LOG_TAG(TAG, "zigbee"); ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp b/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp index 1fa995b3dcd..e03f2c5d31a 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp @@ -5,7 +5,7 @@ namespace esphome::zio_ultrasonic { -static const char *const TAG = "zio_ultrasonic"; +ESPHOME_LOG_TAG(TAG, "zio_ultrasonic"); void ZioUltrasonicComponent::dump_config() { ESP_LOGCONFIG(TAG, "Zio Ultrasonic Sensor:"); diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index b0d18a3e6a7..bfa16f857a0 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -12,7 +12,7 @@ namespace esphome::zwave_proxy { -static const char *const TAG = "zwave_proxy"; +ESPHOME_LOG_TAG(TAG, "zwave_proxy"); // Maximum bytes to log in very verbose hex output (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; diff --git a/esphome/components/zyaura/zyaura.cpp b/esphome/components/zyaura/zyaura.cpp index 0b834de90a4..5f203b18939 100644 --- a/esphome/components/zyaura/zyaura.cpp +++ b/esphome/components/zyaura/zyaura.cpp @@ -3,7 +3,7 @@ namespace esphome::zyaura { -static const char *const TAG = "zyaura"; +ESPHOME_LOG_TAG(TAG, "zyaura"); bool IRAM_ATTR ZaDataProcessor::decode(uint32_t ms, bool data) { // check if a new message has started, based on time since previous bit diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 701765bb419..a143a850326 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3546,7 +3546,7 @@ namespace esphome::api { namespace esphome::api { -static const char *const TAG = "api.service"; +ESPHOME_LOG_TAG(TAG, "api.service"); """ diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 91c1de00cda..d8f692de63a 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -456,7 +456,10 @@ def fix_lvgl_widgets(): def get_logger_tags(): - pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) + pattern = re.compile( + r'^(?:static const char \*const TAG = "|ESPHOME_LOG_TAG\(TAG, ")(\w[^"]*)"', + re.MULTILINE, + ) # tags not in components dir tags = [ "app", diff --git a/script/ci-custom.py b/script/ci-custom.py index 692393b221e..d41d5b4302a 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1237,6 +1237,45 @@ def lint_no_std_nothrow(fname, match): ) +@lint_re_check( + r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" + r"\s*(?:=\s*\{?|[{(])\s*\"", + prefilter="TAG", + include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + # Deprecated components, converted in a follow-up PR + exclude=[ + "esphome/components/st7735/*", + "esphome/components/st7789v/*", + "esphome/components/waveshare_epaper/*", + ], +) +def lint_log_tag_macro(fname, match): + return ( + f"Declare log tags with {highlight('ESPHOME_LOG_TAG(TAG, "name");')}, which keeps the tag in flash on " + f"ESP8266.\n" + f" Before: {highlight('static const char *const TAG = "name";')}\n" + f" After: {highlight('ESPHOME_LOG_TAG(TAG, "name");')}\n" + f"Never pass such a TAG to set_timeout/set_interval names or string functions.\n" + f"(If strictly necessary, add `// NOLINT` to the end of the line)" + ) + + +@lint_re_check( + r"(?:\b(?:set_timeout|set_interval|set_retry|cancel_timeout|cancel_interval|cancel_retry|defer)" + r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*TAG\b" + r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", + mask=True, + prefilter="TAG", + include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], +) +def lint_log_tag_as_string(fname, match): + return ( + f"{highlight('TAG')} is in flash on ESP8266 (ESPHOME_LOG_TAG), so it can only be used for logging.\n" + f"Use a separate name or a numeric id for scheduler calls, and do not pass it to string functions.\n" + f"(If strictly necessary, add `// NOLINT` to the end of the line)" + ) + + LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(") # Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? : # inside them are never seen. A char literal is exactly one (escaped) char so a digit separator diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 5a943cda083..4459631293a 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -386,3 +386,59 @@ def test_ternary_error_message_names_the_literal() -> None: ) assert len(errs) == 1 assert 'LOG_STR_LITERAL("enabled")' in errs[0][2] + + +@pytest.mark.parametrize( + "line", + [ + 'static const char *const TAG = "x";', + 'static const char* const TAG = "x";', + 'static const char *TAG = "x";', + 'constexpr const char *TAG = "x";', + 'static const char *const TAG{"x"};', + 'static constexpr char TAG[] = "x";', + 'static constexpr char TAG[] = {"x"};', + 'static const char TAG[8] = "x";', + ' static const char *const TAG = "x";', + ], +) +def test_log_tag_macro_flags_plain_tags(line: str) -> None: + assert ci_custom.lint_log_tag_macro("test.cpp", line + "\n") + + +@pytest.mark.parametrize( + "line", + ['ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";'], +) +def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: + assert not ci_custom.lint_log_tag_macro("test.cpp", line + "\n") + + +@pytest.mark.parametrize( + "line", + [ + "this->set_timeout(TAG, 100, cb);", + "strcmp(TAG, name);", + "strcmp(name, TAG);", + "strncpy(buf, TAG, sizeof(buf));", + "strcmp(get_name(), TAG);", + "this->set_timeout(make_id(x), TAG, cb);", + "std::string(TAG);", + "std::string name = TAG;", + "std::string name{TAG};", + ], +) +def test_log_tag_as_string_flags_string_uses(line: str) -> None: + assert ci_custom.lint_log_tag_as_string("test.cpp", line + "\n") + + +@pytest.mark.parametrize( + "line", + [ + 'ESP_LOGD(TAG, "x");', + 'this->set_timeout(100, [this]() { ESP_LOGD(TAG, "x"); });', + "strcmp(name, OTHER_TAG);", + ], +) +def test_log_tag_as_string_ignores_logging(line: str) -> None: + assert not ci_custom.lint_log_tag_as_string("test.cpp", line + "\n") From 4b2f8122e37bd53f8bb28716987609f2d9601e7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 07:31:09 -1000 Subject: [PATCH 059/435] [udp] Keep constant write payloads in shared flash tables (#20268) --- esphome/components/udp/__init__.py | 15 ++------ esphome/components/udp/automation.h | 34 ++++--------------- .../component_tests/udp/config/udp_write.yaml | 22 ++++++++++++ tests/component_tests/udp/test_udp_write.py | 17 ++++++++++ 4 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 tests/component_tests/udp/config/udp_write.yaml create mode 100644 tests/component_tests/udp/test_udp_write.py diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 316d4fc311e..cd3cb44ae14 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -184,16 +184,7 @@ async def udp_write_to_code( udp_var = await cg.get_variable(config[CONF_ID]) await cg.register_parented(var, udp_var) cg.add(udp_var.set_should_broadcast()) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static, "udp_data" + ) return var diff --git a/esphome/components/udp/automation.h b/esphome/components/udp/automation.h index 9b3ba7e0ddb..a6a40829b11 100644 --- a/esphome/components/udp/automation.h +++ b/esphome/components/udp/automation.h @@ -4,39 +4,19 @@ #ifdef USE_NETWORK #include "esphome/core/automation.h" -#include - namespace esphome::udp { -template class UDPWriteAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } +/// Constant payloads up to this size are sent from the stack on ESP8266. +static constexpr size_t STACK_PAYLOAD_SIZE = 128; - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } +template class UDPWriteAction final : public Action, public Parented { + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->len_ >= 0) { - // Static mode: pass pointer directly to send_packet(const uint8_t *, size_t) - this->parent_->send_packet(this->data_.data, static_cast(this->len_)); - } else { - // Template mode: call function and pass vector to send_packet(const std::vector &) - auto val = this->data_.func(x...); - this->parent_->send_packet(val); - } + // One write per packet: WiFiUDP appends all or nothing, so a failed allocation never sends a partial packet + this->data_.template visit( + [this](const uint8_t *data, size_t len) { this->parent_->send_packet(data, len); }, x...); } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; }; } // namespace esphome::udp diff --git a/tests/component_tests/udp/config/udp_write.yaml b/tests/component_tests/udp/config/udp_write.yaml new file mode 100644 index 00000000000..645879bfcd4 --- /dev/null +++ b/tests/component_tests/udp/config/udp_write.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: password1 + +udp: + addresses: + - 10.0.0.1 + +button: + - platform: template + name: send + on_press: + - udp.write: [0xAA, 0x55, 0x01, 0x02] + - udp.write: [0xAA, 0x55, 0x01, 0x02] + - udp.write: "" + - udp.write: !lambda return {0x10, 0x20}; diff --git a/tests/component_tests/udp/test_udp_write.py b/tests/component_tests/udp/test_udp_write.py new file mode 100644 index 00000000000..d999891d958 --- /dev/null +++ b/tests/component_tests/udp/test_udp_write.py @@ -0,0 +1,17 @@ +"""Tests for the udp.write action codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_write_payloads_share_a_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Identical constant payloads share one flash table; an empty one needs none.""" + main_cpp = generate_main(component_config_path("udp_write.yaml")) + + assert main_cpp.count("udp_data[] PROGMEM = {0xAA, 0x55, 0x01, 0x02};") == 1 + assert main_cpp.count("->set_data_static(udp_data, 4);") == 2 + assert "->set_data_static(nullptr, 0);" in main_cpp + assert "->set_data_template(" in main_cpp From 9c79fa7430ac2139ff0a560bf9434a3e25558fc7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 13:36:12 -0400 Subject: [PATCH 060/435] [sendspin] Add the dynamic pairing code (#20349) --- esphome/components/sendspin/__init__.py | 46 +++++++++++++- esphome/components/sendspin/sendspin_hub.cpp | 33 ++++++++-- esphome/components/sendspin/sendspin_hub.h | 21 +++++++ .../sendspin/text_sensor/__init__.py | 43 ++++++++++--- .../text_sensor/sendspin_text_sensor.cpp | 15 ++++- .../text_sensor/sendspin_text_sensor.h | 13 +++- .../component_tests/sendspin/test_pairing.py | 20 ++++++- .../sendspin/test_text_sensor.py | 60 +++++++++++++++++++ .../sendspin/test_unpaired_access.py | 5 +- tests/components/sendspin/common-hub.yaml | 11 ++++ .../sendspin/common-text_sensor-pairing.yaml | 17 ++++++ .../sendspin/common-text_sensor.yaml | 4 ++ .../test-text_sensor-pairing.esp32-idf.yaml | 2 + 13 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/sendspin/test_text_sensor.py create mode 100644 tests/components/sendspin/common-text_sensor-pairing.yaml create mode 100644 tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index fcc53dde4ba..5e104fe58aa 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -54,6 +54,8 @@ CONF_STATIC_PAIRING_CODE = "static_pairing_code" CONF_UNPAIRED_ACCESS = "unpaired_access" CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_DISPLAY_PAIRING_CODE = "on_display_pairing_code" +CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code" CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" CONF_ON_PAIRING_FAILED = "on_pairing_failed" @@ -144,6 +146,14 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" ), + automation.CallbackAutomation( + CONF_ON_DISPLAY_PAIRING_CODE, + "add_on_display_pairing_code_callback", + [(cg.std_string, "code")], + ), + automation.CallbackAutomation( + CONF_ON_CLEAR_PAIRING_CODE, "add_on_clear_pairing_code_callback" + ), automation.CallbackAutomation( CONF_ON_PAIRING_SUCCEEDED, "add_on_pairing_succeeded_callback", @@ -164,6 +174,7 @@ class SendspinConfiguration: metadata_support: bool = False player_support: bool = False visualizer_support: bool = False + pairing_code_display_support: bool = False artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -200,6 +211,11 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def request_pairing_code_display_support() -> None: + """Mark that the device can emit a dynamic pairing code (e.g. a pairing_code text sensor).""" + _get_data().pairing_code_display_support = True + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -266,6 +282,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( {} ), + cv.Optional(CONF_ON_DISPLAY_PAIRING_CODE): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}), cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } @@ -277,16 +297,33 @@ CONFIG_SCHEMA = cv.All( ) +def _offers_dynamic_pairing_code(config: ConfigType) -> bool: + """Whether the device can show a dynamic pairing code, so dynamic_pairing_code is advertised.""" + return bool( + config.get(CONF_ON_DISPLAY_PAIRING_CODE) + or _get_data().pairing_code_display_support + ) + + def _has_pairing_method(config: ConfigType) -> bool: """Whether the config gives a server any way to pair with the device.""" - return CONF_STATIC_PAIRING_CODE in config + return CONF_STATIC_PAIRING_CODE in config or _offers_dynamic_pairing_code(config) def _final_validate(config: ConfigType) -> ConfigType: + dynamic_code = _offers_dynamic_pairing_code(config) + # The protocol allows only one pairing code method. + if dynamic_code and CONF_STATIC_PAIRING_CODE in config: + raise cv.Invalid( + f"'{CONF_STATIC_PAIRING_CODE}' cannot be used with a dynamic pairing code " + f"({CONF_ON_DISPLAY_PAIRING_CODE} or a pairing_code text sensor), since only " + "one pairing code method can be offered", + path=[CONF_STATIC_PAIRING_CODE], + ) if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): _LOGGER.warning( - "'%s' is off but nothing lets a server pair (%s), so no server can play on this " - "device", + "'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no " + "server can play on this device", CONF_UNPAIRED_ACCESS, CONF_STATIC_PAIRING_CODE, ) @@ -366,6 +403,9 @@ async def to_code(config: ConfigType) -> None: if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: cg.add(var.set_default_unpaired_access(unpaired_access)) + if _offers_dynamic_pairing_code(config): + cg.add(var.set_pairing_code_display_supported(True)) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) # sendspin-cpp library diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 12617e00818..33fdb78f108 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -202,12 +202,12 @@ void SendspinHub::dump_config() { " Model: %s\n" " Firmware version: %s\n" " Task stack in PSRAM: %s\n" - " Static pairing code: %s\n" - " Unpaired access: %s", + " Unpaired access: %s\n" + " Pairing code method: %s", client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), this->firmware_version_, YESNO(this->task_stack_in_psram_), - YESNO(this->static_pairing_code_ != nullptr), - YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); + YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()), + this->pairing_code_method_()); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -311,6 +311,17 @@ const char *SendspinHub::get_mac_address_into_buffer(std::spanpairing_code_display_supported_) { + return LOG_STR_LITERAL("dynamic"); + } + if (this->static_pairing_code_ != nullptr) { + return LOG_STR_LITERAL("static"); + } + return LOG_STR_LITERAL("none"); +} + const char *SendspinHub::get_product_name_() const { return this->model_ != nullptr ? this->model_ : App.get_name().c_str(); } @@ -328,6 +339,13 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { config.protocol_task_psram_stack = this->task_stack_in_psram_; config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + // The dynamic code needs a channel and a format. Only digits, since automations get the bare string and could not + // tell a QR code token apart. + if (this->pairing_code_display_supported_) { + config.pairing_code_out_channels = {sendspin::SendspinPairingCodeChannel::DISPLAY}; + config.pairing_code_formats = {sendspin::SendspinPairingCodeFormat::DIGITS}; + } + if (this->static_pairing_code_ != nullptr) { config.static_pairing_code_locations = {"operator"}; config.static_pairing_code = this->static_pairing_code_; @@ -365,6 +383,13 @@ void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } +// Only digits are offered, and the library refuses an activation in any other format. +void SendspinHub::on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat /*format*/) { + this->display_pairing_code_callbacks_.call(code); +} + +void SendspinHub::on_clear_pairing_code() { this->clear_pairing_code_callbacks_.call(); } + void SendspinHub::on_pairing_succeeded(const std::string &server_id) { this->pairing_succeeded_callbacks_.call(server_id); } diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 7c3708c374d..d33269048e8 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -146,6 +146,14 @@ class SendspinHub final : public Component, this->close_pairing_window_callbacks_.add(std::forward(callback)); } + template void add_on_display_pairing_code_callback(F &&callback) { + this->display_pairing_code_callbacks_.add(std::forward(callback)); + } + + template void add_on_clear_pairing_code_callback(F &&callback) { + this->clear_pairing_code_callbacks_.add(std::forward(callback)); + } + template void add_on_pairing_succeeded_callback(F &&callback) { this->pairing_succeeded_callbacks_.add(std::forward(callback)); } @@ -186,6 +194,10 @@ class SendspinHub final : public Component, /// The static pairing code, used on every boot. void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + /// Set when on_display_pairing_code or a pairing_code text sensor is configured, so the device offers the dynamic + /// pairing code. + void set_pairing_code_display_supported(bool supported) { this->pairing_code_display_supported_ = supported; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -258,6 +270,8 @@ class SendspinHub final : public Component, /// @brief Returns the product name reported to the server: the configured model, or the device name. const char *get_product_name_() const; + const char *pairing_code_method_() const; + /// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_mac_address_into_buffer(std::span buf); @@ -278,6 +292,10 @@ class SendspinHub final : public Component, void on_close_pairing_window() override; + void on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat format) override; + + void on_clear_pairing_code() override; + void on_pairing_succeeded(const std::string &server_id) override; void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; @@ -357,10 +375,13 @@ class SendspinHub final : public Component, // Lazy: each pairing callback is fed by an optional YAML surface. LazyCallbackManager open_pairing_window_callbacks_{}; LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager display_pairing_code_callbacks_{}; + LazyCallbackManager clear_pairing_code_callbacks_{}; LazyCallbackManager pairing_succeeded_callbacks_{}; LazyCallbackManager pairing_failed_callbacks_{}; const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + bool pairing_code_display_supported_{false}; bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index 87f6c9b9362..24aa2d57346 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -4,16 +4,29 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TYPE from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + SendspinHub, + request_metadata_support, + request_pairing_code_display_support, + sendspin_ns, +) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] +CONF_PAIRING_CODE = "pairing_code" + SendspinTextSensor = sendspin_ns.class_( "SendspinTextSensor", text_sensor.TextSensor, cg.Component, ) +SendspinPairingCodeTextSensor = sendspin_ns.class_( + "SendspinPairingCodeTextSensor", + text_sensor.TextSensor, + cg.Component, +) SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) SENDSPIN_TEXT_METADATA_TYPES = { @@ -26,18 +39,29 @@ SENDSPIN_TEXT_METADATA_TYPES = { def _request_roles(config: ConfigType) -> ConfigType: """Request the necessary Sendspin roles for the text sensor.""" - request_metadata_support() + if config[CONF_TYPE] == CONF_PAIRING_CODE: + # A display surface for the dynamic code, so advertise dynamic_pairing_code. + request_pairing_code_display_support() + else: + request_metadata_support() return config +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) +_METADATA_SCHEMA = text_sensor.text_sensor_schema(SendspinTextSensor).extend( + _HUB_ID_SCHEMA +) + CONFIG_SCHEMA = cv.All( - text_sensor.text_sensor_schema().extend( + cv.typed_schema( { - cv.GenerateID(): cv.declare_id(SendspinTextSensor), - cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), - } + **dict.fromkeys(SENDSPIN_TEXT_METADATA_TYPES, _METADATA_SCHEMA), + CONF_PAIRING_CODE: text_sensor.text_sensor_schema( + SendspinPairingCodeTextSensor + ).extend(_HUB_ID_SCHEMA), + }, + key=CONF_TYPE, ), cv.only_on_esp32, _request_roles, @@ -50,4 +74,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_parented(var, config[CONF_SENDSPIN_ID]) await text_sensor.register_text_sensor(var, config) - cg.add(var.set_metadata_type(config[CONF_TYPE])) + if ( + metadata_type := SENDSPIN_TEXT_METADATA_TYPES.get(config[CONF_TYPE]) + ) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index cda987f2e3b..629396a681e 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -1,8 +1,10 @@ #include "sendspin_text_sensor.h" -#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) +#ifdef USE_SENDSPIN_METADATA #include +#endif #include @@ -10,6 +12,7 @@ namespace esphome::sendspin_ { ESPHOME_LOG_TAG(TAG, "sendspin.text_sensor"); +#ifdef USE_SENDSPIN_METADATA void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } // A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to @@ -50,6 +53,16 @@ void SendspinTextSensor::publish_if_changed_(const char *value) { this->publish_state(value); } } +#endif // USE_SENDSPIN_METADATA + +void SendspinPairingCodeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Pairing Code", this); } + +// THREAD CONTEXT: Main loop. The registered callbacks also fire on the main loop (SendspinHub dispatches them from +// its loop()). +void SendspinPairingCodeTextSensor::setup() { + this->parent_->add_on_display_pairing_code_callback([this](const std::string &code) { this->publish_state(code); }); + this->parent_->add_on_clear_pairing_code_callback([this]() { this->publish_state(""); }); +} } // namespace esphome::sendspin_ diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index 9fe1c9381ef..cc25ab93c7c 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -2,15 +2,19 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" +#ifdef USE_SENDSPIN_METADATA #include +#endif namespace esphome::sendspin_ { +#ifdef USE_SENDSPIN_METADATA + enum class SendspinTextMetadataTypes { TITLE, ARTIST, @@ -31,6 +35,13 @@ class SendspinTextSensor final : public SendspinChild, public text_sensor::TextS SendspinTextMetadataTypes metadata_type_; }; +#endif // USE_SENDSPIN_METADATA + +class SendspinPairingCodeTextSensor final : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; +}; } // namespace esphome::sendspin_ #endif diff --git a/tests/component_tests/sendspin/test_pairing.py b/tests/component_tests/sendspin/test_pairing.py index dcfce8cece8..79612ccff5b 100644 --- a/tests/component_tests/sendspin/test_pairing.py +++ b/tests/component_tests/sendspin/test_pairing.py @@ -9,7 +9,12 @@ from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.sendspin import CONF_STATIC_PAIRING_CODE, CONFIG_SCHEMA +from esphome.components.sendspin import ( + CONF_STATIC_PAIRING_CODE, + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + request_pairing_code_display_support, +) from esphome.const import PlatformFramework from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -75,3 +80,16 @@ def test_unquoted_static_pairing_code_rejected( with pytest.raises(cv.Invalid): CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567)) + + +def test_static_code_with_dynamic_code_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Only one pairing code method can be offered, so a static code next to the dynamic + code would be silently ignored.""" + set_core_config(PlatformFramework.ESP32_IDF) + request_pairing_code_display_support() + config = CONFIG_SCHEMA(_hub_config(static_pairing_code="01234567")) + + with pytest.raises(cv.Invalid, match="cannot be used with a dynamic pairing code"): + FINAL_VALIDATE_SCHEMA(config) diff --git a/tests/component_tests/sendspin/test_text_sensor.py b/tests/component_tests/sendspin/test_text_sensor.py new file mode 100644 index 00000000000..6dc073a59ce --- /dev/null +++ b/tests/component_tests/sendspin/test_text_sensor.py @@ -0,0 +1,60 @@ +"""Validation tests for the sendspin text sensor platform. + +These cover behavior a compile test cannot observe: which roles a sensor type requests. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import _get_data +from esphome.components.sendspin.text_sensor import ( + CONF_PAIRING_CODE, + CONFIG_SCHEMA, + SENDSPIN_TEXT_METADATA_TYPES, +) +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _sensor_config(**overrides: Any) -> ConfigType: + """Build a minimal valid text sensor config, allowing field overrides.""" + config: ConfigType = {"name": "Sendspin Text", "type": "title"} + config.update(overrides) + return config + + +@pytest.mark.parametrize("sensor_type", sorted(SENDSPIN_TEXT_METADATA_TYPES)) +def test_metadata_types_request_metadata_role( + set_core_config: SetCoreConfigCallable, sensor_type: str +) -> None: + """Metadata sensors need the metadata role, and say nothing about pairing codes.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_sensor_config(type=sensor_type)) + + assert _get_data().metadata_support is True + assert _get_data().pairing_code_display_support is False + + +def test_pairing_code_requests_code_display( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pairing_code sensor is a way to show the dynamic code, so it alone makes the + hub offer it. It needs no role, so a pairing-only config builds without metadata.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_sensor_config(type=CONF_PAIRING_CODE)) + + assert _get_data().pairing_code_display_support is True + assert _get_data().metadata_support is False + + +def test_unknown_type_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A misspelled type must fail rather than fall through to a default.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_sensor_config(type="pairing_secret")) diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py index a66de471ab5..80bf5a24178 100644 --- a/tests/component_tests/sendspin/test_unpaired_access.py +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -8,6 +8,7 @@ from esphome.components.sendspin import ( CONF_UNPAIRED_ACCESS, CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, + request_pairing_code_display_support, ) from esphome.const import PlatformFramework from tests.component_tests.types import SetCoreConfigCallable @@ -45,7 +46,7 @@ def test_off_without_pairing_method_warns( assert NO_PAIRING_WARNING in caplog.text -@pytest.mark.parametrize("method", ["static_pairing_code"]) +@pytest.mark.parametrize("method", ["static_pairing_code", "pairing_code"]) def test_off_with_pairing_method_does_not_warn( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture, @@ -55,6 +56,8 @@ def test_off_with_pairing_method_does_not_warn( hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False} if method == "static_pairing_code": hub_config["static_pairing_code"] = "01234567" + else: + request_pairing_code_display_support() config = HUB_CONFIG_SCHEMA(hub_config) with caplog.at_level(logging.WARNING): diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index 147227b2f1d..d89d16856dd 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -7,6 +7,9 @@ sendspin: manufacturer: Test Manufacturer model: Test Model firmware_version: 1.2.3 + # No static_pairing_code here: the on_display_pairing_code automation below makes + # this a dynamic-pairing-code device, and validation rejects both together. + # common-pairing.yaml covers the static-code build. on_open_pairing_window: then: - logger.log: "Pairing window requested" @@ -16,6 +19,14 @@ sendspin: then: - logger.log: "Pairing window prompt dismissed" - sendspin.cancel_pairing_window: + on_display_pairing_code: + then: + - logger.log: + format: "Pairing code: %s" + args: ["code.c_str()"] + on_clear_pairing_code: + then: + - logger.log: "Pairing code cleared" on_pairing_succeeded: then: - logger.log: diff --git a/tests/components/sendspin/common-text_sensor-pairing.yaml b/tests/components/sendspin/common-text_sensor-pairing.yaml new file mode 100644 index 00000000000..c2d92eb453d --- /dev/null +++ b/tests/components/sendspin/common-text_sensor-pairing.yaml @@ -0,0 +1,17 @@ +# The pairing code text sensor on its own, with no metadata text sensor to pull in the +# metadata role. This is the build where USE_SENDSPIN_METADATA is undefined while the text +# sensor platform is still compiled, which is guarded separately in sendspin_text_sensor.{h,cpp}. +# +# Deliberately does not include common.yaml: that file pulls in the shared hub package, and +# the point here is the narrower configuration. sendspin depends on network, so the interface +# has to be declared locally instead. +wifi: + ap: + +sendspin: + id: sendspin_hub_id + +text_sensor: + - platform: sendspin + name: "Pairing Code" + type: pairing_code diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index 23111e8d37c..087bbdb6b0e 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -14,3 +14,7 @@ text_sensor: - platform: sendspin name: "Album Artist" type: album_artist + - platform: sendspin + name: "Pairing Code" + type: pairing_code + entity_category: diagnostic diff --git a/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml new file mode 100644 index 00000000000..2336f877407 --- /dev/null +++ b/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-text_sensor-pairing.yaml From 920295527fa4f7e43eb7230db283f5c1839dff1a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 14:13:31 -0400 Subject: [PATCH 061/435] [sendspin] Add an unpaired access switch (#20350) --- esphome/components/sendspin/__init__.py | 21 +++- esphome/components/sendspin/sendspin_hub.cpp | 24 ++-- esphome/components/sendspin/sendspin_hub.h | 13 +- .../components/sendspin/switch/__init__.py | 88 ++++++++++++-- .../sendspin/switch/sendspin_switch.cpp | 33 ++++- .../sendspin/switch/sendspin_switch.h | 18 ++- .../sendspin/test_unpaired_access.py | 115 +++++++++++++++++- tests/components/sendspin/common-pairing.yaml | 2 +- tests/components/sendspin/common-switch.yaml | 3 + 9 files changed, 274 insertions(+), 43 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 5e104fe58aa..ad9b22e3670 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -5,7 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32, mdns, network, psram, socket, wifi -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -175,6 +175,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False pairing_code_display_support: bool = False + switch_types: set[str] = field(default_factory=set) artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -216,6 +217,11 @@ def request_pairing_code_display_support() -> None: _get_data().pairing_code_display_support = True +def request_switch(switch_type: str) -> None: + """Mark that a sendspin switch of this type drives the matching hub setting.""" + _get_data().switch_types.add(switch_type) + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -275,6 +281,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( _validate_static_pairing_code ), + # No default: the unpaired access switch rejects this key when it is set. cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( {} @@ -322,8 +329,8 @@ def _final_validate(config: ConfigType) -> ConfigType: ) if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): _LOGGER.warning( - "'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no " - "server can play on this device", + "'%s' is off but there is no pairing method (%s or a dynamic pairing code), so no " + "new server can pair with this device", CONF_UNPAIRED_ACCESS, CONF_STATIC_PAIRING_CODE, ) @@ -400,8 +407,12 @@ async def to_code(config: ConfigType) -> None: if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: cg.add(var.set_static_pairing_code(code)) - if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: - cg.add(var.set_default_unpaired_access(unpaired_access)) + # The client starts once both are set: here, or by the switch that drives the setting. + switch_types = _get_data().switch_types + if CONF_ENABLED not in switch_types: + cg.add(var.set_enabled(True)) + if CONF_UNPAIRED_ACCESS not in switch_types: + cg.add(var.set_unpaired_access_enabled(config.get(CONF_UNPAIRED_ACCESS, True))) if _offers_dynamic_pairing_code(config): cg.add(var.set_pairing_code_display_supported(True)) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 33fdb78f108..8780966ffe1 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -154,16 +154,15 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); - -#ifndef USE_SENDSPIN_SWITCH - this->enabled_ = true; -#endif + // Set before setup() by codegen; an unpaired access switch sets it later, from its own setup(). + if (this->unpaired_access_.has_value()) { + this->client_->set_unpaired_access_enabled(*this->unpaired_access_); + } } void SendspinHub::loop() { - if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && - !this->status_has_error()) { + if (this->enabled_.has_value() && this->unpaired_access_.has_value() && + this->enabled_.value() != this->client_->is_started() && !this->status_has_error()) { if (!this->enabled_.value()) { this->client_->stop(); } else if (!this->client_->start()) { @@ -222,7 +221,7 @@ void SendspinHub::dump_config() { #endif } -// THREAD CONTEXT: Main loop (invoked from Sendspin components) +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_enabled(bool enabled) { if (this->status_has_error()) { ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", @@ -232,6 +231,15 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) +void SendspinHub::set_unpaired_access_enabled(bool enabled) { + this->unpaired_access_ = enabled; + // Before setup() there is no client yet; setup() applies the stored value. + if (this->client_ != nullptr) { + this->client_->set_unpaired_access_enabled(enabled); + } +} + #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE // THREAD CONTEXT: Main loop void SendspinHub::update_mdns_service_() { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index d33269048e8..33356f21f67 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -168,12 +168,13 @@ class SendspinHub final : public Component, /// stop. /// /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks - /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has - /// called this once. Must be called from the main loop thread. + /// fire from inside that call. The client stays stopped until this has been called once, by codegen or by the + /// enabled switch. Must be called from the main loop thread. void set_enabled(bool enabled); - /// Turns unpaired (Sentinel) access on or off from setup(); see SendspinClient::set_unpaired_access_enabled(). - void set_default_unpaired_access(bool enabled) { this->default_unpaired_access_ = enabled; } + /// Turns unpaired (Sentinel) access on or off. The client waits for this call, by codegen or by the unpaired + /// access switch, before its first start. May be called before setup(). Main loop only. + void set_unpaired_access_enabled(bool enabled); /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -382,14 +383,14 @@ class SendspinHub final : public Component, const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured bool pairing_code_display_supported_{false}; - bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE bool mdns_advertised_{false}; // Last state requested from mdns #endif - // Requested client state, applied from loop(). Empty until the switch restores its state. + // Requested client state, applied from loop(). The client does not start until both are set. std::optional enabled_; + std::optional unpaired_access_; // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 1f896640bfc..ae640e70a40 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,35 +1,101 @@ +import logging + import esphome.codegen as cg from esphome.components import switch from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_PLATFORM, CONF_SWITCH, CONF_TYPE, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + CONF_STATIC_PAIRING_CODE, + CONF_UNPAIRED_ACCESS, + DOMAIN, + SendspinHub, + _has_pairing_method, + request_switch, + sendspin_ns, +) + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] -SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) +SendspinEnabledSwitch = sendspin_ns.class_( + "SendspinEnabledSwitch", switch.Switch, cg.Component +) +SendspinUnpairedAccessSwitch = sendspin_ns.class_( + "SendspinUnpairedAccessSwitch", switch.Switch, cg.Component +) + + +def _switch_schema(class_: cg.MockObjClass) -> cv.Schema: + return ( + switch.switch_schema( + class_, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +def _request_switch(config: ConfigType) -> ConfigType: + """Tell the hub to wait for this switch before the client's first start.""" + request_switch(config[CONF_TYPE]) + return config + CONFIG_SCHEMA = cv.All( cv.typed_schema( { - CONF_ENABLED: switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + CONF_ENABLED: _switch_schema(SendspinEnabledSwitch), + CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch), }, key=CONF_TYPE, ), cv.only_on_esp32, + _request_switch, ) +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + switch_type = config[CONF_TYPE] + same_type = [ + conf + for conf in full_config.get(CONF_SWITCH, []) + if conf.get(CONF_PLATFORM) == DOMAIN and conf.get(CONF_TYPE) == switch_type + ] + # Two switches of one type would each drive the same hub setting. + if len(same_type) > 1: + raise cv.Invalid(f"Only one sendspin '{switch_type}' switch is allowed") + if switch_type != CONF_UNPAIRED_ACCESS: + return config + hub_config = full_config.get(DOMAIN, {}) + if CONF_UNPAIRED_ACCESS in hub_config: + raise cv.Invalid( + f"'{DOMAIN}: {CONF_UNPAIRED_ACCESS}' cannot be set together with an " + f"{CONF_UNPAIRED_ACCESS} switch; set the switch's restore_mode instead" + ) + if not _has_pairing_method(hub_config): + _LOGGER.warning( + "The %s switch has no pairing method (%s or a dynamic pairing code), so " + "while it is off no new server can pair with this device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index 343202e7f4b..51c151980a6 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,26 +1,49 @@ #include "sendspin_switch.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/core/log.h" +#include "esphome/core/preferences.h" namespace esphome::sendspin_ { ESPHOME_LOG_TAG(TAG, "sendspin.switch"); -void SendspinSwitch::setup() { +// --- SendspinEnabledSwitch --- + +void SendspinEnabledSwitch::setup() { // The hub waits for this request, so a restore mode without a state still has to answer. this->control(this->get_initial_state_with_restore_mode().value_or(true)); } -void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } +void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); } // THREAD CONTEXT: Main loop -void SendspinSwitch::write_state(bool state) { +void SendspinEnabledSwitch::write_state(bool state) { this->parent_->set_enabled(state); this->publish_state(state); } +// --- SendspinUnpairedAccessSwitch --- + +void SendspinUnpairedAccessSwitch::setup() { + // The hub holds the client's first start for this, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinUnpairedAccessSwitch::write_state(bool state) { + const bool turning_off = !state && this->has_state() && this->state; + this->parent_->set_unpaired_access_enabled(state); + this->publish_state(state); + // Write the change to flash now: losing it in a power cut would reopen unpaired access. + if (turning_off) { + global_preferences->sync(); + } +} + } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index f9173c362dd..4b04c95857b 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -10,7 +10,19 @@ namespace esphome::sendspin_ { /// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). -class SendspinSwitch final : public switch_::Switch, public SendspinChild { +class SendspinEnabledSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +/// @brief Switch that turns unpaired (Sentinel) access on and off through the hub (see +/// SendspinHub::set_unpaired_access_enabled()). The library does not persist the setting, so the switch's restore +/// mode does. +class SendspinUnpairedAccessSwitch final : public switch_::Switch, public SendspinChild { public: void setup() override; void dump_config() override; @@ -21,4 +33,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py index 80bf5a24178..8660ba9f7cf 100644 --- a/tests/component_tests/sendspin/test_unpaired_access.py +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -1,24 +1,42 @@ -"""Validation tests for the hub's unpaired access policy.""" +"""Validation tests for the unpaired access policy. + +The hub's `unpaired_access` key and an `unpaired_access` switch both set the +policy, so the switch platform rejects a config that has both. +""" import logging import pytest +from esphome import config_validation as cv from esphome.components.sendspin import ( CONF_UNPAIRED_ACCESS, CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + DOMAIN, FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, + _get_data, request_pairing_code_display_support, ) +from esphome.components.sendspin.switch import ( + CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as SWITCH_FINAL_VALIDATE_SCHEMA, +) from esphome.const import PlatformFramework +from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "sendspin_hub_id" -NO_PAIRING_WARNING = "nothing lets a server pair" +NO_PAIRING_WARNING = "no new server can pair with this device" + + +def _switch_config(switch_type: str) -> ConfigType: + return SWITCH_CONFIG_SCHEMA( + {"name": "Sendspin Switch", "type": switch_type, "sendspin_id": HUB_ID} + ) def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None: - """Codegen leaves the hub's built-in default of on in place when the key is not set.""" + """With no default in the schema, the switch can tell whether the key was set.""" set_core_config(PlatformFramework.ESP32_IDF) assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) @@ -36,7 +54,7 @@ def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) - def test_off_without_pairing_method_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture ) -> None: - """With unpaired access off and no way to pair, no server could ever play.""" + """With unpaired access off and no way to pair, no new server could pair.""" set_core_config(PlatformFramework.ESP32_IDF) config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) @@ -76,3 +94,92 @@ def test_on_does_not_warn( HUB_FINAL_VALIDATE_SCHEMA(config) assert NO_PAIRING_WARNING not in caplog.text + + +def test_switch_with_hub_key_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Both would set the policy, and the hub's value would be silently ignored.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with pytest.raises(cv.Invalid, match="set the switch's restore_mode instead"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +def test_switch_without_hub_key_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +def test_switch_without_pairing_method_warns( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """Turning the switch off would leave no way for a new server to pair.""" + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +def test_switch_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, "static_pairing_code": "01234567"}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable) -> None: + """Only the unpaired access switch competes with the hub key.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config("enabled") + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_duplicate_switch_type_rejected( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """Two switches of one type would fight over the same hub setting.""" + entry = {"platform": DOMAIN, "type": switch_type} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID}, "switch": [entry, dict(entry)]}, + ) + config = _switch_config(switch_type) + + with pytest.raises(cv.Invalid, match="Only one sendspin"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_switch_requests_its_type( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """The hub skips the codegen value for a setting a switch drives, so the client + waits for that switch to restore before its first start.""" + set_core_config(PlatformFramework.ESP32_IDF) + + _switch_config(switch_type) + + assert _get_data().switch_types == {switch_type} diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml index cd9cd82bec8..b201a5b850d 100644 --- a/tests/components/sendspin/common-pairing.yaml +++ b/tests/components/sendspin/common-pairing.yaml @@ -10,5 +10,5 @@ wifi: sendspin: id: sendspin_hub_id static_pairing_code: "01234567" - # Covers the hub's unpaired access option. + # Covers the hub's unpaired access option, which is only valid without an unpaired access switch. unpaired_access: false diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml index 01e2f0b02b1..dbf260286b2 100644 --- a/tests/components/sendspin/common-switch.yaml +++ b/tests/components/sendspin/common-switch.yaml @@ -5,3 +5,6 @@ switch: - platform: sendspin name: "Sendspin Enabled" type: enabled + - platform: sendspin + name: "Sendspin Unpaired Access" + type: unpaired_access From f9b5eca2b9766b25b4cebde79feda2bde897d3c1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:48:19 +0000 Subject: [PATCH 062/435] Bump aioesphomeapi from 46.8.0 to 46.9.0 (#20358) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8deafbad86f..b7df6ca4fc7 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.8.0 +aioesphomeapi==46.9.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From e395e626ecbb38ed901305b89e4d0c97e12f7df5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 15:12:20 -0400 Subject: [PATCH 063/435] [api][sendspin] Add a request for the Sendspin pairing token (#20355) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 39 ++++++++++++++ esphome/components/api/api_connection.cpp | 42 ++++++++++++++- esphome/components/api/api_connection.h | 7 +++ esphome/components/api/api_pb2.cpp | 35 ++++++++++++ esphome/components/api/api_pb2.h | 54 ++++++++++++++++++- esphome/components/api/api_pb2_dump.cpp | 39 ++++++++++++++ esphome/components/api/api_pb2_service.cpp | 9 ++++ esphome/components/api/api_pb2_service.h | 4 ++ esphome/components/sendspin/__init__.py | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 12 +++++ esphome/components/sendspin/sendspin_hub.h | 21 ++++++++ tests/components/sendspin/common-pairing.yaml | 5 ++ .../components/api/test_api_proto.py | 35 ++++++++++++ 13 files changed, 301 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0d2ac36937c..76b82810a4b 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -81,6 +81,8 @@ service APIConnection { rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} + + rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {} } @@ -398,6 +400,11 @@ message WizardCapabilities { bool configured = 1; } +message SendspinCapabilities { + // Bitmask of the Sendspin features this device supports + uint32 feature_flags = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -408,6 +415,7 @@ message DeviceCapabilitiesResponse { repeated SerialProxyInfo serial_proxies = 4 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; + SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"]; } // ==================== DEVICE WIZARD ==================== @@ -3069,3 +3077,34 @@ message BluetoothSetConnectionParamsResponse { uint64 address = 1; int32 error = 2; } + +// ==================== SENDSPIN ==================== +enum SendspinPairingTokenStatus { + // The Sendspin client has not started yet, so the token does not exist yet. Ask again later. + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0; + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1; + // The device has no API encryption key, so it will not send the token. Ask again once a key is set. + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2; + // Sendspin is turned off and has not started since boot, so the token does not exist yet. + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3; + // The Sendspin client failed to start, and will not start until the device reboots. + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4; +} + +// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device +message SendspinPairingTokenRequest { + option (id) = 159; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SENDSPIN"; + // Empty +} + +message SendspinPairingTokenResponse { + option (id) = 160; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SENDSPIN"; + + SendspinPairingTokenStatus status = 1; + // The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK. + string token = 2 [(max_data_length) = 127]; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 42ef3726672..940c8847edd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -48,6 +48,9 @@ #ifdef USE_ZWAVE_PROXY #include "esphome/components/zwave_proxy/zwave_proxy.h" #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +#include "esphome/components/sendspin/sendspin_hub.h" +#endif #ifdef USE_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif @@ -1402,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { } #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +bool APIConnection::send_sendspin_pairing_token_response_() { + SendspinPairingTokenResponse resp; +#ifdef USE_API_NOISE + // has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions + // never reach hello, and activating a key closes every session opened before it. + if (!this->parent_->get_noise_ctx().has_psk()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; + return this->send_message(resp); + } + auto token = sendspin_::global_sendspin_hub->get_pairing_token(); + if (token.has_value()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK; + resp.token = StringRef(*token); + } else if (sendspin_::global_sendspin_hub->status_has_error()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED; + } else if (sendspin_::global_sendspin_hub->is_disabled()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED; + } else { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY; + } +#else + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; +#endif + return this->send_message(resp); +} + +void APIConnection::on_sendspin_pairing_token_request() { + if (!this->send_sendspin_pairing_token_response_()) { + this->on_fatal_error(); + } +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE, @@ -1872,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 18; + resp.api_version_minor = 19; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -2088,6 +2125,9 @@ bool APIConnection::send_device_capabilities_response_() { #endif #ifdef USE_API_WIZARD resp.wizard.configured = true; +#endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index b7f0bda0ed7..9f784185b83 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + void on_sendspin_pairing_token_request(); +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg); @@ -432,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_VOICE_ASSISTANT bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + bool send_sendspin_pairing_token_response_(); +#endif #ifdef USE_CAMERA void try_send_camera_image_(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 646a8f4211e..b59663aeb51 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -306,6 +306,19 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_SENDSPIN +uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + return pos; +} +uint32_t SendspinCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.feature_flags); + return size; +} +#endif uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); #ifdef USE_BLUETOOTH_PROXY @@ -324,6 +337,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res #endif #ifdef USE_API_WIZARD pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard); +#endif +#ifdef USE_SENDSPIN + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin); #endif return pos; } @@ -346,6 +362,9 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #endif #ifdef USE_API_WIZARD size += ProtoSize::calc_message(1, msg.wizard.calculate_size()); +#endif +#ifdef USE_SENDSPIN + size += ProtoSize::calc_message(1, msg.sendspin.calculate_size()); #endif return size; } @@ -4229,6 +4248,22 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_SENDSPIN +uint8_t *SendspinPairingTokenResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.token); + return pos; +} +uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += msg.status ? 2 : 0; + size += !msg.token.empty() ? 2 + msg.token.size() : 0; + return size; +} +#endif #ifndef HAS_PROTO_MESSAGE_DUMP static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 9ad114b2ca1..421c3fccb38 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -383,6 +383,15 @@ enum SerialProxyIdentityFlag : uint32_t { SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1, SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2, }; +#ifdef USE_SENDSPIN +enum SendspinPairingTokenStatus : uint32_t { + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0, + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1, + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2, + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3, + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4, +}; +#endif } // namespace enums @@ -737,10 +746,27 @@ class WizardCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_SENDSPIN +class SendspinCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 119; + static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -758,6 +784,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_API_WIZARD WizardCapabilities wizard{}; +#endif +#ifdef USE_SENDSPIN + SendspinCapabilities sendspin{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -4138,5 +4167,28 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { protected: }; #endif +#ifdef USE_SENDSPIN +class SendspinPairingTokenResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 160; + static constexpr uint8_t ESTIMATED_SIZE = 11; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("sendspin_pairing_token_response"); } +#endif + enums::SendspinPairingTokenStatus status{}; + StringRef token{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 196a4249cc6..3c6f5302b55 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -928,6 +928,25 @@ template<> const char *proto_enum_to_string(enum return ESPHOME_PSTR("UNKNOWN"); } } +#ifdef USE_SENDSPIN +template<> +const char *proto_enum_to_string(enums::SendspinPairingTokenStatus value) { + switch (value) { + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} +#endif const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest")); @@ -1096,6 +1115,13 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_SENDSPIN +const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1124,6 +1150,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": "); this->wizard.dump_to(out); out.append("\n"); +#endif +#ifdef USE_SENDSPIN + out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": "); + this->sendspin.dump_to(out); + out.append("\n"); #endif return out.c_str(); } @@ -2944,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const return out.c_str(); } #endif +#ifdef USE_SENDSPIN +const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse")); + dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); + dump_field(out, ESPHOME_PSTR("token"), this->token); + return out.c_str(); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index fee775daa0e..683bb84c302 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -751,6 +751,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_wizard_input_set_request(msg); break; } +#endif +#ifdef USE_SENDSPIN + case 159 /* SendspinPairingTokenRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request")); +#endif + this->on_sendspin_pairing_token_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 13fc03e03e7..7f33e5668c7 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -252,6 +252,10 @@ class APIServerConnectionBase { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif + +#ifdef USE_SENDSPIN + void on_sendspin_pairing_token_request(){}; +#endif }; } // namespace esphome::api diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index ad9b22e3670..b587a3e0928 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -424,7 +424,7 @@ async def to_code(config: ConfigType) -> None: # esp_websocket_client links esp_tls even for ws:// connections. esp32.request_tls() - cg.add_define("USE_SENDSPIN", True) # for MDNS + cg.add_define("USE_SENDSPIN", True) # for MDNS and the native API # Service starts disabled and the hub enables it; always advertised where unsupported if mdns.request_service_enable_disable(): diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 8780966ffe1..8b5eaeeed23 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -113,6 +113,10 @@ static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"}; #endif +SendspinHub *global_sendspin_hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +SendspinHub::SendspinHub() { global_sendspin_hub = this; } + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -231,6 +235,14 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop +std::optional SendspinHub::get_pairing_token() const { + if (this->client_ == nullptr) { + return std::nullopt; + } + return this->client_->pairing_token(); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 33356f21f67..cd8d0d0afaf 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -62,6 +62,11 @@ inline constexpr float CHILD = HUB - 1.0f; /// Pairing-record slots, handed to the client as max_pairing_records. inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; +/// Feature flags reported to API clients in DeviceCapabilitiesResponse. +enum SendspinFeature : uint32_t { + SENDSPIN_FEATURE_PAIRING_TOKEN = 1 << 0, +}; + /// @brief Thin adapter over sendspin::SendspinClient. /// /// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for @@ -91,6 +96,8 @@ class SendspinHub final : public Component, public sendspin::SendspinNetworkProvider, public sendspin::SendspinPersistenceProvider { public: + SendspinHub(); + float get_setup_priority() const override { return sendspin_priority::HUB; } void setup() override; void loop() override; @@ -179,6 +186,18 @@ class SendspinHub final : public Component, /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + /// @brief Returns whether the client has been turned off with set_enabled(). + bool is_disabled() const { return this->enabled_.has_value() && !this->enabled_.value(); } + + /// @brief Returns the SendspinFeature flags reported to API clients. + uint32_t get_feature_flags() const { return SENDSPIN_FEATURE_PAIRING_TOKEN; } + + /// @brief Returns the pairing token a Sendspin server uses to pair with this device. + /// + /// The token is a long-lived secret, so this component never logs it. Returns std::nullopt until the client has + /// started once; it stays available after the client stops. Main loop only. + std::optional get_pairing_token() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the @@ -424,6 +443,8 @@ class SendspinPollingChild : public PollingComponent, public Parented None: assert "needs_authentication" not in body +def test_sendspin_pairing_token_messages_keep_their_wire_ids() -> None: + """Message ids are part of the wire protocol and must not change.""" + for message, expected in ( + ("SendspinPairingTokenRequest", 159), + ("SendspinPairingTokenResponse", 160), + ): + body = _extract_proto_message(PROTO_TEXT, message) + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, f"{message} is missing `option (id)`" + assert int(match.group(1)) == expected, ( + f"{message} has id {match.group(1)}, expected {expected}" + ) + + +def test_sendspin_pairing_token_response_fields_keep_their_wire_numbers() -> None: + """Clients decode the response purely by field number.""" + body = _extract_proto_message(PROTO_TEXT, "SendspinPairingTokenResponse") + for field_name, number in (("status", 1), ("token", 2)): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), ( + f"{field_name} in SendspinPairingTokenResponse is no longer field " + f"number {number}" + ) + + +def test_sendspin_pairing_token_rpc_requires_authentication() -> None: + """The pairing token is a long-lived secret, so the rpc must not set + `needs_authentication` and inherits the default of true. + """ + body = _extract_rpc_body(PROTO_TEXT, "sendspin_pairing_token") + assert "needs_authentication" not in body + + # ==================== Group C: advertised API version ==================== From bcfe6fd1d272c218ce77972306d0fdb28699c609 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:30:04 +0000 Subject: [PATCH 064/435] Bump prek from 0.5.4 to 0.5.5 (#20370) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index c81f2f83dc7..0f7d3762554 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -prek==0.5.4 # .github/workflows/ci.yml reads this pin +prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests From 06864917a5dfc329dedfcb1702dc39148689b2e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:31:14 +0000 Subject: [PATCH 065/435] Bump filelock from 4.0.10 to 4.0.12 (#20371) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b7df6ca4fc7..612bda6b1aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.3 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.10 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 542de8f03a25cd47da158bd87f73a6c6543449c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:36:01 -1000 Subject: [PATCH 066/435] Bump j178/prek-action from 3.0.0 to 3.0.1 (#20372) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eb96af59b6..371debaab1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,7 +286,7 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run prek - uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 + uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1 with: prek-version: ${{ steps.prek.outputs.version }} # This job only runs on pull requests, so nothing ever populates From 92bfd0ea7e0e6a9d112f3da74a5f1793ffbd9692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 11:18:21 -1000 Subject: [PATCH 067/435] [mdns] Bump espressif/mdns to 1.14.0 (#20383) --- esphome/components/mdns/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 0fb24fdf1df..760952e269d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -235,7 +235,7 @@ async def to_code(config: ConfigType) -> None: ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.12.0") + add_idf_component(name="espressif/mdns", ref="1.14.0") # ESPHome only advertises; the browse APIs are unused add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) # The mdns console CLI is never used by ESPHome diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index ed1202fdb96..3d9f1431f67 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -24,7 +24,7 @@ dependencies: espressif/esp32-camera: version: 2.1.7 espressif/mdns: - version: 1.12.0 + version: 1.14.0 espressif/esp_wifi_remote: version: 1.6.5 rules: From 85108bfd84562353463a1f5bea98d419c5c6ef3c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:58:20 +1300 Subject: [PATCH 068/435] [qspi_amoled] Remove legacy renamed component stub (#20396) --- esphome/components/qspi_amoled/display.py | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 esphome/components/qspi_amoled/display.py diff --git a/esphome/components/qspi_amoled/display.py b/esphome/components/qspi_amoled/display.py deleted file mode 100644 index 00773b516ad..00000000000 --- a/esphome/components/qspi_amoled/display.py +++ /dev/null @@ -1,3 +0,0 @@ -import esphome.config_validation as cv - -CONFIG_SCHEMA = cv.invalid("The qspi_amoled component has been renamed to qspi_dbi") From fe21e3ad169285fd2bc5fcae9e7ea8e754666bd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 13:14:00 -1000 Subject: [PATCH 069/435] [ci] Pin the shared I2C bus in test configs that left it implicit (#20412) --- tests/components/ags10/common.yaml | 1 + tests/components/bmi270/common.yaml | 1 + tests/components/ds248x/common.yaml | 4 ++++ tests/components/lsm6ds/common.yaml | 1 + tests/components/ltr501/common.yaml | 1 + tests/components/ltr_als_ps/common.yaml | 1 + 6 files changed, 9 insertions(+) diff --git a/tests/components/ags10/common.yaml b/tests/components/ags10/common.yaml index 8009c562955..8441729a8fa 100644 --- a/tests/components/ags10/common.yaml +++ b/tests/components/ags10/common.yaml @@ -1,6 +1,7 @@ sensor: - platform: ags10 id: ags10_1 + i2c_id: i2c_bus tvoc: name: AGS10 TVOC update_interval: 60s diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 0f9b70631ce..41f30fd5c61 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -61,6 +61,7 @@ sensor: motion: - platform: bmi270 id: bmi270_motion + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/ds248x/common.yaml b/tests/components/ds248x/common.yaml index 53ae56f20a1..4429f5b7729 100644 --- a/tests/components/ds248x/common.yaml +++ b/tests/components/ds248x/common.yaml @@ -5,10 +5,12 @@ # - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection ds248x: - id: ds2482_100 + i2c_id: i2c_bus address: 0x18 type: ds2482-100 active_pullup: true - id: ds2482_101 + i2c_id: i2c_bus address: 0x19 type: ds2482-101 active_pullup: true @@ -18,10 +20,12 @@ ds248x: bus_sleep: true hub_sleep: true - id: ds2482_800 + i2c_id: i2c_bus address: 0x1a type: ds2482-800 active_pullup: true - id: ds2484_hub + i2c_id: i2c_bus address: 0x1b type: ds2484 active_pullup: true diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml index aeacd314481..40bfbfc2a11 100644 --- a/tests/components/lsm6ds/common.yaml +++ b/tests/components/lsm6ds/common.yaml @@ -61,6 +61,7 @@ sensor: motion: - platform: lsm6ds id: lsm6ds_motion + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/ltr501/common.yaml b/tests/components/ltr501/common.yaml index 77c6f137397..406450f8c53 100644 --- a/tests/components/ltr501/common.yaml +++ b/tests/components/ltr501/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: ltr501 + i2c_id: i2c_bus address: 0x23 type: ALS_PS gain: 1X diff --git a/tests/components/ltr_als_ps/common.yaml b/tests/components/ltr_als_ps/common.yaml index edad4b2e4f7..6c555eeb740 100644 --- a/tests/components/ltr_als_ps/common.yaml +++ b/tests/components/ltr_als_ps/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: ltr_als_ps + i2c_id: i2c_bus address: 0x23 gain: 1x integration_time: 100ms From b4bd95b2acbcd063d138929e00cfa119ac14d88e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 13:48:03 -1000 Subject: [PATCH 070/435] [ci] Deal clang-tidy files across five shards (#20411) --- .github/workflows/ci.yml | 18 ++++++++++++------ script/clang-tidy | 38 +++++++++++++++++++++++++++++++++----- script/helpers.py | 2 +- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 371debaab1f..1cf59f073fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -893,14 +893,20 @@ jobs: matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 1/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 2/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 3/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 3 + name: Run script/clang-tidy for ESP32 IDF 3/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 3 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 4/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 4 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 5/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 5 steps: - name: Check out code from GitHub diff --git a/script/clang-tidy b/script/clang-tidy index ad6c99d637b..f97e9a482eb 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import heapq import os from pathlib import Path import queue @@ -11,6 +12,7 @@ import subprocess import sys import tempfile import threading +import time from typing import Any import click @@ -278,9 +280,12 @@ def clang_options(idedata, environment): pids = set() -def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): +def run_tidy( + executable, args, options, tmpdir, path_queue, lock, failed_files, timings +): while True: path = path_queue.get() + started = time.monotonic() invocation = [executable] if tmpdir is not None: @@ -319,8 +324,9 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): check=False, close_fds=False, ) - if proc.returncode != 0: - with lock: + with lock: + timings.append((time.monotonic() - started, path)) + if proc.returncode != 0: print_error_for_file(path, proc.stdout) failed_files.append(path) path_queue.task_done() @@ -333,8 +339,27 @@ def progress_bar_show(value): def split_list(a, n): - k, m = divmod(len(a), n) - return [a[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] + """Deal the sorted files out to n splits in turn. + + Contiguous slices put whole directories on one split; expensive + neighbours such as esphome/core then land together and that split takes + several times longer than the others. Dealing spreads them out. + """ + return [a[i::n] for i in range(n)] + + +SLOWEST_FILES_SHOWN = 15 + + +def print_slowest_files(timings): + """Print the files clang-tidy spent the longest on, so the splits can be + rebalanced from CI logs.""" + if not timings: + return + total = sum(seconds for seconds, _ in timings) + print(f"\nclang-tidy spent {total:.0f} s on {len(timings)} files; slowest:") + for seconds, path in heapq.nlargest(SLOWEST_FILES_SHOWN, timings): + print(f" {seconds:6.1f} s {path}") def main(): @@ -458,6 +483,7 @@ def main(): tmpdir = tempfile.mkdtemp() failed_files = [] + timings = [] try: executable = get_binary("clang-tidy", 22) task_queue = queue.Queue(args.jobs) @@ -473,6 +499,7 @@ def main(): task_queue, lock, failed_files, + timings, ), ) t.daemon = True @@ -487,6 +514,7 @@ def main(): # Wait for all threads to be done. task_queue.join() + print_slowest_files(timings) except FileNotFoundError: return 1 diff --git a/script/helpers.py b/script/helpers.py index fb2c964bdd9..11605fcef0e 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1616,7 +1616,7 @@ def lpt_partition( Heaviest item first into the lightest group. Ties keep input order, so pass pre-sorted items for deterministic output. script/clang-tidy's - split_list is the unweighted contiguous sibling. + split_list is the unweighted sibling that deals files out in turn. """ groups: list[list[str]] = [[] for _ in range(count)] group_weights = [0.0] * count From e03db60a528376b298f7a4d3dd59280c6d28ff7c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:04:26 +1300 Subject: [PATCH 071/435] [core] Add a DOMAIN constant to every component (#20395) --- AGENTS.md | 5 ++ CODEOWNERS | 1 + esphome/components/a01nyub/__init__.py | 1 + esphome/components/a02yyuw/__init__.py | 1 + esphome/components/a4988/__init__.py | 1 + .../components/absolute_humidity/__init__.py | 1 + esphome/components/ac_dimmer/__init__.py | 1 + esphome/components/adalight/__init__.py | 1 + esphome/components/adc/__init__.py | 1 + esphome/components/adc128s102/__init__.py | 1 + .../components/addressable_light/__init__.py | 1 + esphome/components/ade7880/__init__.py | 1 + esphome/components/ade7953/__init__.py | 1 + esphome/components/ade7953_base/__init__.py | 1 + esphome/components/ade7953_i2c/__init__.py | 1 + esphome/components/ade7953_spi/__init__.py | 1 + esphome/components/ads1115/__init__.py | 1 + esphome/components/ads1118/__init__.py | 1 + esphome/components/ags10/__init__.py | 1 + esphome/components/aht10/__init__.py | 1 + esphome/components/aic3204/__init__.py | 1 + esphome/components/airthings_ble/__init__.py | 1 + .../airthings_wave_base/__init__.py | 1 + .../airthings_wave_mini/__init__.py | 1 + .../airthings_wave_plus/__init__.py | 1 + .../alarm_control_panel/__init__.py | 1 + esphome/components/alpha3/__init__.py | 1 + esphome/components/am2315c/__init__.py | 1 + esphome/components/am2320/__init__.py | 1 + esphome/components/am43/__init__.py | 1 + .../components/analog_threshold/__init__.py | 1 + esphome/components/anova/__init__.py | 1 + esphome/components/apds9306/__init__.py | 1 + esphome/components/apds9960/__init__.py | 1 + esphome/components/aqi/__init__.py | 1 + esphome/components/as3935/__init__.py | 1 + esphome/components/as3935_i2c/__init__.py | 1 + esphome/components/as3935_spi/__init__.py | 1 + esphome/components/as5600/__init__.py | 1 + esphome/components/as7341/__init__.py | 1 + esphome/components/async_tcp/__init__.py | 1 + esphome/components/at581x/__init__.py | 1 + .../components/atc_mithermometer/__init__.py | 1 + esphome/components/atm90e26/__init__.py | 1 + esphome/components/atm90e32/__init__.py | 1 + esphome/components/audio_adc/__init__.py | 1 + esphome/components/audio_dac/__init__.py | 1 + esphome/components/audio_http/__init__.py | 1 + esphome/components/axs15231/__init__.py | 1 + esphome/components/b_parasite/__init__.py | 1 + esphome/components/ballu/__init__.py | 1 + esphome/components/bang_bang/__init__.py | 1 + esphome/components/bedjet/__init__.py | 1 + .../beken_spi_led_strip/__init__.py | 1 + esphome/components/bh1750/__init__.py | 1 + esphome/components/bh1900nux/__init__.py | 1 + esphome/components/binary/__init__.py | 2 + esphome/components/binary_sensor/__init__.py | 1 + .../components/binary_sensor_map/__init__.py | 1 + esphome/components/bk72xx/__init__.py | 1 + esphome/components/bk72xx_ble/__init__.py | 1 + .../components/bk72xx_ble_tracker/__init__.py | 1 + esphome/components/bl0906/__init__.py | 1 + esphome/components/bl0939/__init__.py | 1 + esphome/components/bl0940/__init__.py | 1 + esphome/components/bl0942/__init__.py | 1 + esphome/components/ble_client/__init__.py | 1 + .../components/ble_device_base/__init__.py | 1 + esphome/components/ble_nus/__init__.py | 1 + esphome/components/ble_presence/__init__.py | 1 + esphome/components/ble_rssi/__init__.py | 1 + esphome/components/ble_scanner/__init__.py | 1 + .../components/bluetooth_proxy/__init__.py | 1 + esphome/components/bm8563/__init__.py | 1 + esphome/components/bme280_base/__init__.py | 1 + esphome/components/bme280_i2c/__init__.py | 1 + esphome/components/bme280_spi/__init__.py | 1 + esphome/components/bme680/__init__.py | 1 + esphome/components/bme680_bsec/__init__.py | 1 + .../components/bme68x_bsec2_i2c/__init__.py | 1 + esphome/components/bmi160/__init__.py | 1 + esphome/components/bmi270/__init__.py | 1 + esphome/components/bmp085/__init__.py | 1 + esphome/components/bmp280/__init__.py | 1 + esphome/components/bmp280_base/__init__.py | 1 + esphome/components/bmp280_i2c/__init__.py | 1 + esphome/components/bmp280_spi/__init__.py | 1 + esphome/components/bmp3xx/__init__.py | 1 + esphome/components/bmp3xx_base/__init__.py | 1 + esphome/components/bmp3xx_i2c/__init__.py | 1 + esphome/components/bmp3xx_spi/__init__.py | 1 + esphome/components/bmp581/__init__.py | 1 + esphome/components/bmp581_base/__init__.py | 1 + esphome/components/bmp581_i2c/__init__.py | 1 + esphome/components/bmp581_spi/__init__.py | 1 + esphome/components/bp1658cj/__init__.py | 1 + esphome/components/bp5758d/__init__.py | 1 + .../bthome_mithermometer/__init__.py | 1 + esphome/components/button/__init__.py | 1 + esphome/components/bytebuffer/__init__.py | 1 + esphome/components/camera/__init__.py | 1 + esphome/components/camera_encoder/__init__.py | 1 + esphome/components/canbus/__init__.py | 1 + esphome/components/cap1188/__init__.py | 1 + esphome/components/captive_portal/__init__.py | 1 + esphome/components/cc1101/__init__.py | 1 + esphome/components/ccs811/__init__.py | 1 + esphome/components/cd74hc4067/__init__.py | 1 + esphome/components/cdc_acm_uart/__init__.py | 1 + esphome/components/ch422g/__init__.py | 1 + esphome/components/ch423/__init__.py | 1 + esphome/components/chsc6x/__init__.py | 1 + esphome/components/climate/__init__.py | 1 + esphome/components/climate_ir/__init__.py | 1 + esphome/components/climate_ir_lg/__init__.py | 1 + esphome/components/cm1106/__init__.py | 2 + esphome/components/color/__init__.py | 1 + .../components/color_temperature/__init__.py | 1 + esphome/components/combination/__init__.py | 1 + esphome/components/const/__init__.py | 1 + esphome/components/coolix/__init__.py | 1 + esphome/components/copy/__init__.py | 1 + esphome/components/counter/__init__.py | 1 + esphome/components/cover/__init__.py | 1 + esphome/components/cs5460a/__init__.py | 1 + esphome/components/cse7761/__init__.py | 1 + esphome/components/cse7766/__init__.py | 1 + esphome/components/cst226/__init__.py | 1 + esphome/components/cst328/__init__.py | 1 + esphome/components/cst816/__init__.py | 1 + esphome/components/cst9220/__init__.py | 1 + esphome/components/ct_clamp/__init__.py | 1 + esphome/components/current_based/__init__.py | 1 + esphome/components/custom/__init__.py | 1 + .../components/custom_component/__init__.py | 1 + esphome/components/cwww/__init__.py | 1 + esphome/components/d01/__init__.py | 1 + esphome/components/dac7678/__init__.py | 1 + esphome/components/daikin/__init__.py | 1 + esphome/components/daikin_arc/__init__.py | 1 + esphome/components/daikin_brc/__init__.py | 1 + esphome/components/dallas/__init__.py | 1 + esphome/components/dallas_temp/__init__.py | 1 + esphome/components/daly_bms/__init__.py | 1 + .../components/dashboard_import/__init__.py | 1 + esphome/components/datetime/__init__.py | 1 + esphome/components/debug/__init__.py | 1 + esphome/components/deep_sleep/__init__.py | 1 + esphome/components/delonghi/__init__.py | 1 + esphome/components/demo/__init__.py | 1 + esphome/components/dew_point/__init__.py | 1 + esphome/components/dfplayer/__init__.py | 1 + .../components/dfrobot_sen0395/__init__.py | 1 + esphome/components/dht/__init__.py | 1 + esphome/components/dht12/__init__.py | 1 + .../components/display_menu_base/__init__.py | 1 + esphome/components/dlms_meter/__init__.py | 1 + esphome/components/dps310/__init__.py | 1 + esphome/components/ds1307/__init__.py | 1 + esphome/components/ds1603l/__init__.py | 1 + esphome/components/ds2484/__init__.py | 1 + esphome/components/ds248x/__init__.py | 1 + esphome/components/dsmr/__init__.py | 1 + esphome/components/duty_cycle/__init__.py | 1 + esphome/components/duty_time/__init__.py | 1 + esphome/components/e131/__init__.py | 1 + esphome/components/ee895/__init__.py | 1 + esphome/components/ektf2232/__init__.py | 1 + esphome/components/emc2101/__init__.py | 1 + esphome/components/emmeti/__init__.py | 1 + esphome/components/endstop/__init__.py | 1 + esphome/components/ens160/__init__.py | 1 + esphome/components/ens160_base/__init__.py | 1 + esphome/components/ens160_i2c/__init__.py | 1 + esphome/components/ens160_spi/__init__.py | 1 + esphome/components/ens210/__init__.py | 1 + esphome/components/epaper_spi/__init__.py | 1 + esphome/components/es7210/__init__.py | 1 + esphome/components/es7243e/__init__.py | 1 + esphome/components/es8156/__init__.py | 1 + esphome/components/es8311/__init__.py | 1 + esphome/components/es8388/__init__.py | 1 + esphome/components/esp32/__init__.py | 1 + .../components/esp32_ble_beacon/__init__.py | 1 + .../components/esp32_ble_client/__init__.py | 1 + esphome/components/esp32_camera/__init__.py | 1 + .../esp32_camera_web_server/__init__.py | 1 + esphome/components/esp32_can/__init__.py | 1 + esphome/components/esp32_dac/__init__.py | 1 + esphome/components/esp32_hall/__init__.py | 1 + esphome/components/esp32_hosted/__init__.py | 1 + esphome/components/esp32_rmt/__init__.py | 1 + .../esp32_rmt_led_strip/__init__.py | 1 + esphome/components/esp32_touch/__init__.py | 1 + esphome/components/esp8266/__init__.py | 1 + esphome/components/esp8266_pwm/__init__.py | 1 + esphome/components/esphome/__init__.py | 1 + esphome/components/espnow/__init__.py | 1 + esphome/components/ethernet/__init__.py | 1 + esphome/components/ethernet_info/__init__.py | 1 + esphome/components/event/__init__.py | 1 + .../exponential_moving_average/__init__.py | 1 + .../exposure_notifications/__init__.py | 1 + .../external_components/__init__.py | 3 +- esphome/components/ezo/__init__.py | 1 + esphome/components/ezo_pmp/__init__.py | 1 + esphome/components/factory_reset/__init__.py | 1 + esphome/components/fan/__init__.py | 1 + esphome/components/fastled_base/__init__.py | 1 + .../components/fastled_clockless/__init__.py | 1 + esphome/components/fastled_spi/__init__.py | 1 + esphome/components/feedback/__init__.py | 1 + esphome/components/file/__init__.py | 1 + .../components/fingerprint_grow/__init__.py | 1 + esphome/components/fs3000/__init__.py | 1 + esphome/components/ft5x06/__init__.py | 1 + esphome/components/ft63x6/__init__.py | 1 + .../components/fujitsu_general/__init__.py | 1 + esphome/components/gcja5/__init__.py | 1 + esphome/components/gdk101/__init__.py | 1 + esphome/components/gl_r01_i2c/__init__.py | 1 + esphome/components/globals/__init__.py | 1 + esphome/components/gp2y1010au0f/__init__.py | 1 + esphome/components/gp8403/__init__.py | 1 + esphome/components/gpio/__init__.py | 1 + esphome/components/gpio_expander/__init__.py | 2 + esphome/components/gps/__init__.py | 1 + esphome/components/graph/__init__.py | 1 + .../graphical_display_menu/__init__.py | 1 + esphome/components/gree/__init__.py | 2 + .../components/grove_gas_mc_v2/__init__.py | 1 + .../components/grove_tb6612fng/__init__.py | 1 + esphome/components/growatt_solar/__init__.py | 1 + esphome/components/gsl3670/__init__.py | 1 + esphome/components/gt911/__init__.py | 1 + esphome/components/haier/__init__.py | 1 + esphome/components/havells_solar/__init__.py | 1 + esphome/components/hbridge/__init__.py | 2 + esphome/components/hc8/__init__.py | 1 + esphome/components/hdc1080/__init__.py | 1 + esphome/components/hdc2010/__init__.py | 1 + esphome/components/hdc2080/__init__.py | 1 + esphome/components/hdc302x/__init__.py | 1 + esphome/components/he60r/__init__.py | 1 + esphome/components/heatpumpir/__init__.py | 1 + esphome/components/hitachi_ac344/__init__.py | 1 + esphome/components/hitachi_ac424/__init__.py | 1 + esphome/components/hlk_fm22x/__init__.py | 1 + esphome/components/hlw8012/__init__.py | 1 + esphome/components/hlw8032/__init__.py | 1 + esphome/components/hm3301/__init__.py | 1 + esphome/components/hmac_md5/__init__.py | 1 + esphome/components/hmac_sha256/__init__.py | 1 + esphome/components/hmc5883l/__init__.py | 1 + esphome/components/hoermann_hcp/__init__.py | 1 + esphome/components/homeassistant/__init__.py | 1 + .../components/honeywell_hih_i2c/__init__.py | 1 + esphome/components/honeywellabp/__init__.py | 2 + .../components/honeywellabp2_i2c/__init__.py | 1 + esphome/components/host/__init__.py | 1 + .../components/hrxl_maxsonar_wr/__init__.py | 1 + esphome/components/hte501/__init__.py | 1 + esphome/components/http_request/__init__.py | 1 + esphome/components/htu21d/__init__.py | 1 + esphome/components/htu31d/__init__.py | 1 + esphome/components/hub75/__init__.py | 1 + esphome/components/hx711/__init__.py | 1 + esphome/components/hydreon_rgxx/__init__.py | 1 + esphome/components/hyt271/__init__.py | 1 + esphome/components/i2c/__init__.py | 1 + esphome/components/i2c_device/__init__.py | 1 + esphome/components/i2s_audio/__init__.py | 1 + esphome/components/iaqcore/__init__.py | 1 + esphome/components/icnt86/__init__.py | 1 + esphome/components/ili9341/__init__.py | 1 + esphome/components/ili9xxx/__init__.py | 2 + esphome/components/improv_base/__init__.py | 1 + esphome/components/improv_ble/__init__.py | 1 + esphome/components/improv_serial/__init__.py | 1 + esphome/components/ina219/__init__.py | 1 + esphome/components/ina226/__init__.py | 1 + esphome/components/ina260/__init__.py | 1 + esphome/components/ina2xx_base/__init__.py | 1 + esphome/components/ina2xx_i2c/__init__.py | 1 + esphome/components/ina2xx_spi/__init__.py | 1 + esphome/components/ina3221/__init__.py | 1 + esphome/components/infrared/__init__.py | 1 + .../inkbird_ibsth1_mini/__init__.py | 1 + esphome/components/inkplate/__init__.py | 1 + esphome/components/inkplate6/__init__.py | 1 + esphome/components/integration/__init__.py | 1 + .../internal_temperature/__init__.py | 1 + esphome/components/interval/__init__.py | 1 + esphome/components/ir_rf_base/__init__.py | 1 + esphome/components/ir_rf_proxy/__init__.py | 1 + esphome/components/it8951/__init__.py | 1 + esphome/components/jsn_sr04t/__init__.py | 1 + esphome/components/json/__init__.py | 1 + .../components/kalman_combinator/__init__.py | 1 + esphome/components/kamstrup_kmp/__init__.py | 1 + esphome/components/key_collector/__init__.py | 1 + esphome/components/key_provider/__init__.py | 1 + esphome/components/kmeteriso/__init__.py | 1 + esphome/components/kuntze/__init__.py | 1 + esphome/components/lc709203f/__init__.py | 1 + esphome/components/lcd_base/__init__.py | 2 + esphome/components/lcd_gpio/__init__.py | 1 + esphome/components/lcd_menu/__init__.py | 1 + esphome/components/lcd_pcf8574/__init__.py | 1 + esphome/components/ld2410/__init__.py | 1 + esphome/components/ld2412/__init__.py | 1 + esphome/components/ld2420/__init__.py | 1 + esphome/components/ld2450/__init__.py | 1 + esphome/components/ld24xx/__init__.py | 1 + esphome/components/ld6002b/__init__.py | 1 + esphome/components/ledc/__init__.py | 1 + esphome/components/libretiny/__init__.py | 1 + esphome/components/libretiny_pwm/__init__.py | 1 + esphome/components/lightwaverf/__init__.py | 1 + esphome/components/lilygo_t5_47/__init__.py | 2 + esphome/components/lm75b/__init__.py | 1 + esphome/components/ln882h_ble/__init__.py | 1 + .../components/ln882h_ble_tracker/__init__.py | 1 + esphome/components/ln882x/__init__.py | 1 + esphome/components/lock/__init__.py | 1 + esphome/components/lps22/__init__.py | 1 + esphome/components/lsm6ds/__init__.py | 1 + esphome/components/ltr390/__init__.py | 1 + esphome/components/ltr501/__init__.py | 1 + esphome/components/ltr_als_ps/__init__.py | 1 + esphome/components/m5stack_8angle/__init__.py | 1 + esphome/components/matrix_keypad/__init__.py | 1 + esphome/components/max17043/__init__.py | 1 + esphome/components/max31855/__init__.py | 1 + esphome/components/max31856/__init__.py | 1 + esphome/components/max31865/__init__.py | 1 + esphome/components/max44009/__init__.py | 1 + esphome/components/max6675/__init__.py | 1 + esphome/components/max6956/__init__.py | 1 + esphome/components/max7219/__init__.py | 1 + esphome/components/max7219digit/__init__.py | 1 + esphome/components/max9611/__init__.py | 1 + esphome/components/mcp23008/__init__.py | 1 + esphome/components/mcp23016/__init__.py | 1 + esphome/components/mcp23017/__init__.py | 1 + esphome/components/mcp23s08/__init__.py | 1 + esphome/components/mcp23s17/__init__.py | 1 + esphome/components/mcp23x08_base/__init__.py | 1 + esphome/components/mcp23x17_base/__init__.py | 1 + esphome/components/mcp23xxx_base/__init__.py | 1 + esphome/components/mcp2515/__init__.py | 1 + esphome/components/mcp3008/__init__.py | 1 + esphome/components/mcp3204/__init__.py | 1 + esphome/components/mcp3221/__init__.py | 1 + esphome/components/mcp4461/__init__.py | 1 + esphome/components/mcp4725/__init__.py | 1 + esphome/components/mcp4728/__init__.py | 1 + esphome/components/mcp47a1/__init__.py | 1 + esphome/components/mcp9600/__init__.py | 1 + esphome/components/mcp9808/__init__.py | 1 + esphome/components/md5/__init__.py | 1 + esphome/components/mdns/__init__.py | 1 + esphome/components/media_player/__init__.py | 1 + esphome/components/media_source/__init__.py | 1 + esphome/components/mhz19/__init__.py | 1 + esphome/components/microphone/__init__.py | 1 + esphome/components/mics_4514/__init__.py | 1 + esphome/components/midea/__init__.py | 1 + esphome/components/midea_ac/__init__.py | 1 + esphome/components/midea_ir/__init__.py | 1 + esphome/components/mipi/__init__.py | 2 + esphome/components/mipi_dsi/__init__.py | 1 + esphome/components/mitsubishi/__init__.py | 1 + esphome/components/mixer/__init__.py | 1 + esphome/components/mk2pvrouter/__init__.py | 1 + esphome/components/mlx90393/__init__.py | 1 + esphome/components/mlx90614/__init__.py | 1 + esphome/components/mmc5603/__init__.py | 1 + esphome/components/mmc5983/__init__.py | 1 + esphome/components/modbus/__init__.py | 1 + esphome/components/modbus_client/__init__.py | 1 + esphome/components/modbus_server/__init__.py | 1 + esphome/components/monochromatic/__init__.py | 1 + esphome/components/mopeka_ble/__init__.py | 1 + .../components/mopeka_pro_check/__init__.py | 1 + .../components/mopeka_std_check/__init__.py | 1 + esphome/components/mpl3115a2/__init__.py | 1 + esphome/components/mpr121/__init__.py | 1 + esphome/components/mpu6050/__init__.py | 1 + esphome/components/mpu6886/__init__.py | 2 + esphome/components/mqtt/__init__.py | 1 + esphome/components/mqtt_subscribe/__init__.py | 2 + esphome/components/ms5611/__init__.py | 1 + esphome/components/ms8607/__init__.py | 1 + esphome/components/msa3xx/__init__.py | 1 + esphome/components/my9231/__init__.py | 1 + esphome/components/nau7802/__init__.py | 1 + esphome/components/neopixelbus/__init__.py | 1 + esphome/components/network/__init__.py | 1 + esphome/components/nextion/__init__.py | 1 + esphome/components/nfc/__init__.py | 1 + esphome/components/noblex/__init__.py | 1 + esphome/components/noise/__init__.py | 1 + esphome/components/npi19/__init__.py | 1 + esphome/components/nrf52/__init__.py | 1 + esphome/components/ntc/__init__.py | 1 + esphome/components/number/__init__.py | 1 + esphome/components/one_wire/__init__.py | 1 + esphome/components/opentherm/__init__.py | 1 + esphome/components/openthread/__init__.py | 1 + .../components/openthread_info/__init__.py | 1 + esphome/components/opt3001/__init__.py | 1 + esphome/components/ota/__init__.py | 1 + esphome/components/packages/__init__.py | 2 +- esphome/components/partition/__init__.py | 1 + esphome/components/pca6416a/__init__.py | 1 + esphome/components/pca9554/__init__.py | 1 + esphome/components/pca9685/__init__.py | 1 + esphome/components/pcd8544/__init__.py | 1 + esphome/components/pcf85063/__init__.py | 1 + esphome/components/pcf8563/__init__.py | 1 + esphome/components/pcf8574/__init__.py | 1 + esphome/components/pcm5122/__init__.py | 1 + esphome/components/pi4ioe5v6408/__init__.py | 1 + esphome/components/pid/__init__.py | 1 + esphome/components/pipsolar/__init__.py | 1 + esphome/components/pixoo/__init__.py | 1 + esphome/components/pm1006/__init__.py | 1 + esphome/components/pm2005/__init__.py | 2 + esphome/components/pmsa003i/__init__.py | 1 + esphome/components/pmsx003/__init__.py | 1 + esphome/components/pmwcs3/__init__.py | 1 + esphome/components/pn532/__init__.py | 1 + esphome/components/pn532_i2c/__init__.py | 1 + esphome/components/pn532_spi/__init__.py | 1 + esphome/components/pn7150/__init__.py | 1 + esphome/components/pn7150_i2c/__init__.py | 1 + esphome/components/pn7160/__init__.py | 1 + esphome/components/pn7160_i2c/__init__.py | 1 + esphome/components/pn7160_spi/__init__.py | 1 + esphome/components/pn71xx/__init__.py | 1 + esphome/components/power_supply/__init__.py | 1 + esphome/components/preferences/__init__.py | 1 + esphome/components/prometheus/__init__.py | 1 + esphome/components/pulse_counter/__init__.py | 1 + esphome/components/pulse_meter/__init__.py | 1 + esphome/components/pulse_width/__init__.py | 1 + .../components/pvvx_mithermometer/__init__.py | 1 + esphome/components/pylontech/__init__.py | 1 + esphome/components/pzem004t/__init__.py | 1 + esphome/components/pzem6l24/__init__.py | 1 + esphome/components/pzemac/__init__.py | 1 + esphome/components/pzemdc/__init__.py | 1 + esphome/components/qmc5883l/__init__.py | 1 + esphome/components/qmi8658/__init__.py | 1 + esphome/components/qmp6988/__init__.py | 1 + esphome/components/qr_code/__init__.py | 1 + esphome/components/qspi_dbi/__init__.py | 1 + esphome/components/qwiic_pir/__init__.py | 1 + .../components/radio_frequency/__init__.py | 1 + esphome/components/radon_eye_ble/__init__.py | 1 + .../components/radon_eye_rd200/__init__.py | 1 + esphome/components/rc522/__init__.py | 1 + esphome/components/rc522_i2c/__init__.py | 1 + esphome/components/rc522_spi/__init__.py | 1 + esphome/components/rd03d/__init__.py | 1 + esphome/components/rdm6300/__init__.py | 1 + esphome/components/remote_base/__init__.py | 1 + .../components/remote_receiver/__init__.py | 1 + .../components/remote_transmitter/__init__.py | 1 + esphome/components/resampler/__init__.py | 2 + esphome/components/resistance/__init__.py | 1 + esphome/components/restart/__init__.py | 1 + esphome/components/rf_bridge/__init__.py | 1 + esphome/components/rgb/__init__.py | 1 + esphome/components/rgbct/__init__.py | 1 + esphome/components/rgbw/__init__.py | 1 + esphome/components/rgbww/__init__.py | 1 + esphome/components/ring_buffer/__init__.py | 1 + esphome/components/rotary_encoder/__init__.py | 1 + esphome/components/router/__init__.py | 1 + esphome/components/rp2/__init__.py | 1 + esphome/components/rp2040_ble/__init__.py | 1 + .../rp2040_pio_led_strip/__init__.py | 1 + esphome/components/rp2040_pwm/__init__.py | 1 + .../components/rp2_ble_tracker/__init__.py | 1 + esphome/components/rp2_pio/__init__.py | 1 + esphome/components/rpi_dpi_rgb/__init__.py | 1 + esphome/components/rtl87xx/__init__.py | 1 + esphome/components/rtttl/__init__.py | 1 + esphome/components/runtime_image/__init__.py | 1 + esphome/components/runtime_stats/__init__.py | 1 + esphome/components/ruuvi_ble/__init__.py | 1 + esphome/components/ruuvitag/__init__.py | 1 + esphome/components/rx8025t/__init__.py | 1 + esphome/components/rx8130/__init__.py | 1 + esphome/components/safe_mode/__init__.py | 1 + esphome/components/scd30/__init__.py | 1 + esphome/components/scd4x/__init__.py | 1 + esphome/components/script/__init__.py | 1 + esphome/components/sdl/__init__.py | 1 + esphome/components/sdm_meter/__init__.py | 1 + esphome/components/sdp3x/__init__.py | 1 + esphome/components/sds011/__init__.py | 1 + esphome/components/seeed_mr24hpc1/__init__.py | 1 + esphome/components/seeed_mr60bha2/__init__.py | 1 + esphome/components/seeed_mr60fda2/__init__.py | 1 + esphome/components/selec_meter/__init__.py | 1 + esphome/components/select/__init__.py | 1 + esphome/components/sen0321/__init__.py | 1 + esphome/components/sen21231/__init__.py | 1 + esphome/components/sen5x/__init__.py | 1 + esphome/components/sen6x/__init__.py | 1 + esphome/components/senseair/__init__.py | 1 + .../components/sensirion_common/__init__.py | 1 + esphome/components/sensor/__init__.py | 1 + esphome/components/servo/__init__.py | 1 + esphome/components/sfa30/__init__.py | 1 + esphome/components/sfa40/__init__.py | 1 + esphome/components/sgp30/__init__.py | 1 + esphome/components/sgp40/__init__.py | 1 + esphome/components/sgp4x/__init__.py | 1 + esphome/components/sha256/__init__.py | 1 + esphome/components/shelly_dimmer/__init__.py | 1 + esphome/components/sht3xd/__init__.py | 1 + esphome/components/sht4x/__init__.py | 1 + esphome/components/shtcx/__init__.py | 1 + esphome/components/shutdown/__init__.py | 1 + .../components/sigma_delta_output/__init__.py | 1 + esphome/components/sim800l/__init__.py | 1 + esphome/components/slow_pwm/__init__.py | 1 + esphome/components/sm10bit_base/__init__.py | 1 + esphome/components/sm16716/__init__.py | 1 + esphome/components/sm2135/__init__.py | 1 + esphome/components/sm2235/__init__.py | 1 + esphome/components/sm2335/__init__.py | 1 + esphome/components/sm300d2/__init__.py | 1 + esphome/components/sml/__init__.py | 1 + esphome/components/smt100/__init__.py | 1 + esphome/components/sn74hc165/__init__.py | 1 + esphome/components/sn74hc595/__init__.py | 1 + esphome/components/sntp/__init__.py | 1 + esphome/components/socket/__init__.py | 1 + esphome/components/sonoff_d1/__init__.py | 1 + esphome/components/sound_level/__init__.py | 1 + esphome/components/spa06_base/__init__.py | 1 + esphome/components/spa06_i2c/__init__.py | 1 + esphome/components/spa06_spi/__init__.py | 1 + esphome/components/speaker/__init__.py | 1 + esphome/components/speaker_source/__init__.py | 1 + esphome/components/speed/__init__.py | 2 + esphome/components/spi/__init__.py | 1 + esphome/components/spi_device/__init__.py | 1 + esphome/components/spi_led_strip/__init__.py | 1 + esphome/components/split_buffer/__init__.py | 1 + esphome/components/sprinkler/__init__.py | 1 + esphome/components/sps30/__init__.py | 1 + esphome/components/ssd1306_base/__init__.py | 2 + esphome/components/ssd1306_i2c/__init__.py | 1 + esphome/components/ssd1306_spi/__init__.py | 1 + esphome/components/ssd1322_base/__init__.py | 1 + esphome/components/ssd1322_spi/__init__.py | 1 + esphome/components/ssd1325_base/__init__.py | 1 + esphome/components/ssd1325_spi/__init__.py | 1 + esphome/components/ssd1327_base/__init__.py | 1 + esphome/components/ssd1327_i2c/__init__.py | 1 + esphome/components/ssd1327_spi/__init__.py | 1 + esphome/components/ssd1331_base/__init__.py | 1 + esphome/components/ssd1331_spi/__init__.py | 1 + esphome/components/ssd1351_base/__init__.py | 1 + esphome/components/ssd1351_spi/__init__.py | 1 + esphome/components/st7123/__init__.py | 1 + esphome/components/st7567_base/__init__.py | 1 + esphome/components/st7567_i2c/__init__.py | 1 + esphome/components/st7567_spi/__init__.py | 1 + esphome/components/st7701s/__init__.py | 1 + esphome/components/st7735/__init__.py | 2 + esphome/components/st7789v/__init__.py | 2 + esphome/components/st7920/__init__.py | 1 + esphome/components/statsd/__init__.py | 1 + esphome/components/status/__init__.py | 1 + esphome/components/status_led/__init__.py | 2 + esphome/components/stcc4/__init__.py | 1 + esphome/components/stepper/__init__.py | 1 + esphome/components/sts3x/__init__.py | 1 + esphome/components/stts22h/__init__.py | 1 + esphome/components/substitutions/__init__.py | 1 + esphome/components/sun/__init__.py | 1 + esphome/components/sun_gtil2/__init__.py | 1 + esphome/components/switch/__init__.py | 1 + esphome/components/sx126x/__init__.py | 1 + esphome/components/sx127x/__init__.py | 1 + esphome/components/sx1509/__init__.py | 1 + esphome/components/sy6970/__init__.py | 1 + esphome/components/syslog/__init__.py | 1 + esphome/components/systa_bus/__init__.py | 1 + esphome/components/t6615/__init__.py | 1 + esphome/components/tas2780/__init__.py | 1 + esphome/components/tas58xx/__init__.py | 1 + esphome/components/tc74/__init__.py | 1 + esphome/components/tca9548a/__init__.py | 1 + esphome/components/tca9555/__init__.py | 1 + esphome/components/tcl112/__init__.py | 1 + esphome/components/tcp_uart/__init__.py | 1 + esphome/components/tcs34725/__init__.py | 1 + esphome/components/tee501/__init__.py | 1 + esphome/components/teleinfo/__init__.py | 1 + esphome/components/tem3200/__init__.py | 1 + esphome/components/template/__init__.py | 2 + esphome/components/text/__init__.py | 1 + esphome/components/text_sensor/__init__.py | 1 + esphome/components/tfluna/__init__.py | 1 + esphome/components/thermopro_ble/__init__.py | 1 + esphome/components/thermostat/__init__.py | 1 + esphome/components/time_based/__init__.py | 2 + esphome/components/tinyusb/__init__.py | 1 + esphome/components/tlc59208f/__init__.py | 1 + esphome/components/tlc5947/__init__.py | 1 + esphome/components/tlc5971/__init__.py | 1 + esphome/components/tm1621/__init__.py | 1 + esphome/components/tm1637/__init__.py | 1 + esphome/components/tm1638/__init__.py | 1 + esphome/components/tm1651/__init__.py | 1 + esphome/components/tmp102/__init__.py | 2 + esphome/components/tmp1075/__init__.py | 1 + esphome/components/tmp117/__init__.py | 1 + esphome/components/tof10120/__init__.py | 1 + esphome/components/tormatic/__init__.py | 1 + esphome/components/toshiba/__init__.py | 1 + .../components/total_daily_energy/__init__.py | 1 + esphome/components/touchscreen/__init__.py | 1 + esphome/components/tsl2561/__init__.py | 1 + esphome/components/tsl2591/__init__.py | 1 + esphome/components/tt21100/__init__.py | 1 + esphome/components/ttp229_bsf/__init__.py | 1 + esphome/components/ttp229_lsf/__init__.py | 1 + esphome/components/tuya/__init__.py | 1 + esphome/components/tx20/__init__.py | 1 + esphome/components/udp/__init__.py | 1 + esphome/components/ufire_ec/__init__.py | 1 + esphome/components/ufire_ise/__init__.py | 1 + esphome/components/ufm01/__init__.py | 1 + esphome/components/uln2003/__init__.py | 1 + esphome/components/ultrasonic/__init__.py | 1 + esphome/components/unicode/__init__.py | 1 + esphome/components/update/__init__.py | 1 + esphome/components/uponor_smatrix/__init__.py | 1 + esphome/components/uptime/__init__.py | 1 + esphome/components/usb_cdc_acm/__init__.py | 1 + esphome/components/usb_uart/__init__.py | 1 + esphome/components/valve/__init__.py | 1 + esphome/components/vbus/__init__.py | 1 + esphome/components/veml3235/__init__.py | 1 + esphome/components/veml7700/__init__.py | 1 + esphome/components/version/__init__.py | 1 + esphome/components/vl53l0x/__init__.py | 1 + .../components/voice_assistant/__init__.py | 1 + .../components/voltage_sampler/__init__.py | 2 + esphome/components/wake_on_lan/__init__.py | 1 + esphome/components/watchdog/__init__.py | 1 + esphome/components/water_heater/__init__.py | 1 + .../components/waveshare_epaper/__init__.py | 1 + .../waveshare_io_ch32v003/__init__.py | 1 + esphome/components/web_server/__init__.py | 1 + .../components/web_server_base/__init__.py | 1 + esphome/components/web_server_idf/__init__.py | 1 + esphome/components/weikai/__init__.py | 1 + esphome/components/weikai_i2c/__init__.py | 1 + esphome/components/weikai_spi/__init__.py | 1 + esphome/components/whirlpool/__init__.py | 1 + esphome/components/whynter/__init__.py | 1 + esphome/components/wiegand/__init__.py | 1 + esphome/components/wifi/__init__.py | 1 + esphome/components/wifi_info/__init__.py | 1 + esphome/components/wifi_signal/__init__.py | 1 + esphome/components/wireguard/__init__.py | 1 + esphome/components/wk2132_i2c/__init__.py | 1 + esphome/components/wk2132_spi/__init__.py | 1 + esphome/components/wk2168_i2c/__init__.py | 1 + esphome/components/wk2168_spi/__init__.py | 1 + esphome/components/wk2204_i2c/__init__.py | 1 + esphome/components/wk2204_spi/__init__.py | 1 + esphome/components/wk2212_i2c/__init__.py | 1 + esphome/components/wk2212_spi/__init__.py | 1 + esphome/components/wl_134/__init__.py | 1 + esphome/components/wled/__init__.py | 2 + esphome/components/wts01/__init__.py | 1 + esphome/components/x9c/__init__.py | 1 + esphome/components/xdb401/__init__.py | 1 + esphome/components/xgzp68xx/__init__.py | 1 + esphome/components/xiaomi_ble/__init__.py | 1 + .../components/xiaomi_body_scale/__init__.py | 1 + esphome/components/xiaomi_cgd1/__init__.py | 1 + esphome/components/xiaomi_cgdk2/__init__.py | 1 + esphome/components/xiaomi_cgg1/__init__.py | 1 + esphome/components/xiaomi_cgpr1/__init__.py | 1 + esphome/components/xiaomi_gcls002/__init__.py | 1 + .../components/xiaomi_hhccjcy01/__init__.py | 1 + .../components/xiaomi_hhccjcy10/__init__.py | 1 + .../components/xiaomi_hhccpot002/__init__.py | 1 + .../components/xiaomi_jqjcy01ym/__init__.py | 1 + esphome/components/xiaomi_lywsd02/__init__.py | 1 + .../components/xiaomi_lywsd02mmc/__init__.py | 1 + .../components/xiaomi_lywsd03mmc/__init__.py | 1 + .../components/xiaomi_lywsdcgq/__init__.py | 1 + .../components/xiaomi_mccgq02hl/__init__.py | 1 + esphome/components/xiaomi_mhoc303/__init__.py | 1 + esphome/components/xiaomi_mhoc401/__init__.py | 1 + esphome/components/xiaomi_miscale/__init__.py | 1 + .../components/xiaomi_miscale2/__init__.py | 1 + .../components/xiaomi_mjyd02yla/__init__.py | 1 + .../components/xiaomi_mue4094rt/__init__.py | 1 + .../components/xiaomi_rtcgq02lm/__init__.py | 1 + esphome/components/xiaomi_wx08zm/__init__.py | 1 + .../components/xiaomi_xmwsdj04mmc/__init__.py | 1 + esphome/components/xl9535/__init__.py | 1 + esphome/components/xpt2046/__init__.py | 2 + esphome/components/xxtea/__init__.py | 1 + esphome/components/yashima/__init__.py | 1 + esphome/components/zephyr/__init__.py | 1 + esphome/components/zephyr_mcumgr/__init__.py | 1 + esphome/components/zephyr_pwm/__init__.py | 1 + esphome/components/zhlt01/__init__.py | 1 + esphome/components/zigbee/__init__.py | 1 + esphome/components/zio_ultrasonic/__init__.py | 1 + esphome/components/zwave_proxy/__init__.py | 1 + esphome/components/zyaura/__init__.py | 1 + script/ci-custom.py | 35 +++++++++ tests/script/test_ci_custom.py | 72 +++++++++++++++++++ 729 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 esphome/components/esphome/__init__.py diff --git a/AGENTS.md b/AGENTS.md index 6a60fc47b17..dc364e4e5c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,6 +230,10 @@ file does, and it is the authority when they disagree. The most useful starting ``` * **Component Metadata:** + - `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain + string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g. + `from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other + metadata constants. CI (`lint_component_domain`) fails without it. - `DEPENDENCIES`: List of required components - `AUTO_LOAD`: Components to automatically load - `CONFLICTS_WITH`: Incompatible components @@ -243,6 +247,7 @@ file does, and it is the authority when they disagree. The most useful starting import esphome.config_validation as cv from esphome.const import CONF_KEY, CONF_ID + DOMAIN = "my_component" CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py my_component_ns = cg.esphome_ns.namespace("my_component") diff --git a/CODEOWNERS b/CODEOWNERS index b5138c06120..e0f9d2d2ef9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -188,6 +188,7 @@ esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core esphome/components/esp_ldo/* @clydebarrow esphome/components/espectre/* @francescopace +esphome/components/esphome/ota/* @esphome/core esphome/components/espnow/* @jesserockz esphome/components/espnow/packet_transport/* @EasilyBoredEngineer esphome/components/ethernet_info/* @gtjadsonsantos diff --git a/esphome/components/a01nyub/__init__.py b/esphome/components/a01nyub/__init__.py index 4c84847fb69..45206b66bc2 100644 --- a/esphome/components/a01nyub/__init__.py +++ b/esphome/components/a01nyub/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@MrSuicideParrot"] +DOMAIN = "a01nyub" diff --git a/esphome/components/a02yyuw/__init__.py b/esphome/components/a02yyuw/__init__.py index 6724dbb970d..f6a28dfa18c 100644 --- a/esphome/components/a02yyuw/__init__.py +++ b/esphome/components/a02yyuw/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@TH-Braemer"] +DOMAIN = "a02yyuw" diff --git a/esphome/components/a4988/__init__.py b/esphome/components/a4988/__init__.py index e69de29bb2d..55797725d61 100644 --- a/esphome/components/a4988/__init__.py +++ b/esphome/components/a4988/__init__.py @@ -0,0 +1 @@ +DOMAIN = "a4988" diff --git a/esphome/components/absolute_humidity/__init__.py b/esphome/components/absolute_humidity/__init__.py index 8f113b48f6a..5cfae55a40a 100644 --- a/esphome/components/absolute_humidity/__init__.py +++ b/esphome/components/absolute_humidity/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DAVe3283"] +DOMAIN = "absolute_humidity" diff --git a/esphome/components/ac_dimmer/__init__.py b/esphome/components/ac_dimmer/__init__.py index e69de29bb2d..7025ecff72d 100644 --- a/esphome/components/ac_dimmer/__init__.py +++ b/esphome/components/ac_dimmer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ac_dimmer" diff --git a/esphome/components/adalight/__init__.py b/esphome/components/adalight/__init__.py index afdfefaba67..caf66167b1e 100644 --- a/esphome/components/adalight/__init__.py +++ b/esphome/components/adalight/__init__.py @@ -9,6 +9,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType DEPENDENCIES = ["uart"] +DOMAIN = "adalight" adalight_ns = cg.esphome_ns.namespace("adalight") AdalightLightEffect = adalight_ns.class_( diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index c397e746b0f..6966a86f6b9 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -22,6 +22,7 @@ from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "adc" adc_ns = cg.esphome_ns.namespace("adc") diff --git a/esphome/components/adc128s102/__init__.py b/esphome/components/adc128s102/__init__.py index 684147752d4..125b4113c57 100644 --- a/esphome/components/adc128s102/__init__.py +++ b/esphome/components/adc128s102/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] +DOMAIN = "adc128s102" MULTI_CONF = True CODEOWNERS = ["@DeerMaximum"] diff --git a/esphome/components/addressable_light/__init__.py b/esphome/components/addressable_light/__init__.py index e69de29bb2d..28a33c66d84 100644 --- a/esphome/components/addressable_light/__init__.py +++ b/esphome/components/addressable_light/__init__.py @@ -0,0 +1 @@ +DOMAIN = "addressable_light" diff --git a/esphome/components/ade7880/__init__.py b/esphome/components/ade7880/__init__.py index e69de29bb2d..f3dd325f5cf 100644 --- a/esphome/components/ade7880/__init__.py +++ b/esphome/components/ade7880/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ade7880" diff --git a/esphome/components/ade7953/__init__.py b/esphome/components/ade7953/__init__.py index d3078a0b679..512ac1a3f34 100644 --- a/esphome/components/ade7953/__init__.py +++ b/esphome/components/ade7953/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953" diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 71250ac94e3..808de99984c 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -27,6 +27,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953_base" CONF_CURRENT_A = "current_a" CONF_CURRENT_B = "current_b" diff --git a/esphome/components/ade7953_i2c/__init__.py b/esphome/components/ade7953_i2c/__init__.py index d3078a0b679..691ceb02749 100644 --- a/esphome/components/ade7953_i2c/__init__.py +++ b/esphome/components/ade7953_i2c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953_i2c" diff --git a/esphome/components/ade7953_spi/__init__.py b/esphome/components/ade7953_spi/__init__.py index d3078a0b679..71ad260db2a 100644 --- a/esphome/components/ade7953_spi/__init__.py +++ b/esphome/components/ade7953_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953_spi" diff --git a/esphome/components/ads1115/__init__.py b/esphome/components/ads1115/__init__.py index b42ee918c57..1b8405322fa 100644 --- a/esphome/components/ads1115/__init__.py +++ b/esphome/components/ads1115/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "ads1115" MULTI_CONF = True ads1115_ns = cg.esphome_ns.namespace("ads1115") diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 956b9a0c1fb..98129ecbea3 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@solomondg1"] DEPENDENCIES = ["spi"] +DOMAIN = "ads1118" MULTI_CONF = True CONF_ADS1118_ID = "ads1118_id" diff --git a/esphome/components/ags10/__init__.py b/esphome/components/ags10/__init__.py index 37f34d06dff..8d2bbd6fb4b 100644 --- a/esphome/components/ags10/__init__.py +++ b/esphome/components/ags10/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@mak-42"] +DOMAIN = "ags10" diff --git a/esphome/components/aht10/__init__.py b/esphome/components/aht10/__init__.py index e69de29bb2d..888d442fdf1 100644 --- a/esphome/components/aht10/__init__.py +++ b/esphome/components/aht10/__init__.py @@ -0,0 +1 @@ +DOMAIN = "aht10" diff --git a/esphome/components/aic3204/__init__.py b/esphome/components/aic3204/__init__.py index e69de29bb2d..c7f7f00960b 100644 --- a/esphome/components/aic3204/__init__.py +++ b/esphome/components/aic3204/__init__.py @@ -0,0 +1 @@ +DOMAIN = "aic3204" diff --git a/esphome/components/airthings_ble/__init__.py b/esphome/components/airthings_ble/__init__.py index 44534b80e94..48cebad5fbc 100644 --- a/esphome/components/airthings_ble/__init__.py +++ b/esphome/components/airthings_ble/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeromelaban"] +DOMAIN = "airthings_ble" airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble") AirthingsListener = airthings_ble_ns.class_( diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index 2cec58934d6..035142193a8 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -24,6 +24,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@ncareau", "@jeromelaban"] +DOMAIN = "airthings_wave_base" DEPENDENCIES = ["ble_client"] diff --git a/esphome/components/airthings_wave_mini/__init__.py b/esphome/components/airthings_wave_mini/__init__.py index 022f35b4cf6..64513a448d6 100644 --- a/esphome/components/airthings_wave_mini/__init__.py +++ b/esphome/components/airthings_wave_mini/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ncareau"] +DOMAIN = "airthings_wave_mini" diff --git a/esphome/components/airthings_wave_plus/__init__.py b/esphome/components/airthings_wave_plus/__init__.py index e26bfd471bb..904aa922ba7 100644 --- a/esphome/components/airthings_wave_plus/__init__.py +++ b/esphome/components/airthings_wave_plus/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jeromelaban", "@precurse"] +DOMAIN = "airthings_wave_plus" diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 78206ff3f8a..ac12835433d 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -21,6 +21,7 @@ from esphome.core.entity_helpers import ( from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@grahambrown11", "@hwstar"] +DOMAIN = "alarm_control_panel" IS_PLATFORM_COMPONENT = True CONF_ON_TRIGGERED = "on_triggered" diff --git a/esphome/components/alpha3/__init__.py b/esphome/components/alpha3/__init__.py index 7cd320c80fd..551edbeb7f1 100644 --- a/esphome/components/alpha3/__init__.py +++ b/esphome/components/alpha3/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jan-hofmeier"] +DOMAIN = "alpha3" diff --git a/esphome/components/am2315c/__init__.py b/esphome/components/am2315c/__init__.py index 2398e4fa236..a0ffa96f5eb 100644 --- a/esphome/components/am2315c/__init__.py +++ b/esphome/components/am2315c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@swoboda1337"] +DOMAIN = "am2315c" diff --git a/esphome/components/am2320/__init__.py b/esphome/components/am2320/__init__.py index e69de29bb2d..34e57d220cd 100644 --- a/esphome/components/am2320/__init__.py +++ b/esphome/components/am2320/__init__.py @@ -0,0 +1 @@ +DOMAIN = "am2320" diff --git a/esphome/components/am43/__init__.py b/esphome/components/am43/__init__.py index f21a15ce0a2..fa3caae506a 100644 --- a/esphome/components/am43/__init__.py +++ b/esphome/components/am43/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@buxtronix"] +DOMAIN = "am43" diff --git a/esphome/components/analog_threshold/__init__.py b/esphome/components/analog_threshold/__init__.py index 9ae2df986db..c01f3b40e5f 100644 --- a/esphome/components/analog_threshold/__init__.py +++ b/esphome/components/analog_threshold/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ianchi"] +DOMAIN = "analog_threshold" diff --git a/esphome/components/anova/__init__.py b/esphome/components/anova/__init__.py index e69de29bb2d..4fdd8de72cc 100644 --- a/esphome/components/anova/__init__.py +++ b/esphome/components/anova/__init__.py @@ -0,0 +1 @@ +DOMAIN = "anova" diff --git a/esphome/components/apds9306/__init__.py b/esphome/components/apds9306/__init__.py index 3dc8fcf5ff5..b22aecf6d20 100644 --- a/esphome/components/apds9306/__init__.py +++ b/esphome/components/apds9306/__init__.py @@ -2,3 +2,4 @@ # https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf CODEOWNERS = ["@aodrenah"] +DOMAIN = "apds9306" diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 7ac1e5eb326..8c19eec5ca1 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "apds9960" MULTI_CONF = True CONF_APDS9960_ID = "apds9960_id" diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py index 17d434294a3..02c0bd2228a 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] +DOMAIN = "aqi" aqi_ns = cg.esphome_ns.namespace("aqi") AQICalculatorType = aqi_ns.enum("AQICalculatorType") diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index bd02d22d1b3..b7b5b09c626 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "as3935" MULTI_CONF = True CONF_AS3935_ID = "as3935_id" diff --git a/esphome/components/as3935_i2c/__init__.py b/esphome/components/as3935_i2c/__init__.py index 83924de760f..e1d2d89ec5d 100644 --- a/esphome/components/as3935_i2c/__init__.py +++ b/esphome/components/as3935_i2c/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["as3935"] DEPENDENCIES = ["i2c"] +DOMAIN = "as3935_i2c" as3935_i2c_ns = cg.esphome_ns.namespace("as3935_i2c") I2CAS3935 = as3935_i2c_ns.class_("I2CAS3935Component", as3935.AS3935, i2c.I2CDevice) diff --git a/esphome/components/as3935_spi/__init__.py b/esphome/components/as3935_spi/__init__.py index 332a51c7a9f..9313705ab66 100644 --- a/esphome/components/as3935_spi/__init__.py +++ b/esphome/components/as3935_spi/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["as3935"] DEPENDENCIES = ["spi"] +DOMAIN = "as3935_spi" as3935_spi_ns = cg.esphome_ns.namespace("as3935_spi") SPIAS3935 = as3935_spi_ns.class_("SPIAS3935Component", as3935.AS3935, spi.SPIDevice) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 780712c3bd0..3cc5ed5cc1a 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@ammmze"] DEPENDENCIES = ["i2c"] +DOMAIN = "as5600" MULTI_CONF = True as5600_ns = cg.esphome_ns.namespace("as5600") diff --git a/esphome/components/as7341/__init__.py b/esphome/components/as7341/__init__.py index e69de29bb2d..bdbe78a4ece 100644 --- a/esphome/components/as7341/__init__.py +++ b/esphome/components/as7341/__init__.py @@ -0,0 +1 @@ +DOMAIN = "as7341" diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 31007022d56..b67202db593 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +DOMAIN = "async_tcp" def AUTO_LOAD() -> list[str]: diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index a29e166745e..50c9080c2dd 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] DEPENDENCIES = ["i2c"] +DOMAIN = "at581x" MULTI_CONF = True diff --git a/esphome/components/atc_mithermometer/__init__.py b/esphome/components/atc_mithermometer/__init__.py index e69de29bb2d..3d517583e83 100644 --- a/esphome/components/atc_mithermometer/__init__.py +++ b/esphome/components/atc_mithermometer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "atc_mithermometer" diff --git a/esphome/components/atm90e26/__init__.py b/esphome/components/atm90e26/__init__.py index ac441a9c2d3..b1047e9290f 100644 --- a/esphome/components/atm90e26/__init__.py +++ b/esphome/components/atm90e26/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@danieltwagner"] +DOMAIN = "atm90e26" diff --git a/esphome/components/atm90e32/__init__.py b/esphome/components/atm90e32/__init__.py index 766807872bc..d25f5c1ce94 100644 --- a/esphome/components/atm90e32/__init__.py +++ b/esphome/components/atm90e32/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@circuitsetup", "@descipher"] +DOMAIN = "atm90e32" atm90e32_ns = cg.esphome_ns.namespace("atm90e32") ATM90E32Component = atm90e32_ns.class_("ATM90E32Component", cg.Component) diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 7d4b51db047..ba6544b50b2 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -6,6 +6,7 @@ from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "audio_adc" IS_PLATFORM_COMPONENT = True audio_adc_ns = cg.esphome_ns.namespace("audio_adc") diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index fbaa72014fc..346f1be798a 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -7,6 +7,7 @@ from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "audio_dac" IS_PLATFORM_COMPONENT = True audio_dac_ns = cg.esphome_ns.namespace("audio_dac") diff --git a/esphome/components/audio_http/__init__.py b/esphome/components/audio_http/__init__.py index e69de29bb2d..0126fa0e8bc 100644 --- a/esphome/components/audio_http/__init__.py +++ b/esphome/components/audio_http/__init__.py @@ -0,0 +1 @@ +DOMAIN = "audio_http" diff --git a/esphome/components/axs15231/__init__.py b/esphome/components/axs15231/__init__.py index 3246dbed247..bed826cf8b8 100644 --- a/esphome/components/axs15231/__init__.py +++ b/esphome/components/axs15231/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "axs15231" axs15231_ns = cg.esphome_ns.namespace("axs15231") diff --git a/esphome/components/b_parasite/__init__.py b/esphome/components/b_parasite/__init__.py index e69de29bb2d..147319ba959 100644 --- a/esphome/components/b_parasite/__init__.py +++ b/esphome/components/b_parasite/__init__.py @@ -0,0 +1 @@ +DOMAIN = "b_parasite" diff --git a/esphome/components/ballu/__init__.py b/esphome/components/ballu/__init__.py index e69de29bb2d..d0d8d1b3b21 100644 --- a/esphome/components/ballu/__init__.py +++ b/esphome/components/ballu/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ballu" diff --git a/esphome/components/bang_bang/__init__.py b/esphome/components/bang_bang/__init__.py index 71a87b6ae50..6536dc2c4f7 100644 --- a/esphome/components/bang_bang/__init__.py +++ b/esphome/components/bang_bang/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "bang_bang" diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 1b967e665af..61dc73c73d5 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jhansche"] DEPENDENCIES = ["ble_client"] +DOMAIN = "bedjet" MULTI_CONF = True CONF_BEDJET_ID = "bedjet_id" diff --git a/esphome/components/beken_spi_led_strip/__init__.py b/esphome/components/beken_spi_led_strip/__init__.py index e69de29bb2d..940eabbeb64 100644 --- a/esphome/components/beken_spi_led_strip/__init__.py +++ b/esphome/components/beken_spi_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "beken_spi_led_strip" diff --git a/esphome/components/bh1750/__init__.py b/esphome/components/bh1750/__init__.py index e69de29bb2d..67b3f5ba5ad 100644 --- a/esphome/components/bh1750/__init__.py +++ b/esphome/components/bh1750/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bh1750" diff --git a/esphome/components/bh1900nux/__init__.py b/esphome/components/bh1900nux/__init__.py index e69de29bb2d..b5b6c5eac96 100644 --- a/esphome/components/bh1900nux/__init__.py +++ b/esphome/components/bh1900nux/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bh1900nux" diff --git a/esphome/components/binary/__init__.py b/esphome/components/binary/__init__.py index 3f6a45d28a2..6244d89b8d6 100644 --- a/esphome/components/binary/__init__.py +++ b/esphome/components/binary/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "binary" + binary_ns = cg.esphome_ns.namespace("binary") diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index f5cde2c8e6f..60e20aa1935 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -106,6 +106,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ] +DOMAIN = "binary_sensor" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/binary_sensor_map/__init__.py b/esphome/components/binary_sensor_map/__init__.py index e69de29bb2d..3ac51b590ee 100644 --- a/esphome/components/binary_sensor_map/__init__.py +++ b/esphome/components/binary_sensor_map/__init__.py @@ -0,0 +1 @@ +DOMAIN = "binary_sensor_map" diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index e64237c95ab..1db8f5d54fc 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -35,6 +35,7 @@ from .boards import BK72XX_BOARD_PINS, BK72XX_BOARDS CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["libretiny"] +DOMAIN = "bk72xx" IS_TARGET_PLATFORM = True COMPONENT_DATA = LibreTinyComponent( diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 38cba56c623..44c66919bb0 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -38,6 +38,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "bk72xx_ble" _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 55013d40876..abef3c7752b 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -44,6 +44,7 @@ CONF_BK72XX_BLE_ID = "bk72xx_ble_id" DEPENDENCIES = ["bk72xx"] AUTO_LOAD = ["ble_device_base", "bk72xx_ble"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "bk72xx_ble_tracker" ble_device_base.register_hub_provider("bk72xx_ble_tracker") diff --git a/esphome/components/bl0906/__init__.py b/esphome/components/bl0906/__init__.py index b0777926044..b840c0ee99e 100644 --- a/esphome/components/bl0906/__init__.py +++ b/esphome/components/bl0906/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@athom-tech", "@tarontop", "@jesserockz"] +DOMAIN = "bl0906" diff --git a/esphome/components/bl0939/__init__.py b/esphome/components/bl0939/__init__.py index 9bd4598dd26..a2c2dd4c7b3 100644 --- a/esphome/components/bl0939/__init__.py +++ b/esphome/components/bl0939/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ziceva"] +DOMAIN = "bl0939" diff --git a/esphome/components/bl0940/__init__.py b/esphome/components/bl0940/__init__.py index 066c2818b6d..953824be51f 100644 --- a/esphome/components/bl0940/__init__.py +++ b/esphome/components/bl0940/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@tobias-", "@dan-s-github"] +DOMAIN = "bl0940" CONF_BL0940_ID = "bl0940_id" bl0940_ns = cg.esphome_ns.namespace("bl0940") diff --git a/esphome/components/bl0942/__init__.py b/esphome/components/bl0942/__init__.py index 38b68d84b5f..89141daae39 100644 --- a/esphome/components/bl0942/__init__.py +++ b/esphome/components/bl0942/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@dbuezas", "@dwmw2"] +DOMAIN = "bl0942" diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 810f4b66e3d..b783ef1c153 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -22,6 +22,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble_client"] CODEOWNERS = ["@buxtronix", "@clydebarrow"] DEPENDENCIES = ["esp32_ble_tracker"] +DOMAIN = "ble_client" CONF_DESCRIPTOR_UUID = "descriptor_uuid" CONF_ON_NOTIFY = "on_notify" diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 43ec7367277..e8283457c6c 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -41,6 +41,7 @@ from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "ble_device_base" CONF_BLE_HUB_ID = "ble_hub_id" diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index 40983c4dbb0..54f111e4af9 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -21,6 +21,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["zephyr_ble_server", "uart"] CODEOWNERS = ["@tomaszduda23"] +DOMAIN = "ble_nus" ble_nus_ns = cg.esphome_ns.namespace("ble_nus") BLENUS = ble_nus_ns.class_("BLENUS", cg.Component, UARTComponent) diff --git a/esphome/components/ble_presence/__init__.py b/esphome/components/ble_presence/__init__.py index e69de29bb2d..0be32a6e956 100644 --- a/esphome/components/ble_presence/__init__.py +++ b/esphome/components/ble_presence/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ble_presence" diff --git a/esphome/components/ble_rssi/__init__.py b/esphome/components/ble_rssi/__init__.py index e69de29bb2d..2d31b9aec25 100644 --- a/esphome/components/ble_rssi/__init__.py +++ b/esphome/components/ble_rssi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ble_rssi" diff --git a/esphome/components/ble_scanner/__init__.py b/esphome/components/ble_scanner/__init__.py index e69de29bb2d..e687d58e65a 100644 --- a/esphome/components/ble_scanner/__init__.py +++ b/esphome/components/ble_scanner/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ble_scanner" diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index c87ad7f5957..6332ea6dc12 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -58,6 +58,7 @@ _HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] +DOMAIN = "bluetooth_proxy" _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/bm8563/__init__.py b/esphome/components/bm8563/__init__.py index 20254a8b006..c3d1fd8cf61 100644 --- a/esphome/components/bm8563/__init__.py +++ b/esphome/components/bm8563/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@abmantis"] +DOMAIN = "bm8563" diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index 4b079def64d..8d54cbed2a0 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -20,6 +20,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "bme280_base" bme280_ns = cg.esphome_ns.namespace("bme280_base") BME280Oversampling = bme280_ns.enum("BME280Oversampling") diff --git a/esphome/components/bme280_i2c/__init__.py b/esphome/components/bme280_i2c/__init__.py index e69de29bb2d..d57004f5fbd 100644 --- a/esphome/components/bme280_i2c/__init__.py +++ b/esphome/components/bme280_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bme280_i2c" diff --git a/esphome/components/bme280_spi/__init__.py b/esphome/components/bme280_spi/__init__.py index e69de29bb2d..5ca2ba45953 100644 --- a/esphome/components/bme280_spi/__init__.py +++ b/esphome/components/bme280_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bme280_spi" diff --git a/esphome/components/bme680/__init__.py b/esphome/components/bme680/__init__.py index e69de29bb2d..1ebff77202e 100644 --- a/esphome/components/bme680/__init__.py +++ b/esphome/components/bme680/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bme680" diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 35df2a7ea3f..190994b2f8a 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -9,6 +9,7 @@ CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensor", "text_sensor"] CONFLICTS_WITH = ["bme68x_bsec2"] +DOMAIN = "bme680_bsec" MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index 1da3bdbd6c6..8c50f6e165a 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -9,6 +9,7 @@ import esphome.config_validation as cv from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] +DOMAIN = "bme68x_bsec2_i2c" AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] diff --git a/esphome/components/bmi160/__init__.py b/esphome/components/bmi160/__init__.py index 49b6d0252a3..75386e98576 100644 --- a/esphome/components/bmi160/__init__.py +++ b/esphome/components/bmi160/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@flaviut"] +DOMAIN = "bmi160" diff --git a/esphome/components/bmi270/__init__.py b/esphome/components/bmi270/__init__.py index 0e67e41a0e0..499e88b5b12 100644 --- a/esphome/components/bmi270/__init__.py +++ b/esphome/components/bmi270/__init__.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.motion import MotionComponent CODEOWNERS = ["@clydebarrow"] +DOMAIN = "bmi270" CONF_BMI270_ID = "bmi270_id" # C++ namespace / class diff --git a/esphome/components/bmp085/__init__.py b/esphome/components/bmp085/__init__.py index e69de29bb2d..51355f5bc0b 100644 --- a/esphome/components/bmp085/__init__.py +++ b/esphome/components/bmp085/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp085" diff --git a/esphome/components/bmp280/__init__.py b/esphome/components/bmp280/__init__.py index e69de29bb2d..99130dd4e1e 100644 --- a/esphome/components/bmp280/__init__.py +++ b/esphome/components/bmp280/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp280" diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index a956ed34f6e..68d9145378b 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -17,6 +17,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@ademuri"] +DOMAIN = "bmp280_base" bmp280_ns = cg.esphome_ns.namespace("bmp280_base") BMP280Oversampling = bmp280_ns.enum("BMP280Oversampling") diff --git a/esphome/components/bmp280_i2c/__init__.py b/esphome/components/bmp280_i2c/__init__.py index e69de29bb2d..38fc9582e55 100644 --- a/esphome/components/bmp280_i2c/__init__.py +++ b/esphome/components/bmp280_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp280_i2c" diff --git a/esphome/components/bmp280_spi/__init__.py b/esphome/components/bmp280_spi/__init__.py index e69de29bb2d..2b67436333f 100644 --- a/esphome/components/bmp280_spi/__init__.py +++ b/esphome/components/bmp280_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp280_spi" diff --git a/esphome/components/bmp3xx/__init__.py b/esphome/components/bmp3xx/__init__.py index e69de29bb2d..076eafa8e71 100644 --- a/esphome/components/bmp3xx/__init__.py +++ b/esphome/components/bmp3xx/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp3xx" diff --git a/esphome/components/bmp3xx_base/__init__.py b/esphome/components/bmp3xx_base/__init__.py index 6d0618e136a..de9b227aac2 100644 --- a/esphome/components/bmp3xx_base/__init__.py +++ b/esphome/components/bmp3xx_base/__init__.py @@ -17,6 +17,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@martgras", "@latonita"] +DOMAIN = "bmp3xx_base" bmp3xx_ns = cg.esphome_ns.namespace("bmp3xx_base") Oversampling = bmp3xx_ns.enum("Oversampling") diff --git a/esphome/components/bmp3xx_i2c/__init__.py b/esphome/components/bmp3xx_i2c/__init__.py index e69de29bb2d..87cf0e52611 100644 --- a/esphome/components/bmp3xx_i2c/__init__.py +++ b/esphome/components/bmp3xx_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp3xx_i2c" diff --git a/esphome/components/bmp3xx_spi/__init__.py b/esphome/components/bmp3xx_spi/__init__.py index e69de29bb2d..6e04124ccbd 100644 --- a/esphome/components/bmp3xx_spi/__init__.py +++ b/esphome/components/bmp3xx_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp3xx_spi" diff --git a/esphome/components/bmp581/__init__.py b/esphome/components/bmp581/__init__.py index e69de29bb2d..ad0da1d8f7f 100644 --- a/esphome/components/bmp581/__init__.py +++ b/esphome/components/bmp581/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp581" diff --git a/esphome/components/bmp581_base/__init__.py b/esphome/components/bmp581_base/__init__.py index 5d031a8208a..18c18a2e9b9 100644 --- a/esphome/components/bmp581_base/__init__.py +++ b/esphome/components/bmp581_base/__init__.py @@ -19,6 +19,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kahrendt", "@danielkent-net"] +DOMAIN = "bmp581_base" bmp581_ns = cg.esphome_ns.namespace("bmp581_base") diff --git a/esphome/components/bmp581_i2c/__init__.py b/esphome/components/bmp581_i2c/__init__.py index e69de29bb2d..a5c70d3d425 100644 --- a/esphome/components/bmp581_i2c/__init__.py +++ b/esphome/components/bmp581_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp581_i2c" diff --git a/esphome/components/bmp581_spi/__init__.py b/esphome/components/bmp581_spi/__init__.py index e69de29bb2d..d8f525adce0 100644 --- a/esphome/components/bmp581_spi/__init__.py +++ b/esphome/components/bmp581_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp581_spi" diff --git a/esphome/components/bp1658cj/__init__.py b/esphome/components/bp1658cj/__init__.py index b45272d2eaf..76b3d427a45 100644 --- a/esphome/components/bp1658cj/__init__.py +++ b/esphome/components/bp1658cj/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] +DOMAIN = "bp1658cj" MULTI_CONF = True CONF_MAX_POWER_COLOR_CHANNELS = "max_power_color_channels" diff --git a/esphome/components/bp5758d/__init__.py b/esphome/components/bp5758d/__init__.py index fa4e8a231bb..bd6b6ca1106 100644 --- a/esphome/components/bp5758d/__init__.py +++ b/esphome/components/bp5758d/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] +DOMAIN = "bp5758d" MULTI_CONF = True AUTO_LOAD = ["output"] diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index ed0cbaa9e14..fe78162c650 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@nagyrobi"] AUTO_LOAD = ["ble_device_base"] +DOMAIN = "bthome_mithermometer" bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 342f4515093..f27475bdca3 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -28,6 +28,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] +DOMAIN = "button" IS_PLATFORM_COMPONENT = True DEVICE_CLASSES = [ diff --git a/esphome/components/bytebuffer/__init__.py b/esphome/components/bytebuffer/__init__.py index 3c7c695118b..df0c76338c3 100644 --- a/esphome/components/bytebuffer/__init__.py +++ b/esphome/components/bytebuffer/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "bytebuffer" # Allows bytebuffer to be configured in yaml, to allow use of the C++ api. diff --git a/esphome/components/camera/__init__.py b/esphome/components/camera/__init__.py index a19f7707afe..0cb916ac49a 100644 --- a/esphome/components/camera/__init__.py +++ b/esphome/components/camera/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DT-art1", "@bdraco"] +DOMAIN = "camera" diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 344248fcf31..f63c316126b 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType CODEOWNERS = ["@DT-art1"] +DOMAIN = "camera_encoder" AUTO_LOAD = ["camera"] diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index b70bac72656..51bda0c75df 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -10,6 +10,7 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@mvturnho", "@danielschramm"] +DOMAIN = "canbus" IS_PLATFORM_COMPONENT = True CONF_CAN_ID = "can_id" diff --git a/esphome/components/cap1188/__init__.py b/esphome/components/cap1188/__init__.py index eff0a051639..24f6e627d2c 100644 --- a/esphome/components/cap1188/__init__.py +++ b/esphome/components/cap1188/__init__.py @@ -11,6 +11,7 @@ CONF_ALLOW_MULTIPLE_TOUCHES = "allow_multiple_touches" DEPENDENCIES = ["i2c"] AUTO_LOAD = ["binary_sensor", "output"] CODEOWNERS = ["@mreditor97"] +DOMAIN = "cap1188" cap1188_ns = cg.esphome_ns.namespace("cap1188") CONF_CAP1188_ID = "cap1188_id" diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index e490d89062a..fd4daab6dfd 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -34,6 +34,7 @@ def AUTO_LOAD() -> list[str]: DEPENDENCIES = ["wifi"] CODEOWNERS = ["@esphome/core"] +DOMAIN = "captive_portal" captive_portal_ns = cg.esphome_ns.namespace("captive_portal") CaptivePortal = captive_portal_ns.class_("CaptivePortal", cg.Component) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 65d5d6d9f1d..8b9e91f833a 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( CODEOWNERS = ["@lygris", "@gabest11"] DEPENDENCIES = ["spi"] +DOMAIN = "cc1101" MULTI_CONF = True ns = cg.esphome_ns.namespace("cc1101") diff --git a/esphome/components/ccs811/__init__.py b/esphome/components/ccs811/__init__.py index e69de29bb2d..4a86948d394 100644 --- a/esphome/components/ccs811/__init__.py +++ b/esphome/components/ccs811/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ccs811" diff --git a/esphome/components/cd74hc4067/__init__.py b/esphome/components/cd74hc4067/__init__.py index 5f7778e1861..655b8db819d 100644 --- a/esphome/components/cd74hc4067/__init__.py +++ b/esphome/components/cd74hc4067/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["sensor", "voltage_sampler"] CODEOWNERS = ["@asoehlke"] +DOMAIN = "cd74hc4067" MULTI_CONF = True cd74hc4067_ns = cg.esphome_ns.namespace("cd74hc4067") diff --git a/esphome/components/cdc_acm_uart/__init__.py b/esphome/components/cdc_acm_uart/__init__.py index 516af848564..9d5c0a3e951 100644 --- a/esphome/components/cdc_acm_uart/__init__.py +++ b/esphome/components/cdc_acm_uart/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@kbx81"] +DOMAIN = "cdc_acm_uart" diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 7f0c5bb95e6..5110503bf84 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesterret", "@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "ch422g" MULTI_CONF = True ch422g_ns = cg.esphome_ns.namespace("ch422g") diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 9fbf3ea515e..45d8423beb6 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -19,6 +19,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@dwmw2"] DEPENDENCIES = ["i2c"] +DOMAIN = "ch423" MULTI_CONF = True ch423_ns = cg.esphome_ns.namespace("ch423") diff --git a/esphome/components/chsc6x/__init__.py b/esphome/components/chsc6x/__init__.py index 80f3a0b33ec..bbdc8707732 100644 --- a/esphome/components/chsc6x/__init__.py +++ b/esphome/components/chsc6x/__init__.py @@ -1,2 +1,3 @@ CODEOWNERS = ["@kkosik20"] DEPENDENCIES = ["i2c"] +DOMAIN = "chsc6x" diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index e64092d30e5..17aeb1467d3 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -62,6 +62,7 @@ from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True CODEOWNERS = ["@esphome/core"] +DOMAIN = "climate" climate_ns = cg.esphome_ns.namespace("climate") Climate = climate_ns.class_("Climate", cg.EntityBase) diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index f5a31b31c4f..f4b43e3a523 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -19,6 +19,7 @@ CONF_SUPPORTS_HEAT_COOL = "supports_heat_cool" DEPENDENCIES = ["remote_transmitter"] AUTO_LOAD = ["sensor", "remote_base"] CODEOWNERS = ["@glmnet"] +DOMAIN = "climate_ir" climate_ir_ns = cg.esphome_ns.namespace("climate_ir") ClimateIR = climate_ir_ns.class_( diff --git a/esphome/components/climate_ir_lg/__init__.py b/esphome/components/climate_ir_lg/__init__.py index e69de29bb2d..b62e9d17fb1 100644 --- a/esphome/components/climate_ir_lg/__init__.py +++ b/esphome/components/climate_ir_lg/__init__.py @@ -0,0 +1 @@ +DOMAIN = "climate_ir_lg" diff --git a/esphome/components/cm1106/__init__.py b/esphome/components/cm1106/__init__.py index fa3c3f19252..24203304ae3 100644 --- a/esphome/components/cm1106/__init__.py +++ b/esphome/components/cm1106/__init__.py @@ -1 +1,3 @@ """CM1106 component for ESPHome.""" + +DOMAIN = "cm1106" diff --git a/esphome/components/color/__init__.py b/esphome/components/color/__init__.py index 70240eff07b..49978c29c2e 100644 --- a/esphome/components/color/__init__.py +++ b/esphome/components/color/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType ColorStruct = cg.esphome_ns.struct("Color") +DOMAIN = "color" INSTANCE_TYPE = ColorStruct MULTI_CONF = True diff --git a/esphome/components/color_temperature/__init__.py b/esphome/components/color_temperature/__init__.py index e69de29bb2d..2c38fe6fc6b 100644 --- a/esphome/components/color_temperature/__init__.py +++ b/esphome/components/color_temperature/__init__.py @@ -0,0 +1 @@ +DOMAIN = "color_temperature" diff --git a/esphome/components/combination/__init__.py b/esphome/components/combination/__init__.py index e69de29bb2d..7625bf2d023 100644 --- a/esphome/components/combination/__init__.py +++ b/esphome/components/combination/__init__.py @@ -0,0 +1 @@ +DOMAIN = "combination" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6e98162baae..6eeab52e297 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -1,6 +1,7 @@ """Constants used by esphome components.""" CODEOWNERS = ["@esphome/core"] +DOMAIN = "const" BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" diff --git a/esphome/components/coolix/__init__.py b/esphome/components/coolix/__init__.py index e69de29bb2d..e916a942384 100644 --- a/esphome/components/coolix/__init__.py +++ b/esphome/components/coolix/__init__.py @@ -0,0 +1 @@ +DOMAIN = "coolix" diff --git a/esphome/components/copy/__init__.py b/esphome/components/copy/__init__.py index 75948946501..917991b2fc9 100644 --- a/esphome/components/copy/__init__.py +++ b/esphome/components/copy/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg CODEOWNERS = ["@OttoWinter"] +DOMAIN = "copy" copy_ns = cg.esphome_ns.namespace("copy") diff --git a/esphome/components/counter/__init__.py b/esphome/components/counter/__init__.py index c58ce8a01e8..a85a88d6e64 100644 --- a/esphome/components/counter/__init__.py +++ b/esphome/components/counter/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "counter" diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 0f5aa9b4503..c570f3d4ca2 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -62,6 +62,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ] +DOMAIN = "cover" _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/cs5460a/__init__.py b/esphome/components/cs5460a/__init__.py index e69de29bb2d..2ea88409a90 100644 --- a/esphome/components/cs5460a/__init__.py +++ b/esphome/components/cs5460a/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cs5460a" diff --git a/esphome/components/cse7761/__init__.py b/esphome/components/cse7761/__init__.py index e69de29bb2d..3db7e9a1d48 100644 --- a/esphome/components/cse7761/__init__.py +++ b/esphome/components/cse7761/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cse7761" diff --git a/esphome/components/cse7766/__init__.py b/esphome/components/cse7766/__init__.py index e69de29bb2d..df45f6395c2 100644 --- a/esphome/components/cse7766/__init__.py +++ b/esphome/components/cse7766/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cse7766" diff --git a/esphome/components/cst226/__init__.py b/esphome/components/cst226/__init__.py index 847e44dbdaf..5bccdc1cba0 100644 --- a/esphome/components/cst226/__init__.py +++ b/esphome/components/cst226/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst226" cst226_ns = cg.esphome_ns.namespace("cst226") diff --git a/esphome/components/cst328/__init__.py b/esphome/components/cst328/__init__.py index 374df648982..6b103989694 100644 --- a/esphome/components/cst328/__init__.py +++ b/esphome/components/cst328/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst328" cst328_ns = cg.esphome_ns.namespace("cst328") diff --git a/esphome/components/cst816/__init__.py b/esphome/components/cst816/__init__.py index 674a80b7c19..8d6e15abc58 100644 --- a/esphome/components/cst816/__init__.py +++ b/esphome/components/cst816/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst816" cst816_ns = cg.esphome_ns.namespace("cst816") diff --git a/esphome/components/cst9220/__init__.py b/esphome/components/cst9220/__init__.py index f97c8944efa..e3de3243e07 100644 --- a/esphome/components/cst9220/__init__.py +++ b/esphome/components/cst9220/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst9220" cst9220_ns = cg.esphome_ns.namespace("cst9220") diff --git a/esphome/components/ct_clamp/__init__.py b/esphome/components/ct_clamp/__init__.py index e69de29bb2d..f7ab2905b28 100644 --- a/esphome/components/ct_clamp/__init__.py +++ b/esphome/components/ct_clamp/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ct_clamp" diff --git a/esphome/components/current_based/__init__.py b/esphome/components/current_based/__init__.py index e7f41154b70..0ef871eeed7 100644 --- a/esphome/components/current_based/__init__.py +++ b/esphome/components/current_based/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@djwmarcx"] +DOMAIN = "current_based" diff --git a/esphome/components/custom/__init__.py b/esphome/components/custom/__init__.py index e69de29bb2d..4eb4f32c12c 100644 --- a/esphome/components/custom/__init__.py +++ b/esphome/components/custom/__init__.py @@ -0,0 +1 @@ +DOMAIN = "custom" diff --git a/esphome/components/custom_component/__init__.py b/esphome/components/custom_component/__init__.py index 982153414d3..395f140d6c4 100644 --- a/esphome/components/custom_component/__init__.py +++ b/esphome/components/custom_component/__init__.py @@ -1,5 +1,6 @@ import esphome.config_validation as cv +DOMAIN = "custom_component" MULTI_CONF = True CONFIG_SCHEMA = cv.invalid( diff --git a/esphome/components/cwww/__init__.py b/esphome/components/cwww/__init__.py index e69de29bb2d..2da0e50fa0d 100644 --- a/esphome/components/cwww/__init__.py +++ b/esphome/components/cwww/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cwww" diff --git a/esphome/components/d01/__init__.py b/esphome/components/d01/__init__.py index e69de29bb2d..45595aada31 100644 --- a/esphome/components/d01/__init__.py +++ b/esphome/components/d01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "d01" diff --git a/esphome/components/dac7678/__init__.py b/esphome/components/dac7678/__init__.py index 668cc87cece..4ee9f1f5413 100644 --- a/esphome/components/dac7678/__init__.py +++ b/esphome/components/dac7678/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["output"] CODEOWNERS = ["@NickB1"] DEPENDENCIES = ["i2c"] +DOMAIN = "dac7678" MULTI_CONF = True dac7678_ns = cg.esphome_ns.namespace("dac7678") diff --git a/esphome/components/daikin/__init__.py b/esphome/components/daikin/__init__.py index e69de29bb2d..cc5617d4d53 100644 --- a/esphome/components/daikin/__init__.py +++ b/esphome/components/daikin/__init__.py @@ -0,0 +1 @@ +DOMAIN = "daikin" diff --git a/esphome/components/daikin_arc/__init__.py b/esphome/components/daikin_arc/__init__.py index f9164fb02b4..39d99e9da0e 100644 --- a/esphome/components/daikin_arc/__init__.py +++ b/esphome/components/daikin_arc/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@MagicBear"] +DOMAIN = "daikin_arc" diff --git a/esphome/components/daikin_brc/__init__.py b/esphome/components/daikin_brc/__init__.py index 69002c015f7..c9352db0733 100644 --- a/esphome/components/daikin_brc/__init__.py +++ b/esphome/components/daikin_brc/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@hagak"] +DOMAIN = "daikin_brc" diff --git a/esphome/components/dallas/__init__.py b/esphome/components/dallas/__init__.py index 6c2a9d830e6..7d55ae91299 100644 --- a/esphome/components/dallas/__init__.py +++ b/esphome/components/dallas/__init__.py @@ -1,5 +1,6 @@ import esphome.config_validation as cv +DOMAIN = "dallas" MULTI_CONF = True CONFIG_SCHEMA = cv.invalid( diff --git a/esphome/components/dallas_temp/__init__.py b/esphome/components/dallas_temp/__init__.py index 3f73044ca8a..6a7af3ad4d9 100644 --- a/esphome/components/dallas_temp/__init__.py +++ b/esphome/components/dallas_temp/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ssieb"] +DOMAIN = "dallas_temp" diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index c0d7d0aa624..01d0eef857f 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ADDRESS, CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] +DOMAIN = "daly_bms" MULTI_CONF = True DEPENDENCIES = ["uart"] diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 906e9762bfa..aca0901d2b8 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -22,6 +22,7 @@ dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") # is enabled DEPENDENCIES = ["api"] CODEOWNERS = ["@esphome/core"] +DOMAIN = "dashboard_import" def validate_import_url(value: Any) -> str: diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 3a33e5e0044..de79c05a9f0 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -33,6 +33,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] +DOMAIN = "datetime" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 898d7d6c0e7..889cfaaa682 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -17,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger"] +DOMAIN = "debug" CONF_DEBUG_ID = "debug_id" debug_ns = cg.esphome_ns.namespace("debug") diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 06286933b90..2b820c9be6c 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -491,6 +491,7 @@ for _name, _call in ( ) +DOMAIN = "deep_sleep" FILTER_SOURCE_FILES = filter_source_files_from_platform( { "deep_sleep_esp32.cpp": { diff --git a/esphome/components/delonghi/__init__.py b/esphome/components/delonghi/__init__.py index 0a81eb2da75..ba66077c840 100644 --- a/esphome/components/delonghi/__init__.py +++ b/esphome/components/delonghi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@grob6000"] +DOMAIN = "delonghi" diff --git a/esphome/components/demo/__init__.py b/esphome/components/demo/__init__.py index 75feaa65af1..8f2d5ded203 100644 --- a/esphome/components/demo/__init__.py +++ b/esphome/components/demo/__init__.py @@ -76,6 +76,7 @@ AUTO_LOAD = [ "text_sensor", "valve", ] +DOMAIN = "demo" demo_ns = cg.esphome_ns.namespace("demo") DemoAlarmControlPanel = demo_ns.class_( diff --git a/esphome/components/dew_point/__init__.py b/esphome/components/dew_point/__init__.py index 3b852436c3c..ab61f55f0b2 100644 --- a/esphome/components/dew_point/__init__.py +++ b/esphome/components/dew_point/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@CFlix"] +DOMAIN = "dew_point" diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 296afcd3183..d4ec9d6590a 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -9,6 +9,7 @@ from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] +DOMAIN = "dfplayer" dfplayer_ns = cg.esphome_ns.namespace("dfplayer") DFPlayer = dfplayer_ns.class_("DFPlayer", cg.Component) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 6334e10ac5f..d4e7aed91d8 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@niklasweber"] DEPENDENCIES = ["uart"] +DOMAIN = "dfrobot_sen0395" MULTI_CONF = True dfrobot_sen0395_ns = cg.esphome_ns.namespace("dfrobot_sen0395") diff --git a/esphome/components/dht/__init__.py b/esphome/components/dht/__init__.py index 71a87b6ae50..9def4211030 100644 --- a/esphome/components/dht/__init__.py +++ b/esphome/components/dht/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "dht" diff --git a/esphome/components/dht12/__init__.py b/esphome/components/dht12/__init__.py index e69de29bb2d..0078da33000 100644 --- a/esphome/components/dht12/__init__.py +++ b/esphome/components/dht12/__init__.py @@ -0,0 +1 @@ +DOMAIN = "dht12" diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index bb493ae8d33..9d046dbc19a 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( ) CODEOWNERS = ["@numo68"] +DOMAIN = "display_menu_base" display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 00a1694cc32..09423c7d8c5 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] +DOMAIN = "dlms_meter" CONF_DLMS_METER_ID = "dlms_meter_id" CONF_DECRYPTION_KEY = "decryption_key" diff --git a/esphome/components/dps310/__init__.py b/esphome/components/dps310/__init__.py index e69de29bb2d..52f2a4105e0 100644 --- a/esphome/components/dps310/__init__.py +++ b/esphome/components/dps310/__init__.py @@ -0,0 +1 @@ +DOMAIN = "dps310" diff --git a/esphome/components/ds1307/__init__.py b/esphome/components/ds1307/__init__.py index e69de29bb2d..fa275ded95e 100644 --- a/esphome/components/ds1307/__init__.py +++ b/esphome/components/ds1307/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ds1307" diff --git a/esphome/components/ds1603l/__init__.py b/esphome/components/ds1603l/__init__.py index e69de29bb2d..81c65f4fd3d 100644 --- a/esphome/components/ds1603l/__init__.py +++ b/esphome/components/ds1603l/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ds1603l" diff --git a/esphome/components/ds2484/__init__.py b/esphome/components/ds2484/__init__.py index 3d9f24ff197..f101dedc012 100644 --- a/esphome/components/ds2484/__init__.py +++ b/esphome/components/ds2484/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@mrk-its"] +DOMAIN = "ds2484" diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index a2e2a87ed0c..3a2cb34747a 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] +DOMAIN = "ds248x" MULTI_CONF = True DEPENDENCIES = ["i2c"] diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 96a1e756685..176192b241c 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@PolarGoose"] +DOMAIN = "dsmr" MULTI_CONF = True diff --git a/esphome/components/duty_cycle/__init__.py b/esphome/components/duty_cycle/__init__.py index e69de29bb2d..8d11f044883 100644 --- a/esphome/components/duty_cycle/__init__.py +++ b/esphome/components/duty_cycle/__init__.py @@ -0,0 +1 @@ +DOMAIN = "duty_cycle" diff --git a/esphome/components/duty_time/__init__.py b/esphome/components/duty_time/__init__.py index b708cee80b2..258253e4eb0 100644 --- a/esphome/components/duty_time/__init__.py +++ b/esphome/components/duty_time/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@dudanov"] +DOMAIN = "duty_time" diff --git a/esphome/components/e131/__init__.py b/esphome/components/e131/__init__.py index 3b1eb99e60b..dbe9b1e45c1 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] +DOMAIN = "e131" e131_ns = cg.esphome_ns.namespace("e131") E131AddressableLightEffect = e131_ns.class_( diff --git a/esphome/components/ee895/__init__.py b/esphome/components/ee895/__init__.py index e69de29bb2d..82043233c31 100644 --- a/esphome/components/ee895/__init__.py +++ b/esphome/components/ee895/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ee895" diff --git a/esphome/components/ektf2232/__init__.py b/esphome/components/ektf2232/__init__.py index e69de29bb2d..03f83e2a58e 100644 --- a/esphome/components/ektf2232/__init__.py +++ b/esphome/components/ektf2232/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ektf2232" diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 6cd122fb96e..024ff3c8937 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION from esphome.types import ConfigType CODEOWNERS = ["@ellull"] +DOMAIN = "emc2101" DEPENDENCIES = ["i2c"] diff --git a/esphome/components/emmeti/__init__.py b/esphome/components/emmeti/__init__.py index e69de29bb2d..ec6d2835b0d 100644 --- a/esphome/components/emmeti/__init__.py +++ b/esphome/components/emmeti/__init__.py @@ -0,0 +1 @@ +DOMAIN = "emmeti" diff --git a/esphome/components/endstop/__init__.py b/esphome/components/endstop/__init__.py index e69de29bb2d..c064448409e 100644 --- a/esphome/components/endstop/__init__.py +++ b/esphome/components/endstop/__init__.py @@ -0,0 +1 @@ +DOMAIN = "endstop" diff --git a/esphome/components/ens160/__init__.py b/esphome/components/ens160/__init__.py index e69de29bb2d..e59458427d9 100644 --- a/esphome/components/ens160/__init__.py +++ b/esphome/components/ens160/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens160" diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 6da0d10af90..b41575c7171 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -22,6 +22,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@vincentscode", "@latonita"] +DOMAIN = "ens160_base" ens160_ns = cg.esphome_ns.namespace("ens160_base") diff --git a/esphome/components/ens160_i2c/__init__.py b/esphome/components/ens160_i2c/__init__.py index e69de29bb2d..605acf0f537 100644 --- a/esphome/components/ens160_i2c/__init__.py +++ b/esphome/components/ens160_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens160_i2c" diff --git a/esphome/components/ens160_spi/__init__.py b/esphome/components/ens160_spi/__init__.py index e69de29bb2d..88ad91e6328 100644 --- a/esphome/components/ens160_spi/__init__.py +++ b/esphome/components/ens160_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens160_spi" diff --git a/esphome/components/ens210/__init__.py b/esphome/components/ens210/__init__.py index e69de29bb2d..58f5d4f25f3 100644 --- a/esphome/components/ens210/__init__.py +++ b/esphome/components/ens210/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens210" diff --git a/esphome/components/epaper_spi/__init__.py b/esphome/components/epaper_spi/__init__.py index f70ffa95208..da3685c263e 100644 --- a/esphome/components/epaper_spi/__init__.py +++ b/esphome/components/epaper_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "epaper_spi" diff --git a/esphome/components/es7210/__init__.py b/esphome/components/es7210/__init__.py index e69de29bb2d..0038d290408 100644 --- a/esphome/components/es7210/__init__.py +++ b/esphome/components/es7210/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es7210" diff --git a/esphome/components/es7243e/__init__.py b/esphome/components/es7243e/__init__.py index e69de29bb2d..6968b84c933 100644 --- a/esphome/components/es7243e/__init__.py +++ b/esphome/components/es7243e/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es7243e" diff --git a/esphome/components/es8156/__init__.py b/esphome/components/es8156/__init__.py index e69de29bb2d..f1e7f4d4e7b 100644 --- a/esphome/components/es8156/__init__.py +++ b/esphome/components/es8156/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es8156" diff --git a/esphome/components/es8311/__init__.py b/esphome/components/es8311/__init__.py index e69de29bb2d..975291c4a7e 100644 --- a/esphome/components/es8311/__init__.py +++ b/esphome/components/es8311/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es8311" diff --git a/esphome/components/es8388/__init__.py b/esphome/components/es8388/__init__.py index e69de29bb2d..e8e2e361bac 100644 --- a/esphome/components/es8388/__init__.py +++ b/esphome/components/es8388/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es8388" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c825deaefd9..bb6333956ef 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -106,6 +106,7 @@ from .gpio import esp32_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] CODEOWNERS = ["@esphome/core"] +DOMAIN = "esp32" IS_TARGET_PLATFORM = True CONF_ASSERTION_LEVEL = "assertion_level" diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index e9c44284e43..7498228f4c6 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_ble_beacon" esp32_ble_beacon_ns = cg.esphome_ns.namespace("esp32_ble_beacon") ESP32BLEBeacon = esp32_ble_beacon_ns.class_("ESP32BLEBeacon", cg.Component) diff --git a/esphome/components/esp32_ble_client/__init__.py b/esphome/components/esp32_ble_client/__init__.py index 55619f1fc0b..dbb409b17ec 100644 --- a/esphome/components/esp32_ble_client/__init__.py +++ b/esphome/components/esp32_ble_client/__init__.py @@ -4,6 +4,7 @@ from esphome.components import esp32_ble_tracker AUTO_LOAD = ["esp32_ble_tracker"] CODEOWNERS = ["@jesserockz", "@bdraco"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_ble_client" esp32_ble_client_ns = cg.esphome_ns.namespace("esp32_ble_client") BLEClientBase = esp32_ble_client_ns.class_( diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 3c41d22903f..2d4e1ed7e99 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -33,6 +33,7 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["camera"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_camera" esp32_camera_ns = cg.esphome_ns.namespace("esp32_camera") ESP32Camera = esp32_camera_ns.class_("ESP32Camera", cg.PollingComponent, cg.EntityBase) diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index d54d5c69376..c063eb88be5 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@ayufan"] AUTO_LOAD = ["camera"] DEPENDENCIES = ["network"] +DOMAIN = "esp32_camera_web_server" MULTI_CONF = True esp32_camera_web_server_ns = cg.esphome_ns.namespace("esp32_camera_web_server") diff --git a/esphome/components/esp32_can/__init__.py b/esphome/components/esp32_can/__init__.py index e69de29bb2d..eb2b5b2aaa7 100644 --- a/esphome/components/esp32_can/__init__.py +++ b/esphome/components/esp32_can/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_can" diff --git a/esphome/components/esp32_dac/__init__.py b/esphome/components/esp32_dac/__init__.py index e69de29bb2d..6aa72c6456b 100644 --- a/esphome/components/esp32_dac/__init__.py +++ b/esphome/components/esp32_dac/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_dac" diff --git a/esphome/components/esp32_hall/__init__.py b/esphome/components/esp32_hall/__init__.py index e69de29bb2d..a8e97842ae4 100644 --- a/esphome/components/esp32_hall/__init__.py +++ b/esphome/components/esp32_hall/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_hall" diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index d78941ea4c8..f84f9c64642 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -28,6 +28,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_hosted" # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index a213a787786..9d3562ed7bc 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -7,6 +7,7 @@ from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] +DOMAIN = "esp32_rmt" VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} diff --git a/esphome/components/esp32_rmt_led_strip/__init__.py b/esphome/components/esp32_rmt_led_strip/__init__.py index e69de29bb2d..a33110a7d37 100644 --- a/esphome/components/esp32_rmt_led_strip/__init__.py +++ b/esphome/components/esp32_rmt_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_rmt_led_strip" diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index ede6beb9b6e..bdd16bc2eff 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -31,6 +31,7 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["binary_sensor"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_touch" CONF_DEBOUNCE_COUNT = "debounce_count" CONF_DENOISE_GRADE = "denoise_grade" diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 57fac2dc98a..d975d08f25e 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -71,6 +71,7 @@ CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float" _SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]") CODEOWNERS = ["@esphome/core"] +DOMAIN = "esp8266" _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True diff --git a/esphome/components/esp8266_pwm/__init__.py b/esphome/components/esp8266_pwm/__init__.py index e69de29bb2d..741b06bb8bf 100644 --- a/esphome/components/esp8266_pwm/__init__.py +++ b/esphome/components/esp8266_pwm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp8266_pwm" diff --git a/esphome/components/esphome/__init__.py b/esphome/components/esphome/__init__.py new file mode 100644 index 00000000000..8bcc59dd2d1 --- /dev/null +++ b/esphome/components/esphome/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esphome" diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index dc36885f2dc..b69bc4e347f 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -23,6 +23,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["network"] +DOMAIN = "espnow" peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index ff05caca83e..9e12ca3e6e9 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -61,6 +61,7 @@ import esphome.final_validate as fv from esphome.types import ConfigType AUTO_LOAD = ["network"] +DOMAIN = "ethernet" LOGGER = logging.getLogger(__name__) # Key for tracking IP state listener count in CORE.data diff --git a/esphome/components/ethernet_info/__init__.py b/esphome/components/ethernet_info/__init__.py index 312688d3fde..3f72edbc7d1 100644 --- a/esphome/components/ethernet_info/__init__.py +++ b/esphome/components/ethernet_info/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gtjadsonsantos"] +DOMAIN = "ethernet_info" diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index d02863b9ee2..c40130634f9 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -27,6 +27,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@nohat"] +DOMAIN = "event" IS_PLATFORM_COMPONENT = True DEVICE_CLASSES = [ diff --git a/esphome/components/exponential_moving_average/__init__.py b/esphome/components/exponential_moving_average/__init__.py index c58ce8a01e8..7a389785e70 100644 --- a/esphome/components/exponential_moving_average/__init__.py +++ b/esphome/components/exponential_moving_average/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "exponential_moving_average" diff --git a/esphome/components/exposure_notifications/__init__.py b/esphome/components/exposure_notifications/__init__.py index 4f7e698e23b..adcfa83717b 100644 --- a/esphome/components/exposure_notifications/__init__.py +++ b/esphome/components/exposure_notifications/__init__.py @@ -10,6 +10,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] AUTO_LOAD = ["ble_device_base"] +DOMAIN = "exposure_notifications" exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications") ExposureNotification = exposure_notifications_ns.struct("ExposureNotification") diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 504b1ae6794..8994805432c 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -6,7 +6,6 @@ from esphome import git, loader import esphome.config_validation as cv from esphome.const import ( CONF_COMPONENTS, - CONF_EXTERNAL_COMPONENTS, CONF_PASSWORD, CONF_PATH, CONF_REF, @@ -22,7 +21,7 @@ from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) -DOMAIN = CONF_EXTERNAL_COMPONENTS +DOMAIN = "external_components" CONFIG_SCHEMA = cv.ensure_list( diff --git a/esphome/components/ezo/__init__.py b/esphome/components/ezo/__init__.py index e69de29bb2d..60408a40eee 100644 --- a/esphome/components/ezo/__init__.py +++ b/esphome/components/ezo/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ezo" diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 1157ef1fb01..aed071e8cf5 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CODEOWNERS = ["@carlos-sarmiento"] DEPENDENCIES = ["i2c"] +DOMAIN = "ezo_pmp" MULTI_CONF = True CONF_VOLUME_PER_MINUTE = "volume_per_minute" diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index a9064eb18fd..e2376c217cb 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -15,6 +15,7 @@ from esphome.final_validate import full_config from esphome.types import ConfigType CODEOWNERS = ["@anatoly-savchenkov"] +DOMAIN = "factory_reset" factory_reset_ns = cg.esphome_ns.namespace("factory_reset") FactoryResetComponent = factory_reset_ns.class_("FactoryResetComponent", cg.Component) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 526499fe077..ac5469fc42c 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -38,6 +38,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) +DOMAIN = "fan" IS_PLATFORM_COMPONENT = True fan_ns = cg.esphome_ns.namespace("fan") diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index e2fc8578cde..8936627aaab 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -12,6 +12,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] +DOMAIN = "fastled_base" fastled_base_ns = cg.esphome_ns.namespace("fastled_base") FastLEDLightOutput = fastled_base_ns.class_( "FastLEDLightOutput", light.AddressableLight diff --git a/esphome/components/fastled_clockless/__init__.py b/esphome/components/fastled_clockless/__init__.py index e69de29bb2d..05af618c2ee 100644 --- a/esphome/components/fastled_clockless/__init__.py +++ b/esphome/components/fastled_clockless/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fastled_clockless" diff --git a/esphome/components/fastled_spi/__init__.py b/esphome/components/fastled_spi/__init__.py index e69de29bb2d..26451add651 100644 --- a/esphome/components/fastled_spi/__init__.py +++ b/esphome/components/fastled_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fastled_spi" diff --git a/esphome/components/feedback/__init__.py b/esphome/components/feedback/__init__.py index 9ae2df986db..cfac77e7974 100644 --- a/esphome/components/feedback/__init__.py +++ b/esphome/components/feedback/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ianchi"] +DOMAIN = "feedback" diff --git a/esphome/components/file/__init__.py b/esphome/components/file/__init__.py index f70ffa95208..e98751dac5b 100644 --- a/esphome/components/file/__init__.py +++ b/esphome/components/file/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "file" diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 0266a9caad5..0e522ced258 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -26,6 +26,7 @@ from esphome.const import ( CODEOWNERS = ["@OnFreund", "@loongyh", "@alexborro"] DEPENDENCIES = ["uart"] AUTO_LOAD = ["binary_sensor", "sensor"] +DOMAIN = "fingerprint_grow" MULTI_CONF = True CONF_FINGERPRINT_GROW_ID = "fingerprint_grow_id" diff --git a/esphome/components/fs3000/__init__.py b/esphome/components/fs3000/__init__.py index e69de29bb2d..c1a6b39d9bc 100644 --- a/esphome/components/fs3000/__init__.py +++ b/esphome/components/fs3000/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fs3000" diff --git a/esphome/components/ft5x06/__init__.py b/esphome/components/ft5x06/__init__.py index dceea71dd07..9770f30e435 100644 --- a/esphome/components/ft5x06/__init__.py +++ b/esphome/components/ft5x06/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "ft5x06" ft5x06_ns = cg.esphome_ns.namespace("ft5x06") diff --git a/esphome/components/ft63x6/__init__.py b/esphome/components/ft63x6/__init__.py index b6d7d3580ed..565da54fe16 100644 --- a/esphome/components/ft63x6/__init__.py +++ b/esphome/components/ft63x6/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gpambrozio"] +DOMAIN = "ft63x6" diff --git a/esphome/components/fujitsu_general/__init__.py b/esphome/components/fujitsu_general/__init__.py index e69de29bb2d..fdb21576be4 100644 --- a/esphome/components/fujitsu_general/__init__.py +++ b/esphome/components/fujitsu_general/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fujitsu_general" diff --git a/esphome/components/gcja5/__init__.py b/esphome/components/gcja5/__init__.py index 122ffaf6edf..9df2024e2a4 100644 --- a/esphome/components/gcja5/__init__.py +++ b/esphome/components/gcja5/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gcormier"] +DOMAIN = "gcja5" diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index f98af3f8634..e0ede6136dc 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] +DOMAIN = "gdk101" DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/gl_r01_i2c/__init__.py b/esphome/components/gl_r01_i2c/__init__.py index e69de29bb2d..23c5e19b18c 100644 --- a/esphome/components/gl_r01_i2c/__init__.py +++ b/esphome/components/gl_r01_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "gl_r01_i2c" diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 8adf4e983ca..a35e2280455 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -12,6 +12,7 @@ from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "globals" globals_ns = cg.esphome_ns.namespace("globals") GlobalsComponent = globals_ns.class_("GlobalsComponent", cg.Component) RestoringGlobalsComponent = globals_ns.class_( diff --git a/esphome/components/gp2y1010au0f/__init__.py b/esphome/components/gp2y1010au0f/__init__.py index e69de29bb2d..36c643103c7 100644 --- a/esphome/components/gp2y1010au0f/__init__.py +++ b/esphome/components/gp2y1010au0f/__init__.py @@ -0,0 +1 @@ +DOMAIN = "gp2y1010au0f" diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 17c88b68750..583b1d15689 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@sebydocky"] DEPENDENCIES = ["i2c"] +DOMAIN = "gp8403" MULTI_CONF = True gp8403_ns = cg.esphome_ns.namespace("gp8403") diff --git a/esphome/components/gpio/__init__.py b/esphome/components/gpio/__init__.py index 07ebb64cd23..e529b2276d5 100644 --- a/esphome/components/gpio/__init__.py +++ b/esphome/components/gpio/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg CODEOWNERS = ["@esphome/core"] +DOMAIN = "gpio" gpio_ns = cg.esphome_ns.namespace("gpio") diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index 0c7199b6dfb..cf6be6e8e0e 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -3,6 +3,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED from esphome.types import ConfigType +DOMAIN = "gpio_expander" + def validate_interrupt_pin(value: ConfigType) -> ConfigType: # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index 94a36a0afa0..cc77dff496b 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -33,6 +33,7 @@ DEPENDENCIES = ["uart"] AUTO_LOAD = ["sensor"] CODEOWNERS = ["@coogle", "@ximex"] +DOMAIN = "gps" gps_ns = cg.esphome_ns.namespace("gps") GPS = gps_ns.class_("GPS", cg.PollingComponent, uart.UARTDevice) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 1b99491f9c5..0aee5973347 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -32,6 +32,7 @@ from esphome.const import ( from esphome.types import ConfigType CODEOWNERS = ["@synco"] +DOMAIN = "graph" DEPENDENCIES = ["display", "sensor"] MULTI_CONF = True diff --git a/esphome/components/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index 668a0d74d14..59e8f66c501 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -34,6 +34,7 @@ GraphicalDisplayMenuOnRedrawTrigger = graphical_display_menu_ns.class_( ) CODEOWNERS = ["@MrMDavidson"] +DOMAIN = "graphical_display_menu" AUTO_LOAD = ["display_menu_base"] diff --git a/esphome/components/gree/__init__.py b/esphome/components/gree/__init__.py index 2dd9ac0f1c7..67489b38767 100644 --- a/esphome/components/gree/__init__.py +++ b/esphome/components/gree/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "gree" + gree_ns = cg.esphome_ns.namespace("gree") diff --git a/esphome/components/grove_gas_mc_v2/__init__.py b/esphome/components/grove_gas_mc_v2/__init__.py index e69de29bb2d..31a956ac6f2 100644 --- a/esphome/components/grove_gas_mc_v2/__init__.py +++ b/esphome/components/grove_gas_mc_v2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "grove_gas_mc_v2" diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 2d44d883230..0874613c5cf 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( DEPENDENCIES = ["i2c"] CODEOWNERS = ["@max246"] +DOMAIN = "grove_tb6612fng" MULTI_CONF = True diff --git a/esphome/components/growatt_solar/__init__.py b/esphome/components/growatt_solar/__init__.py index e69de29bb2d..078e8367830 100644 --- a/esphome/components/growatt_solar/__init__.py +++ b/esphome/components/growatt_solar/__init__.py @@ -0,0 +1 @@ +DOMAIN = "growatt_solar" diff --git a/esphome/components/gsl3670/__init__.py b/esphome/components/gsl3670/__init__.py index c58ce8a01e8..24d19ee8873 100644 --- a/esphome/components/gsl3670/__init__.py +++ b/esphome/components/gsl3670/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "gsl3670" diff --git a/esphome/components/gt911/__init__.py b/esphome/components/gt911/__init__.py index 1f7ecd1d5e8..2b45ba5efe7 100644 --- a/esphome/components/gt911/__init__.py +++ b/esphome/components/gt911/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@jesserockz", "@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "gt911" gt911_ns = cg.esphome_ns.namespace("gt911") diff --git a/esphome/components/haier/__init__.py b/esphome/components/haier/__init__.py index e69de29bb2d..605d3151853 100644 --- a/esphome/components/haier/__init__.py +++ b/esphome/components/haier/__init__.py @@ -0,0 +1 @@ +DOMAIN = "haier" diff --git a/esphome/components/havells_solar/__init__.py b/esphome/components/havells_solar/__init__.py index e69de29bb2d..f6fa38817c4 100644 --- a/esphome/components/havells_solar/__init__.py +++ b/esphome/components/havells_solar/__init__.py @@ -0,0 +1 @@ +DOMAIN = "havells_solar" diff --git a/esphome/components/hbridge/__init__.py b/esphome/components/hbridge/__init__.py index 7eae863ff53..bbf8313de07 100644 --- a/esphome/components/hbridge/__init__.py +++ b/esphome/components/hbridge/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "hbridge" + hbridge_ns = cg.esphome_ns.namespace("hbridge") diff --git a/esphome/components/hc8/__init__.py b/esphome/components/hc8/__init__.py index e1028456b0a..bbcfae894a5 100644 --- a/esphome/components/hc8/__init__.py +++ b/esphome/components/hc8/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@omartijn"] +DOMAIN = "hc8" diff --git a/esphome/components/hdc1080/__init__.py b/esphome/components/hdc1080/__init__.py index e69de29bb2d..7c1f276f3d1 100644 --- a/esphome/components/hdc1080/__init__.py +++ b/esphome/components/hdc1080/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hdc1080" diff --git a/esphome/components/hdc2010/__init__.py b/esphome/components/hdc2010/__init__.py index badf9dbb0c5..839dc630b24 100644 --- a/esphome/components/hdc2010/__init__.py +++ b/esphome/components/hdc2010/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@optimusprimespace", "@ssieb"] +DOMAIN = "hdc2010" diff --git a/esphome/components/hdc2080/__init__.py b/esphome/components/hdc2080/__init__.py index 341ea610489..e1ab22cc418 100644 --- a/esphome/components/hdc2080/__init__.py +++ b/esphome/components/hdc2080/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@G-Pereira", "@jesserockz"] +DOMAIN = "hdc2080" diff --git a/esphome/components/hdc302x/__init__.py b/esphome/components/hdc302x/__init__.py index d8165281bfe..2fbfb0191ce 100644 --- a/esphome/components/hdc302x/__init__.py +++ b/esphome/components/hdc302x/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@joshuasing"] +DOMAIN = "hdc302x" diff --git a/esphome/components/he60r/__init__.py b/esphome/components/he60r/__init__.py index c58ce8a01e8..070ba595a30 100644 --- a/esphome/components/he60r/__init__.py +++ b/esphome/components/he60r/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "he60r" diff --git a/esphome/components/heatpumpir/__init__.py b/esphome/components/heatpumpir/__init__.py index e69de29bb2d..f0ef79b8958 100644 --- a/esphome/components/heatpumpir/__init__.py +++ b/esphome/components/heatpumpir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "heatpumpir" diff --git a/esphome/components/hitachi_ac344/__init__.py b/esphome/components/hitachi_ac344/__init__.py index e69de29bb2d..15dac432607 100644 --- a/esphome/components/hitachi_ac344/__init__.py +++ b/esphome/components/hitachi_ac344/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hitachi_ac344" diff --git a/esphome/components/hitachi_ac424/__init__.py b/esphome/components/hitachi_ac424/__init__.py index 10f2c27fe83..8372b84c7e9 100644 --- a/esphome/components/hitachi_ac424/__init__.py +++ b/esphome/components/hitachi_ac424/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@sourabhjaiswal"] +DOMAIN = "hitachi_ac424" diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index b8040e55794..72d3cc2ada3 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CODEOWNERS = ["@OnFreund"] DEPENDENCIES = ["uart"] AUTO_LOAD = ["binary_sensor", "sensor", "text_sensor"] +DOMAIN = "hlk_fm22x" MULTI_CONF = True CONF_HLK_FM22X_ID = "hlk_fm22x_id" diff --git a/esphome/components/hlw8012/__init__.py b/esphome/components/hlw8012/__init__.py index e69de29bb2d..3da6262ce7f 100644 --- a/esphome/components/hlw8012/__init__.py +++ b/esphome/components/hlw8012/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hlw8012" diff --git a/esphome/components/hlw8032/__init__.py b/esphome/components/hlw8032/__init__.py index 4908e10037a..fc9c674659f 100644 --- a/esphome/components/hlw8032/__init__.py +++ b/esphome/components/hlw8032/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@rici4kubicek"] +DOMAIN = "hlw8032" diff --git a/esphome/components/hm3301/__init__.py b/esphome/components/hm3301/__init__.py index e69de29bb2d..eb459892701 100644 --- a/esphome/components/hm3301/__init__.py +++ b/esphome/components/hm3301/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hm3301" diff --git a/esphome/components/hmac_md5/__init__.py b/esphome/components/hmac_md5/__init__.py index e37eb9b1164..72f9aa60fea 100644 --- a/esphome/components/hmac_md5/__init__.py +++ b/esphome/components/hmac_md5/__init__.py @@ -2,5 +2,6 @@ import esphome.config_validation as cv AUTO_LOAD = ["md5"] CODEOWNERS = ["@dwmw2"] +DOMAIN = "hmac_md5" CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmac_sha256/__init__.py b/esphome/components/hmac_sha256/__init__.py index 158d740dc59..4f42c968a39 100644 --- a/esphome/components/hmac_sha256/__init__.py +++ b/esphome/components/hmac_sha256/__init__.py @@ -2,5 +2,6 @@ import esphome.config_validation as cv AUTO_LOAD = ["sha256"] CODEOWNERS = ["@dwmw2"] +DOMAIN = "hmac_sha256" CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmc5883l/__init__.py b/esphome/components/hmc5883l/__init__.py index e69de29bb2d..5326507e448 100644 --- a/esphome/components/hmc5883l/__init__.py +++ b/esphome/components/hmc5883l/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hmc5883l" diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py index 958b495c2e9..addd1b299b5 100644 --- a/esphome/components/hoermann_hcp/__init__.py +++ b/esphome/components/hoermann_hcp/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@zweckj"] DEPENDENCIES = ["modbus"] +DOMAIN = "hoermann_hcp" MULTI_CONF = True CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 9fead561af3..3851bc3c0bc 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -8,6 +8,7 @@ import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] +DOMAIN = "homeassistant" homeassistant_ns = cg.esphome_ns.namespace("homeassistant") diff --git a/esphome/components/honeywell_hih_i2c/__init__.py b/esphome/components/honeywell_hih_i2c/__init__.py index 8d13fcb1528..5808cb6fc8e 100644 --- a/esphome/components/honeywell_hih_i2c/__init__.py +++ b/esphome/components/honeywell_hih_i2c/__init__.py @@ -1,3 +1,4 @@ """Support for Honeywell HumidIcon HIH""" CODEOWNERS = ["@Benichou34"] +DOMAIN = "honeywell_hih_i2c" diff --git a/esphome/components/honeywellabp/__init__.py b/esphome/components/honeywellabp/__init__.py index 03440d675d4..e6e1fd89156 100644 --- a/esphome/components/honeywellabp/__init__.py +++ b/esphome/components/honeywellabp/__init__.py @@ -1 +1,3 @@ """Support for Honeywell ABP""" + +DOMAIN = "honeywellabp" diff --git a/esphome/components/honeywellabp2_i2c/__init__.py b/esphome/components/honeywellabp2_i2c/__init__.py index 29a910eca98..1205355bc4e 100644 --- a/esphome/components/honeywellabp2_i2c/__init__.py +++ b/esphome/components/honeywellabp2_i2c/__init__.py @@ -1,3 +1,4 @@ """Support for Honeywell ABP2""" CODEOWNERS = ["@jpfaff"] +DOMAIN = "honeywellabp2_i2c" diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 16e4c81d344..d0ec6f25350 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -22,6 +22,7 @@ from .gpio import host_pin_to_code # noqa: F401 CODEOWNERS = ["@esphome/core", "@clydebarrow"] AUTO_LOAD = ["network", "preferences"] +DOMAIN = "host" IS_TARGET_PLATFORM = True diff --git a/esphome/components/hrxl_maxsonar_wr/__init__.py b/esphome/components/hrxl_maxsonar_wr/__init__.py index e69de29bb2d..2210989d9f3 100644 --- a/esphome/components/hrxl_maxsonar_wr/__init__.py +++ b/esphome/components/hrxl_maxsonar_wr/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hrxl_maxsonar_wr" diff --git a/esphome/components/hte501/__init__.py b/esphome/components/hte501/__init__.py index e69de29bb2d..18bffe2b222 100644 --- a/esphome/components/hte501/__init__.py +++ b/esphome/components/hte501/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hte501" diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 5faf9896868..92359714025 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -30,6 +30,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["network"] AUTO_LOAD = ["json", "watchdog"] +DOMAIN = "http_request" http_request_ns = cg.esphome_ns.namespace("http_request") HttpRequestComponent = http_request_ns.class_("HttpRequestComponent", cg.Component) diff --git a/esphome/components/htu21d/__init__.py b/esphome/components/htu21d/__init__.py index e69de29bb2d..10e6e9d66b3 100644 --- a/esphome/components/htu21d/__init__.py +++ b/esphome/components/htu21d/__init__.py @@ -0,0 +1 @@ +DOMAIN = "htu21d" diff --git a/esphome/components/htu31d/__init__.py b/esphome/components/htu31d/__init__.py index 039693cb30f..bc17a0d8867 100644 --- a/esphome/components/htu31d/__init__.py +++ b/esphome/components/htu31d/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@betterengineering"] +DOMAIN = "htu31d" diff --git a/esphome/components/hub75/__init__.py b/esphome/components/hub75/__init__.py index cd5441f749c..3ee6a864153 100644 --- a/esphome/components/hub75/__init__.py +++ b/esphome/components/hub75/__init__.py @@ -1,6 +1,7 @@ from esphome.cpp_generator import MockObj CODEOWNERS = ["@stuartparmenter"] +DOMAIN = "hub75" # Use fully-qualified namespace to avoid collision with external hub75 library's global ::hub75 namespace hub75_ns = MockObj("::esphome::hub75", "::") diff --git a/esphome/components/hx711/__init__.py b/esphome/components/hx711/__init__.py index e69de29bb2d..f9402be78ea 100644 --- a/esphome/components/hx711/__init__.py +++ b/esphome/components/hx711/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hx711" diff --git a/esphome/components/hydreon_rgxx/__init__.py b/esphome/components/hydreon_rgxx/__init__.py index b488bfc1b41..313ce9158c1 100644 --- a/esphome/components/hydreon_rgxx/__init__.py +++ b/esphome/components/hydreon_rgxx/__init__.py @@ -3,6 +3,7 @@ from esphome.components import uart CODEOWNERS = ["@functionpointer"] DEPENDENCIES = ["uart"] +DOMAIN = "hydreon_rgxx" hydreon_rgxx_ns = cg.esphome_ns.namespace("hydreon_rgxx") RGModel = hydreon_rgxx_ns.enum("RGModel") diff --git a/esphome/components/hyt271/__init__.py b/esphome/components/hyt271/__init__.py index 2e88d4f3660..70be33a00ec 100644 --- a/esphome/components/hyt271/__init__.py +++ b/esphome/components/hyt271/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Philippe12"] +DOMAIN = "hyt271" diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index b053125446e..3981f44fe10 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -58,6 +58,7 @@ from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType +DOMAIN = "i2c" LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] i2c_ns = cg.esphome_ns.namespace("i2c") diff --git a/esphome/components/i2c_device/__init__.py b/esphome/components/i2c_device/__init__.py index f890fefdb9d..a118cb3f172 100644 --- a/esphome/components/i2c_device/__init__.py +++ b/esphome/components/i2c_device/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@gabest11"] +DOMAIN = "i2c_device" MULTI_CONF = True i2c_device_ns = cg.esphome_ns.namespace("i2c_device") diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index c5e82beb46d..dc1d2b95934 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -27,6 +27,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] +DOMAIN = "i2s_audio" MULTI_CONF = True CONF_PDM = "pdm" diff --git a/esphome/components/iaqcore/__init__.py b/esphome/components/iaqcore/__init__.py index e69de29bb2d..216921250f9 100644 --- a/esphome/components/iaqcore/__init__.py +++ b/esphome/components/iaqcore/__init__.py @@ -0,0 +1 @@ +DOMAIN = "iaqcore" diff --git a/esphome/components/icnt86/__init__.py b/esphome/components/icnt86/__init__.py index 07f3b4e31ca..7736bfd3936 100644 --- a/esphome/components/icnt86/__init__.py +++ b/esphome/components/icnt86/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@danepowell"] +DOMAIN = "icnt86" diff --git a/esphome/components/ili9341/__init__.py b/esphome/components/ili9341/__init__.py index e69de29bb2d..95ff0c3056d 100644 --- a/esphome/components/ili9341/__init__.py +++ b/esphome/components/ili9341/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ili9341" diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index 84888bbabcb..2803b439156 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -1,3 +1,5 @@ +DOMAIN = "ili9xxx" + DEPRECATED_COMPONENT = """ The 'ili9xxx' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 9b57b6561f9..1acc5e6ca0f 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -8,6 +8,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "improv_base" CONF_NEXT_URL = "next_url" diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index 72ac5866286..06e73803f19 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -31,6 +31,7 @@ def AUTO_LOAD() -> list[str]: CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["wifi"] +DOMAIN = "improv_ble" # Legacy top-level YAML key that routes here; esphome/loader.py and # esphome/config.py handle the warning and the key rename. diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 0231791e9b3..a944a135408 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -17,6 +17,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["improv_base"] CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger", "network"] +DOMAIN = "improv_serial" improv_serial_ns = cg.esphome_ns.namespace("improv_serial") diff --git a/esphome/components/ina219/__init__.py b/esphome/components/ina219/__init__.py index e69de29bb2d..5e9d2cef174 100644 --- a/esphome/components/ina219/__init__.py +++ b/esphome/components/ina219/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina219" diff --git a/esphome/components/ina226/__init__.py b/esphome/components/ina226/__init__.py index ca1f2caa31c..9998623c82c 100644 --- a/esphome/components/ina226/__init__.py +++ b/esphome/components/ina226/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Sergio303", "@latonita"] +DOMAIN = "ina226" diff --git a/esphome/components/ina260/__init__.py b/esphome/components/ina260/__init__.py index e69de29bb2d..4728ef80f8b 100644 --- a/esphome/components/ina260/__init__.py +++ b/esphome/components/ina260/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina260" diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index b6cf0e38f30..7fdfd90bb6f 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -34,6 +34,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@latonita"] +DOMAIN = "ina2xx_base" CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_RANGE = "adc_range" diff --git a/esphome/components/ina2xx_i2c/__init__.py b/esphome/components/ina2xx_i2c/__init__.py index e69de29bb2d..d6c784bad77 100644 --- a/esphome/components/ina2xx_i2c/__init__.py +++ b/esphome/components/ina2xx_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina2xx_i2c" diff --git a/esphome/components/ina2xx_spi/__init__.py b/esphome/components/ina2xx_spi/__init__.py index e69de29bb2d..ae72c2d1950 100644 --- a/esphome/components/ina2xx_spi/__init__.py +++ b/esphome/components/ina2xx_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina2xx_spi" diff --git a/esphome/components/ina3221/__init__.py b/esphome/components/ina3221/__init__.py index e69de29bb2d..f8f491ddec5 100644 --- a/esphome/components/ina3221/__init__.py +++ b/esphome/components/ina3221/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina3221" diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 47ca5bf07fb..2d3d478e583 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -19,6 +19,7 @@ from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] AUTO_LOAD = ["ir_rf_base"] +DOMAIN = "infrared" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/inkbird_ibsth1_mini/__init__.py b/esphome/components/inkbird_ibsth1_mini/__init__.py index e69de29bb2d..e0fbed17ca2 100644 --- a/esphome/components/inkbird_ibsth1_mini/__init__.py +++ b/esphome/components/inkbird_ibsth1_mini/__init__.py @@ -0,0 +1 @@ +DOMAIN = "inkbird_ibsth1_mini" diff --git a/esphome/components/inkplate/__init__.py b/esphome/components/inkplate/__init__.py index 1c6013793a7..36740332a50 100644 --- a/esphome/components/inkplate/__init__.py +++ b/esphome/components/inkplate/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jesserockz", "@JosipKuci"] +DOMAIN = "inkplate" diff --git a/esphome/components/inkplate6/__init__.py b/esphome/components/inkplate6/__init__.py index e69de29bb2d..2b198f666bc 100644 --- a/esphome/components/inkplate6/__init__.py +++ b/esphome/components/inkplate6/__init__.py @@ -0,0 +1 @@ +DOMAIN = "inkplate6" diff --git a/esphome/components/integration/__init__.py b/esphome/components/integration/__init__.py index 71a87b6ae50..4afc7ce9e1f 100644 --- a/esphome/components/integration/__init__.py +++ b/esphome/components/integration/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "integration" diff --git a/esphome/components/internal_temperature/__init__.py b/esphome/components/internal_temperature/__init__.py index 9433ade13f8..aa1671a0154 100644 --- a/esphome/components/internal_temperature/__init__.py +++ b/esphome/components/internal_temperature/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Mat931"] +DOMAIN = "internal_temperature" diff --git a/esphome/components/interval/__init__.py b/esphome/components/interval/__init__.py index 11c3e15b0d4..adbe4349c66 100644 --- a/esphome/components/interval/__init__.py +++ b/esphome/components/interval/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID, CONF_INTERVAL, CONF_STARTUP_DELAY from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "interval" interval_ns = cg.esphome_ns.namespace("interval") IntervalTrigger = interval_ns.class_( "IntervalTrigger", automation.Trigger.template(), cg.PollingComponent diff --git a/esphome/components/ir_rf_base/__init__.py b/esphome/components/ir_rf_base/__init__.py index 55536c131b1..92d853b2a09 100644 --- a/esphome/components/ir_rf_base/__init__.py +++ b/esphome/components/ir_rf_base/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81", "@bdraco"] AUTO_LOAD = ["remote_base"] +DOMAIN = "ir_rf_base" ir_rf_base_ns = cg.esphome_ns.namespace("ir_rf_base") IrRfEntity = ir_rf_base_ns.class_("IrRfEntity", cg.EntityBase, cg.Component) diff --git a/esphome/components/ir_rf_proxy/__init__.py b/esphome/components/ir_rf_proxy/__init__.py index bc4079ede7e..45bce4ac6f3 100644 --- a/esphome/components/ir_rf_proxy/__init__.py +++ b/esphome/components/ir_rf_proxy/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg CODEOWNERS = ["@kbx81"] +DOMAIN = "ir_rf_proxy" # Namespace and constants exported for infrared.py platform ir_rf_proxy_ns = cg.esphome_ns.namespace("ir_rf_proxy") diff --git a/esphome/components/it8951/__init__.py b/esphome/components/it8951/__init__.py index 7fc4ae2cd0e..94bdf451d32 100644 --- a/esphome/components/it8951/__init__.py +++ b/esphome/components/it8951/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"] +DOMAIN = "it8951" diff --git a/esphome/components/jsn_sr04t/__init__.py b/esphome/components/jsn_sr04t/__init__.py index ef6335f3167..672487d32aa 100644 --- a/esphome/components/jsn_sr04t/__init__.py +++ b/esphome/components/jsn_sr04t/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Mafus1"] +DOMAIN = "jsn_sr04t" diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 618c56faa14..cac06a29d24 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -4,6 +4,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "json" json_ns = cg.esphome_ns.namespace("json") CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/kalman_combinator/__init__.py b/esphome/components/kalman_combinator/__init__.py index e69de29bb2d..35782ff625c 100644 --- a/esphome/components/kalman_combinator/__init__.py +++ b/esphome/components/kalman_combinator/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kalman_combinator" diff --git a/esphome/components/kamstrup_kmp/__init__.py b/esphome/components/kamstrup_kmp/__init__.py index e69de29bb2d..2446761ce9b 100644 --- a/esphome/components/kamstrup_kmp/__init__.py +++ b/esphome/components/kamstrup_kmp/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kamstrup_kmp" diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index dedd960c368..8dafb5da070 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -19,6 +19,7 @@ from esphome.cpp_generator import MockObj, literal from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@ssieb"] +DOMAIN = "key_collector" MULTI_CONF = True diff --git a/esphome/components/key_provider/__init__.py b/esphome/components/key_provider/__init__.py index f397382ff24..3008545f40e 100644 --- a/esphome/components/key_provider/__init__.py +++ b/esphome/components/key_provider/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@ssieb"] +DOMAIN = "key_provider" key_provider_ns = cg.esphome_ns.namespace("key_provider") KeyProvider = key_provider_ns.class_("KeyProvider") diff --git a/esphome/components/kmeteriso/__init__.py b/esphome/components/kmeteriso/__init__.py index e69de29bb2d..33163580966 100644 --- a/esphome/components/kmeteriso/__init__.py +++ b/esphome/components/kmeteriso/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kmeteriso" diff --git a/esphome/components/kuntze/__init__.py b/esphome/components/kuntze/__init__.py index e69de29bb2d..34a9637a090 100644 --- a/esphome/components/kuntze/__init__.py +++ b/esphome/components/kuntze/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kuntze" diff --git a/esphome/components/lc709203f/__init__.py b/esphome/components/lc709203f/__init__.py index 3be68d174fb..3076e385274 100644 --- a/esphome/components/lc709203f/__init__.py +++ b/esphome/components/lc709203f/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ilikecake"] +DOMAIN = "lc709203f" diff --git a/esphome/components/lcd_base/__init__.py b/esphome/components/lcd_base/__init__.py index 08ec3957200..58efbd4884c 100644 --- a/esphome/components/lcd_base/__init__.py +++ b/esphome/components/lcd_base/__init__.py @@ -7,6 +7,8 @@ from esphome.const import CONF_DATA, CONF_DIMENSIONS, CONF_POSITION from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "lcd_base" + CONF_USER_CHARACTERS = "user_characters" lcd_base_ns = cg.esphome_ns.namespace("lcd_base") diff --git a/esphome/components/lcd_gpio/__init__.py b/esphome/components/lcd_gpio/__init__.py index e69de29bb2d..9df8039c3bf 100644 --- a/esphome/components/lcd_gpio/__init__.py +++ b/esphome/components/lcd_gpio/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lcd_gpio" diff --git a/esphome/components/lcd_menu/__init__.py b/esphome/components/lcd_menu/__init__.py index 88b8ac21d42..483a36491db 100644 --- a/esphome/components/lcd_menu/__init__.py +++ b/esphome/components/lcd_menu/__init__.py @@ -11,6 +11,7 @@ from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType CODEOWNERS = ["@numo68"] +DOMAIN = "lcd_menu" AUTO_LOAD = ["display_menu_base"] diff --git a/esphome/components/lcd_pcf8574/__init__.py b/esphome/components/lcd_pcf8574/__init__.py index e69de29bb2d..cfeba4c863d 100644 --- a/esphome/components/lcd_pcf8574/__init__.py +++ b/esphome/components/lcd_pcf8574/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lcd_pcf8574" diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index d3179d9b302..e2728aa73c7 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] CODEOWNERS = ["@sebcaps", "@regevbr"] +DOMAIN = "ld2410" MULTI_CONF = True ld2410_ns = cg.esphome_ns.namespace("ld2410") diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index 82db3198611..fd63f78c3d4 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@Rihan9"] DEPENDENCIES = ["uart"] +DOMAIN = "ld2412" MULTI_CONF = True LD2412_ns = cg.esphome_ns.namespace("ld2412") diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 5a5aabeba03..87a54d278a7 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@descipher"] +DOMAIN = "ld2420" DEPENDENCIES = ["uart"] diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 4c37f4fcd1e..a54e83f2875 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] CODEOWNERS = ["@hareeshmu"] +DOMAIN = "ld2450" MULTI_CONF = True ld2450_ns = cg.esphome_ns.namespace("ld2450") diff --git a/esphome/components/ld24xx/__init__.py b/esphome/components/ld24xx/__init__.py index 516af848564..97ff42626c7 100644 --- a/esphome/components/ld24xx/__init__.py +++ b/esphome/components/ld24xx/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@kbx81"] +DOMAIN = "ld24xx" diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index af1e501a6ab..c7529f93149 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -9,6 +9,7 @@ from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE CODEOWNERS = ["@hepter"] DEPENDENCIES = ["uart"] +DOMAIN = "ld6002b" MULTI_CONF = True ld6002b_ns = cg.esphome_ns.namespace("ld6002b") diff --git a/esphome/components/ledc/__init__.py b/esphome/components/ledc/__init__.py index 71a87b6ae50..cc2c4d56aa1 100644 --- a/esphome/components/ledc/__init__.py +++ b/esphome/components/ledc/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "ledc" diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 5d630fec6cc..5a82cce7ad7 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -57,6 +57,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["preferences"] +DOMAIN = "libretiny" IS_TARGET_PLATFORM = True # BLE 5.x BK SDK options to disable unused features. diff --git a/esphome/components/libretiny_pwm/__init__.py b/esphome/components/libretiny_pwm/__init__.py index 4db724f8ade..1825606769b 100644 --- a/esphome/components/libretiny_pwm/__init__.py +++ b/esphome/components/libretiny_pwm/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@kuba2k2"] +DOMAIN = "libretiny_pwm" diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index a1138c7de82..1a4234ceaf0 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -15,6 +15,7 @@ from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType CODEOWNERS = ["@max246"] +DOMAIN = "lightwaverf" lightwaverf_ns = cg.esphome_ns.namespace("lightwaverf") diff --git a/esphome/components/lilygo_t5_47/__init__.py b/esphome/components/lilygo_t5_47/__init__.py index 5499d096a91..cb3605c63e4 100644 --- a/esphome/components/lilygo_t5_47/__init__.py +++ b/esphome/components/lilygo_t5_47/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "lilygo_t5_47" + lilygo_t5_47_ns = cg.esphome_ns.namespace("lilygo_t5_47") diff --git a/esphome/components/lm75b/__init__.py b/esphome/components/lm75b/__init__.py index e69de29bb2d..228b24febe4 100644 --- a/esphome/components/lm75b/__init__.py +++ b/esphome/components/lm75b/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lm75b" diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py index aadc1f3b2f9..7e382eda85b 100644 --- a/esphome/components/ln882h_ble/__init__.py +++ b/esphome/components/ln882h_ble/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["ln882x"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "ln882h_ble" ln882h_ble_ns = cg.esphome_ns.namespace("ln882h_ble") LN882HBLE = ln882h_ble_ns.class_("LN882HBLE", cg.Component) diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 69a01ae1acf..0d2b3a51804 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -28,6 +28,7 @@ CONF_LN882H_BLE_ID = "ln882h_ble_id" DEPENDENCIES = ["ln882x"] AUTO_LOAD = ["ble_device_base", "ln882h_ble"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "ln882h_ble_tracker" ble_device_base.register_hub_provider("ln882h_ble_tracker") diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index b4e179c4a95..d23702d7995 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -35,6 +35,7 @@ from .boards import LN882X_BOARD_PINS, LN882X_BOARDS CODEOWNERS = ["@lamauny"] AUTO_LOAD = ["libretiny"] +DOMAIN = "ln882x" IS_TARGET_PLATFORM = True COMPONENT_DATA = LibreTinyComponent( diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index aa5bec98d53..cf723df9b93 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -22,6 +22,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] +DOMAIN = "lock" IS_PLATFORM_COMPONENT = True lock_ns = cg.esphome_ns.namespace("lock") diff --git a/esphome/components/lps22/__init__.py b/esphome/components/lps22/__init__.py index e69de29bb2d..b9936d7ab18 100644 --- a/esphome/components/lps22/__init__.py +++ b/esphome/components/lps22/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lps22" diff --git a/esphome/components/lsm6ds/__init__.py b/esphome/components/lsm6ds/__init__.py index b1044276a17..311058249e0 100644 --- a/esphome/components/lsm6ds/__init__.py +++ b/esphome/components/lsm6ds/__init__.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.motion import MotionComponent CODEOWNERS = ["@clydebarrow"] +DOMAIN = "lsm6ds" CONF_LSM6DS_ID = "lsm6ds_id" # C++ namespace / class diff --git a/esphome/components/ltr390/__init__.py b/esphome/components/ltr390/__init__.py index e69de29bb2d..3bf5ae25639 100644 --- a/esphome/components/ltr390/__init__.py +++ b/esphome/components/ltr390/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ltr390" diff --git a/esphome/components/ltr501/__init__.py b/esphome/components/ltr501/__init__.py index dd06cfffea0..450e38be0fe 100644 --- a/esphome/components/ltr501/__init__.py +++ b/esphome/components/ltr501/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "ltr501" diff --git a/esphome/components/ltr_als_ps/__init__.py b/esphome/components/ltr_als_ps/__init__.py index dd06cfffea0..3b6fa0d2689 100644 --- a/esphome/components/ltr_als_ps/__init__.py +++ b/esphome/components/ltr_als_ps/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "ltr_als_ps" diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index 6404bcf64c9..96b9782e811 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@rnauber"] +DOMAIN = "m5stack_8angle" MULTI_CONF = True CONF_M5STACK_8ANGLE_ID = "m5stack_8angle_id" diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index f9b744daf0a..8e9b21fe433 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -7,6 +7,7 @@ from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "matrix_keypad" AUTO_LOAD = ["key_provider"] diff --git a/esphome/components/max17043/__init__.py b/esphome/components/max17043/__init__.py index 1db25ccdd67..ba609881f0a 100644 --- a/esphome/components/max17043/__init__.py +++ b/esphome/components/max17043/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@blacknell"] +DOMAIN = "max17043" diff --git a/esphome/components/max31855/__init__.py b/esphome/components/max31855/__init__.py index e69de29bb2d..b8a76cd5007 100644 --- a/esphome/components/max31855/__init__.py +++ b/esphome/components/max31855/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max31855" diff --git a/esphome/components/max31856/__init__.py b/esphome/components/max31856/__init__.py index e69de29bb2d..3fde3398733 100644 --- a/esphome/components/max31856/__init__.py +++ b/esphome/components/max31856/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max31856" diff --git a/esphome/components/max31865/__init__.py b/esphome/components/max31865/__init__.py index e69de29bb2d..42068dc04fe 100644 --- a/esphome/components/max31865/__init__.py +++ b/esphome/components/max31865/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max31865" diff --git a/esphome/components/max44009/__init__.py b/esphome/components/max44009/__init__.py index e69de29bb2d..a7d755f6a68 100644 --- a/esphome/components/max44009/__init__.py +++ b/esphome/components/max44009/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max44009" diff --git a/esphome/components/max6675/__init__.py b/esphome/components/max6675/__init__.py index e69de29bb2d..d00df9770db 100644 --- a/esphome/components/max6675/__init__.py +++ b/esphome/components/max6675/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max6675" diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 05abaf886f1..77e82fdec71 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -15,6 +15,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@looping40"] +DOMAIN = "max6956" DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/max7219/__init__.py b/esphome/components/max7219/__init__.py index e69de29bb2d..2c1a7833956 100644 --- a/esphome/components/max7219/__init__.py +++ b/esphome/components/max7219/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max7219" diff --git a/esphome/components/max7219digit/__init__.py b/esphome/components/max7219digit/__init__.py index e69de29bb2d..33d7ad3f80a 100644 --- a/esphome/components/max7219digit/__init__.py +++ b/esphome/components/max7219digit/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max7219digit" diff --git a/esphome/components/max9611/__init__.py b/esphome/components/max9611/__init__.py index 9b0ea14b57e..a3fc6ca7745 100644 --- a/esphome/components/max9611/__init__.py +++ b/esphome/components/max9611/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@mckaymatthew"] +DOMAIN = "max9611" diff --git a/esphome/components/mcp23008/__init__.py b/esphome/components/mcp23008/__init__.py index 3d1480a6f7d..9f1e9c9f964 100644 --- a/esphome/components/mcp23008/__init__.py +++ b/esphome/components/mcp23008/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x08_base"] CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp23008" MULTI_CONF = True mcp23008_ns = cg.esphome_ns.namespace("mcp23008") diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index aa606507f0b..7dddf4b0422 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp23016" MULTI_CONF = True mcp23016_ns = cg.esphome_ns.namespace("mcp23016") diff --git a/esphome/components/mcp23017/__init__.py b/esphome/components/mcp23017/__init__.py index 474d75f6ff6..6733f194566 100644 --- a/esphome/components/mcp23017/__init__.py +++ b/esphome/components/mcp23017/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x17_base"] CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp23017" MULTI_CONF = True mcp23017_ns = cg.esphome_ns.namespace("mcp23017") diff --git a/esphome/components/mcp23s08/__init__.py b/esphome/components/mcp23s08/__init__.py index ffc51b81468..95a396865be 100644 --- a/esphome/components/mcp23s08/__init__.py +++ b/esphome/components/mcp23s08/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x08_base"] CODEOWNERS = ["@SenexCrenshaw", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "mcp23s08" MULTI_CONF = True CONF_DEVICEADDRESS = "deviceaddress" diff --git a/esphome/components/mcp23s17/__init__.py b/esphome/components/mcp23s17/__init__.py index d693a64ce84..9745b74bc08 100644 --- a/esphome/components/mcp23s17/__init__.py +++ b/esphome/components/mcp23s17/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x17_base"] CODEOWNERS = ["@SenexCrenshaw", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "mcp23s17" MULTI_CONF = True CONF_DEVICEADDRESS = "deviceaddress" diff --git a/esphome/components/mcp23x08_base/__init__.py b/esphome/components/mcp23x08_base/__init__.py index a3c12165f0c..d283d57bd0d 100644 --- a/esphome/components/mcp23x08_base/__init__.py +++ b/esphome/components/mcp23x08_base/__init__.py @@ -3,6 +3,7 @@ from esphome.components import mcp23xxx_base AUTO_LOAD = ["mcp23xxx_base"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "mcp23x08_base" NUM_PINS = 8 diff --git a/esphome/components/mcp23x17_base/__init__.py b/esphome/components/mcp23x17_base/__init__.py index 1b93d16ff30..6160987cef8 100644 --- a/esphome/components/mcp23x17_base/__init__.py +++ b/esphome/components/mcp23x17_base/__init__.py @@ -3,6 +3,7 @@ from esphome.components import mcp23xxx_base AUTO_LOAD = ["mcp23xxx_base"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "mcp23x17_base" NUM_PINS = 16 diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index a626835518b..f7449b0ab9f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "mcp23xxx_base" mcp23xxx_base_ns = cg.esphome_ns.namespace("mcp23xxx_base") MCP23XXXBase = mcp23xxx_base_ns.class_("MCP23XXXBase", cg.Component) diff --git a/esphome/components/mcp2515/__init__.py b/esphome/components/mcp2515/__init__.py index e69de29bb2d..5b0e72b7586 100644 --- a/esphome/components/mcp2515/__init__.py +++ b/esphome/components/mcp2515/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp2515" diff --git a/esphome/components/mcp3008/__init__.py b/esphome/components/mcp3008/__init__.py index 6d1bd5a970b..f29050f9fe5 100644 --- a/esphome/components/mcp3008/__init__.py +++ b/esphome/components/mcp3008/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["spi"] AUTO_LOAD = ["sensor"] +DOMAIN = "mcp3008" MULTI_CONF = True CONF_MCP3008 = "mcp3008" diff --git a/esphome/components/mcp3204/__init__.py b/esphome/components/mcp3204/__init__.py index 5757bdbfa98..7829a07d80a 100644 --- a/esphome/components/mcp3204/__init__.py +++ b/esphome/components/mcp3204/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID, CONF_REFERENCE_VOLTAGE from esphome.types import ConfigType DEPENDENCIES = ["spi"] +DOMAIN = "mcp3204" MULTI_CONF = True CODEOWNERS = ["@rsumner"] diff --git a/esphome/components/mcp3221/__init__.py b/esphome/components/mcp3221/__init__.py index 677bb78c358..8e5a7f134e3 100644 --- a/esphome/components/mcp3221/__init__.py +++ b/esphome/components/mcp3221/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@philippderdiedas"] +DOMAIN = "mcp3221" diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index 60cece67d74..d5465098f29 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp4461" MULTI_CONF = True CONF_DISABLE_WIPER_0 = "disable_wiper_0" CONF_DISABLE_WIPER_1 = "disable_wiper_1" diff --git a/esphome/components/mcp4725/__init__.py b/esphome/components/mcp4725/__init__.py index e69de29bb2d..d63ffaf7c3a 100644 --- a/esphome/components/mcp4725/__init__.py +++ b/esphome/components/mcp4725/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp4725" diff --git a/esphome/components/mcp4728/__init__.py b/esphome/components/mcp4728/__init__.py index f48bdde681f..7156b4aa7fe 100644 --- a/esphome/components/mcp4728/__init__.py +++ b/esphome/components/mcp4728/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@berfenger"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp4728" MULTI_CONF = True CONF_STORE_IN_EEPROM = "store_in_eeprom" diff --git a/esphome/components/mcp47a1/__init__.py b/esphome/components/mcp47a1/__init__.py index e69de29bb2d..274ca6fa01e 100644 --- a/esphome/components/mcp47a1/__init__.py +++ b/esphome/components/mcp47a1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp47a1" diff --git a/esphome/components/mcp9600/__init__.py b/esphome/components/mcp9600/__init__.py index e69de29bb2d..3d85a4d937c 100644 --- a/esphome/components/mcp9600/__init__.py +++ b/esphome/components/mcp9600/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp9600" diff --git a/esphome/components/mcp9808/__init__.py b/esphome/components/mcp9808/__init__.py index e69de29bb2d..7a167ebf062 100644 --- a/esphome/components/mcp9808/__init__.py +++ b/esphome/components/mcp9808/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp9808" diff --git a/esphome/components/md5/__init__.py b/esphome/components/md5/__init__.py index 6a928d16829..09287664293 100644 --- a/esphome/components/md5/__init__.py +++ b/esphome/components/md5/__init__.py @@ -4,6 +4,7 @@ from esphome.helpers import IS_MACOS from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "md5" async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 760952e269d..703cdc9ab09 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -22,6 +22,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +DOMAIN = "mdns" # Components that create mDNS services at runtime # IMPORTANT: If you add a new component here, you must also update the corresponding diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d99e7877bf2..d012773d8a6 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -29,6 +29,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] +DOMAIN = "media_player" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/media_source/__init__.py b/esphome/components/media_source/__init__.py index c9dab7e4d23..348ea01428a 100644 --- a/esphome/components/media_source/__init__.py +++ b/esphome/components/media_source/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] +DOMAIN = "media_source" AUTO_LOAD = ["audio"] diff --git a/esphome/components/mhz19/__init__.py b/esphome/components/mhz19/__init__.py index e69de29bb2d..1b75339b456 100644 --- a/esphome/components/mhz19/__init__.py +++ b/esphome/components/mhz19/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mhz19" diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index c604f64d775..6fa14cdd8b5 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -21,6 +21,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] +DOMAIN = "microphone" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/mics_4514/__init__.py b/esphome/components/mics_4514/__init__.py index e69de29bb2d..9b985504b0f 100644 --- a/esphome/components/mics_4514/__init__.py +++ b/esphome/components/mics_4514/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mics_4514" diff --git a/esphome/components/midea/__init__.py b/esphome/components/midea/__init__.py index e69de29bb2d..d9bb8492b33 100644 --- a/esphome/components/midea/__init__.py +++ b/esphome/components/midea/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea" diff --git a/esphome/components/midea_ac/__init__.py b/esphome/components/midea_ac/__init__.py index e69de29bb2d..dbff1a11860 100644 --- a/esphome/components/midea_ac/__init__.py +++ b/esphome/components/midea_ac/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_ac" diff --git a/esphome/components/midea_ir/__init__.py b/esphome/components/midea_ir/__init__.py index e69de29bb2d..01f3c67f7c2 100644 --- a/esphome/components/midea_ir/__init__.py +++ b/esphome/components/midea_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_ir" diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 526afa914b5..b2351e23a44 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -35,6 +35,8 @@ from esphome.const import ( from esphome.core import CORE, TimePeriod from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +DOMAIN = "mipi" + LOGGER = cv.logging.getLogger(__name__) CONF_TRANSFORMS = "transforms" diff --git a/esphome/components/mipi_dsi/__init__.py b/esphome/components/mipi_dsi/__init__.py index 84dd66e2f42..ae17e00bdce 100644 --- a/esphome/components/mipi_dsi/__init__.py +++ b/esphome/components/mipi_dsi/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] +DOMAIN = "mipi_dsi" mipi_dsi_ns = cg.esphome_ns.namespace("mipi_dsi") diff --git a/esphome/components/mitsubishi/__init__.py b/esphome/components/mitsubishi/__init__.py index e69de29bb2d..05ae0f0543d 100644 --- a/esphome/components/mitsubishi/__init__.py +++ b/esphome/components/mitsubishi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mitsubishi" diff --git a/esphome/components/mixer/__init__.py b/esphome/components/mixer/__init__.py index e69de29bb2d..5ea96da4d25 100644 --- a/esphome/components/mixer/__init__.py +++ b/esphome/components/mixer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mixer" diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py index d00b4ce8d03..7df1f7104f5 100644 --- a/esphome/components/mk2pvrouter/__init__.py +++ b/esphome/components/mk2pvrouter/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@FredM67"] DEPENDENCIES = ["uart"] +DOMAIN = "mk2pvrouter" mk2pvrouter_ns = cg.esphome_ns.namespace("mk2pvrouter") Mk2PVRouter = mk2pvrouter_ns.class_("Mk2PVRouter", cg.Component, uart.UARTDevice) diff --git a/esphome/components/mlx90393/__init__.py b/esphome/components/mlx90393/__init__.py index fc92f02120c..800d120558a 100644 --- a/esphome/components/mlx90393/__init__.py +++ b/esphome/components/mlx90393/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@functionpointer"] +DOMAIN = "mlx90393" diff --git a/esphome/components/mlx90614/__init__.py b/esphome/components/mlx90614/__init__.py index e69de29bb2d..e1db3524a40 100644 --- a/esphome/components/mlx90614/__init__.py +++ b/esphome/components/mlx90614/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mlx90614" diff --git a/esphome/components/mmc5603/__init__.py b/esphome/components/mmc5603/__init__.py index cc88f262313..b7008ff1579 100644 --- a/esphome/components/mmc5603/__init__.py +++ b/esphome/components/mmc5603/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@benhoff"] +DOMAIN = "mmc5603" diff --git a/esphome/components/mmc5983/__init__.py b/esphome/components/mmc5983/__init__.py index c8db8c4300d..5e5287db5fb 100644 --- a/esphome/components/mmc5983/__init__.py +++ b/esphome/components/mmc5983/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@agoode"] +DOMAIN = "mmc5983" diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index cad2781cc17..4e4d7fe1608 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType, TemplateArgsType _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["uart"] +DOMAIN = "modbus" # Loading the hub makes the modbus_client.* actions available (they are registry entries only; no code is # generated unless a config uses one). AUTO_LOAD = ["modbus_client"] diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 66ddcd7722d..5294f20f2de 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@exciton"] DEPENDENCIES = ["modbus"] +DOMAIN = "modbus_client" MULTI_CONF = True # The modbus hub auto-loads this component to make the actions available. Without this, that auto-load # would try to create a device with no address. diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 0de40a4cc1f..bff23e364ed 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -23,6 +23,7 @@ from .const import ( ) CODEOWNERS = ["@exciton"] +DOMAIN = "modbus_server" AUTO_LOAD = ["modbus"] diff --git a/esphome/components/monochromatic/__init__.py b/esphome/components/monochromatic/__init__.py index e69de29bb2d..4f572e8557f 100644 --- a/esphome/components/monochromatic/__init__.py +++ b/esphome/components/monochromatic/__init__.py @@ -0,0 +1 @@ +DOMAIN = "monochromatic" diff --git a/esphome/components/mopeka_ble/__init__.py b/esphome/components/mopeka_ble/__init__.py index ec7deb3067e..968447dadf3 100644 --- a/esphome/components/mopeka_ble/__init__.py +++ b/esphome/components/mopeka_ble/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@spbrogan", "@Fabian-Schmidt"] AUTO_LOAD = ["ble_device_base"] +DOMAIN = "mopeka_ble" CONF_SHOW_SENSORS_WITHOUT_SYNC = "show_sensors_without_sync" diff --git a/esphome/components/mopeka_pro_check/__init__.py b/esphome/components/mopeka_pro_check/__init__.py index c57f60f5219..86bf451227a 100644 --- a/esphome/components/mopeka_pro_check/__init__.py +++ b/esphome/components/mopeka_pro_check/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@spbrogan"] +DOMAIN = "mopeka_pro_check" diff --git a/esphome/components/mopeka_std_check/__init__.py b/esphome/components/mopeka_std_check/__init__.py index 88e344464f1..be8f3d329bc 100644 --- a/esphome/components/mopeka_std_check/__init__.py +++ b/esphome/components/mopeka_std_check/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Fabian-Schmidt"] +DOMAIN = "mopeka_std_check" diff --git a/esphome/components/mpl3115a2/__init__.py b/esphome/components/mpl3115a2/__init__.py index e69de29bb2d..08956d32f69 100644 --- a/esphome/components/mpl3115a2/__init__.py +++ b/esphome/components/mpl3115a2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mpl3115a2" diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index da56b4ff4bc..1764ff9c054 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -25,6 +25,7 @@ CONF_MPR121 = "mpr121" CONF_MPR121_ID = "mpr121_id" DEPENDENCIES = ["i2c"] +DOMAIN = "mpr121" mpr121_ns = cg.esphome_ns.namespace("mpr121") MPR121Component = mpr121_ns.class_("MPR121Component", cg.Component, i2c.I2CDevice) diff --git a/esphome/components/mpu6050/__init__.py b/esphome/components/mpu6050/__init__.py index e69de29bb2d..ee025604782 100644 --- a/esphome/components/mpu6050/__init__.py +++ b/esphome/components/mpu6050/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mpu6050" diff --git a/esphome/components/mpu6886/__init__.py b/esphome/components/mpu6886/__init__.py index 933f71ccd77..af9cdd2f272 100644 --- a/esphome/components/mpu6886/__init__.py +++ b/esphome/components/mpu6886/__init__.py @@ -1 +1,3 @@ """Support for MPC-6886.""" + +DOMAIN = "mpu6886" diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 65a7cf409b8..b21dc2e2f00 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -68,6 +68,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType DEPENDENCIES = ["network"] +DOMAIN = "mqtt" def AUTO_LOAD(): diff --git a/esphome/components/mqtt_subscribe/__init__.py b/esphome/components/mqtt_subscribe/__init__.py index b17f3213727..ee58f82fb66 100644 --- a/esphome/components/mqtt_subscribe/__init__.py +++ b/esphome/components/mqtt_subscribe/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "mqtt_subscribe" + mqtt_subscribe_ns = cg.esphome_ns.namespace("mqtt_subscribe") diff --git a/esphome/components/ms5611/__init__.py b/esphome/components/ms5611/__init__.py index e69de29bb2d..925c57d57bf 100644 --- a/esphome/components/ms5611/__init__.py +++ b/esphome/components/ms5611/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ms5611" diff --git a/esphome/components/ms8607/__init__.py b/esphome/components/ms8607/__init__.py index e1cd49ec7b6..9fc73f80a97 100644 --- a/esphome/components/ms8607/__init__.py +++ b/esphome/components/ms8607/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@e28eta"] +DOMAIN = "ms8607" diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 0beece67101..75260a1b0af 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] +DOMAIN = "msa3xx" MULTI_CONF = True diff --git a/esphome/components/my9231/__init__.py b/esphome/components/my9231/__init__.py index e43454d6ed9..6fcb09a3478 100644 --- a/esphome/components/my9231/__init__.py +++ b/esphome/components/my9231/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( from esphome.types import ConfigType AUTO_LOAD = ["output"] +DOMAIN = "my9231" my9231_ns = cg.esphome_ns.namespace("my9231") MY9231OutputComponent = my9231_ns.class_("MY9231OutputComponent", cg.Component) diff --git a/esphome/components/nau7802/__init__.py b/esphome/components/nau7802/__init__.py index e69de29bb2d..d86eed44b84 100644 --- a/esphome/components/nau7802/__init__.py +++ b/esphome/components/nau7802/__init__.py @@ -0,0 +1 @@ +DOMAIN = "nau7802" diff --git a/esphome/components/neopixelbus/__init__.py b/esphome/components/neopixelbus/__init__.py index e69de29bb2d..0294a2a7357 100644 --- a/esphome/components/neopixelbus/__init__.py +++ b/esphome/components/neopixelbus/__init__.py @@ -0,0 +1 @@ +DOMAIN = "neopixelbus" diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index decee27bcf8..1ee6d6cffaa 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -21,6 +21,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["mdns"] +DOMAIN = "network" _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index efb6c88d28e..c1ab86427e3 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -11,6 +11,7 @@ CONF_NEXTION_ID = "nextion_id" CONF_PUBLISH_STATE = "publish_state" CONF_SEND_TO_NEXTION = "send_to_nextion" +DOMAIN = "nextion" FILTER_SOURCE_FILES = filter_source_files_from_platform( { "nextion_upload_esp32.cpp": { diff --git a/esphome/components/nfc/__init__.py b/esphome/components/nfc/__init__.py index 6afc2eca092..a837edead54 100644 --- a/esphome/components/nfc/__init__.py +++ b/esphome/components/nfc/__init__.py @@ -2,6 +2,7 @@ from esphome import automation import esphome.codegen as cg CODEOWNERS = ["@jesserockz", "@kbx81"] +DOMAIN = "nfc" nfc_ns = cg.esphome_ns.namespace("nfc") diff --git a/esphome/components/noblex/__init__.py b/esphome/components/noblex/__init__.py index ceceff9df9f..a7c86b064bb 100644 --- a/esphome/components/noblex/__init__.py +++ b/esphome/components/noblex/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@AGalfra"] +DOMAIN = "noblex" diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 66043236b1a..1769c110184 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -10,6 +10,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "noise" # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its manifests. diff --git a/esphome/components/npi19/__init__.py b/esphome/components/npi19/__init__.py index e69de29bb2d..190848e54d0 100644 --- a/esphome/components/npi19/__init__.py +++ b/esphome/components/npi19/__init__.py @@ -0,0 +1 @@ +DOMAIN = "npi19" diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 86d485777ca..dc9800a3617 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -86,6 +86,7 @@ from .gpio import nrf52_pin_to_code # noqa: F401 CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["zephyr", "preferences"] +DOMAIN = "nrf52" IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/ntc/__init__.py b/esphome/components/ntc/__init__.py index e69de29bb2d..6b126fe8d01 100644 --- a/esphome/components/ntc/__init__.py +++ b/esphome/components/ntc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ntc" diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 8edbdecee5f..5822c95c7ef 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -154,6 +154,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ] +DOMAIN = "number" IS_PLATFORM_COMPONENT = True number_ns = cg.esphome_ns.namespace("number") diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 8d35aaa34ea..69368a990ad 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -5,6 +5,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "one_wire" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 85632d0bf87..439ec032457 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -11,6 +11,7 @@ from esphome.core import CORE from . import const, generate, schema, validate CODEOWNERS = ["@olegtarasov"] +DOMAIN = "opentherm" MULTI_CONF = True CONF_IN_PIN = "in_pin" diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 80b28a26285..84b525271f1 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -62,6 +62,7 @@ from .const import ( ) CODEOWNERS = ["@mrene"] +DOMAIN = "openthread" AUTO_LOAD = ["network"] diff --git a/esphome/components/openthread_info/__init__.py b/esphome/components/openthread_info/__init__.py index e69de29bb2d..245d84a1044 100644 --- a/esphome/components/openthread_info/__init__.py +++ b/esphome/components/openthread_info/__init__.py @@ -0,0 +1 @@ +DOMAIN = "openthread_info" diff --git a/esphome/components/opt3001/__init__.py b/esphome/components/opt3001/__init__.py index e69de29bb2d..efd544b1e25 100644 --- a/esphome/components/opt3001/__init__.py +++ b/esphome/components/opt3001/__init__.py @@ -0,0 +1 @@ +DOMAIN = "opt3001" diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 6031a684894..f7e03a16990 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -22,6 +22,7 @@ from esphome.types import ConfigType OTA_STATE_LISTENER_KEY = "ota_state_listener" CODEOWNERS = ["@esphome/core"] +DOMAIN = "ota" def AUTO_LOAD() -> list[str]: diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 703af8281d2..a03b1f1c301 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -38,7 +38,7 @@ from esphome.core import EsphomeError _LOGGER = logging.getLogger(__name__) -DOMAIN = CONF_PACKAGES +DOMAIN = "packages" # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 diff --git a/esphome/components/partition/__init__.py b/esphome/components/partition/__init__.py index e69de29bb2d..7d911eb405b 100644 --- a/esphome/components/partition/__init__.py +++ b/esphome/components/partition/__init__.py @@ -0,0 +1 @@ +DOMAIN = "partition" diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 9756e9431ab..69d8935977c 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["gpio_expander"] +DOMAIN = "pca6416a" MULTI_CONF = True pca6416a_ns = cg.esphome_ns.namespace("pca6416a") diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 68e6d163e8d..cba5656e88d 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -17,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "pca9554" MULTI_CONF = True CONF_PIN_COUNT = "pin_count" pca9554_ns = cg.esphome_ns.namespace("pca9554") diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 817cfd1c30a..16103aee443 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "pca9685" MULTI_CONF = True pca9685_ns = cg.esphome_ns.namespace("pca9685") diff --git a/esphome/components/pcd8544/__init__.py b/esphome/components/pcd8544/__init__.py index e69de29bb2d..ff11812a302 100644 --- a/esphome/components/pcd8544/__init__.py +++ b/esphome/components/pcd8544/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pcd8544" diff --git a/esphome/components/pcf85063/__init__.py b/esphome/components/pcf85063/__init__.py index e69de29bb2d..577cea5e1e0 100644 --- a/esphome/components/pcf85063/__init__.py +++ b/esphome/components/pcf85063/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pcf85063" diff --git a/esphome/components/pcf8563/__init__.py b/esphome/components/pcf8563/__init__.py index e69de29bb2d..624ec1be8ce 100644 --- a/esphome/components/pcf8563/__init__.py +++ b/esphome/components/pcf8563/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pcf8563" diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 4ee1c2af567..6c9ed3cd98b 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "pcf8574" MULTI_CONF = True pcf8574_ns = cg.esphome_ns.namespace("pcf8574") diff --git a/esphome/components/pcm5122/__init__.py b/esphome/components/pcm5122/__init__.py index 81e00ca74ba..f79d1eadfcf 100644 --- a/esphome/components/pcm5122/__init__.py +++ b/esphome/components/pcm5122/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@remcom"] +DOMAIN = "pcm5122" diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 45d84b88efd..cd901cb448d 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pi4ioe5v6408" MULTI_CONF = True diff --git a/esphome/components/pid/__init__.py b/esphome/components/pid/__init__.py index 71a87b6ae50..b7b1c5c7afc 100644 --- a/esphome/components/pid/__init__.py +++ b/esphome/components/pid/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "pid" diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index b404409145c..54337b0b130 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andreashergert1984"] AUTO_LOAD = ["binary_sensor", "text_sensor", "sensor", "switch", "output"] +DOMAIN = "pipsolar" MULTI_CONF = True CONF_PIPSOLAR_ID = "pipsolar_id" diff --git a/esphome/components/pixoo/__init__.py b/esphome/components/pixoo/__init__.py index b1de57df8f1..a2c5172bd14 100644 --- a/esphome/components/pixoo/__init__.py +++ b/esphome/components/pixoo/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jesserockz"] +DOMAIN = "pixoo" diff --git a/esphome/components/pm1006/__init__.py b/esphome/components/pm1006/__init__.py index e69de29bb2d..e79bb3f2a40 100644 --- a/esphome/components/pm1006/__init__.py +++ b/esphome/components/pm1006/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pm1006" diff --git a/esphome/components/pm2005/__init__.py b/esphome/components/pm2005/__init__.py index 3716dd7b5eb..bda634dc03a 100644 --- a/esphome/components/pm2005/__init__.py +++ b/esphome/components/pm2005/__init__.py @@ -1 +1,3 @@ """PM2005/2105 component for ESPHome.""" + +DOMAIN = "pm2005" diff --git a/esphome/components/pmsa003i/__init__.py b/esphome/components/pmsa003i/__init__.py index e69de29bb2d..e66395e3de4 100644 --- a/esphome/components/pmsa003i/__init__.py +++ b/esphome/components/pmsa003i/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pmsa003i" diff --git a/esphome/components/pmsx003/__init__.py b/esphome/components/pmsx003/__init__.py index e69de29bb2d..1c60a105dd2 100644 --- a/esphome/components/pmsx003/__init__.py +++ b/esphome/components/pmsx003/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pmsx003" diff --git a/esphome/components/pmwcs3/__init__.py b/esphome/components/pmwcs3/__init__.py index b04c30a0053..64229140cba 100644 --- a/esphome/components/pmwcs3/__init__.py +++ b/esphome/components/pmwcs3/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@SeByDocKy"] +DOMAIN = "pmwcs3" diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index c9118834c56..b8efeb213c9 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -13,6 +13,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] AUTO_LOAD = ["binary_sensor", "nfc"] +DOMAIN = "pn532" MULTI_CONF = True CONF_PN532_ID = "pn532_id" diff --git a/esphome/components/pn532_i2c/__init__.py b/esphome/components/pn532_i2c/__init__.py index 16b877b1f12..bd2a9373d76 100644 --- a/esphome/components/pn532_i2c/__init__.py +++ b/esphome/components/pn532_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn532"] CODEOWNERS = ["@OttoWinter", "@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pn532_i2c" MULTI_CONF = True pn532_i2c_ns = cg.esphome_ns.namespace("pn532_i2c") diff --git a/esphome/components/pn532_spi/__init__.py b/esphome/components/pn532_spi/__init__.py index 734f7941803..0e24425f765 100644 --- a/esphome/components/pn532_spi/__init__.py +++ b/esphome/components/pn532_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn532"] CODEOWNERS = ["@OttoWinter", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "pn532_spi" MULTI_CONF = True pn532_spi_ns = cg.esphome_ns.namespace("pn532_spi") diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 14299ee310a..4fbe07e2e32 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn71xx"] CODEOWNERS = ["@kbx81", "@jesserockz"] +DOMAIN = "pn7150" pn7150_ns = cg.esphome_ns.namespace("pn7150") PN7150 = pn7150_ns.class_("PN7150", pn71xx.PN71xx) diff --git a/esphome/components/pn7150_i2c/__init__.py b/esphome/components/pn7150_i2c/__init__.py index d15c0b431e3..c0dfb4c2e4b 100644 --- a/esphome/components/pn7150_i2c/__init__.py +++ b/esphome/components/pn7150_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn7150"] CODEOWNERS = ["@kbx81", "@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pn7150_i2c" pn7150_i2c_ns = cg.esphome_ns.namespace("pn7150_i2c") PN7150I2C = pn7150_i2c_ns.class_("PN7150I2C", pn7150.PN7150, i2c.I2CDevice) diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index 4cde36e9e73..f1bd6122ff1 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn71xx"] CODEOWNERS = ["@kbx81", "@jesserockz"] +DOMAIN = "pn7160" CONF_DWL_REQ_PIN = "dwl_req_pin" CONF_WKUP_REQ_PIN = "wkup_req_pin" diff --git a/esphome/components/pn7160_i2c/__init__.py b/esphome/components/pn7160_i2c/__init__.py index d711c3e48fc..f9add96bdf8 100644 --- a/esphome/components/pn7160_i2c/__init__.py +++ b/esphome/components/pn7160_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn7160"] CODEOWNERS = ["@kbx81", "@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pn7160_i2c" pn7160_i2c_ns = cg.esphome_ns.namespace("pn7160_i2c") PN7160I2C = pn7160_i2c_ns.class_("PN7160I2C", pn7160.PN7160, i2c.I2CDevice) diff --git a/esphome/components/pn7160_spi/__init__.py b/esphome/components/pn7160_spi/__init__.py index 7705dfa6f6a..32c1008f72a 100644 --- a/esphome/components/pn7160_spi/__init__.py +++ b/esphome/components/pn7160_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn7160"] CODEOWNERS = ["@kbx81", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "pn7160_spi" MULTI_CONF = True pn7160_spi_ns = cg.esphome_ns.namespace("pn7160_spi") diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index 8472d4192c4..3b40eb11419 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -17,6 +17,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] +DOMAIN = "pn71xx" CONF_EMULATION_MESSAGE = "emulation_message" CONF_INCLUDE_ANDROID_APP_RECORD = "include_android_app_record" diff --git a/esphome/components/power_supply/__init__.py b/esphome/components/power_supply/__init__.py index b68118b3523..4d90642b5f5 100644 --- a/esphome/components/power_supply/__init__.py +++ b/esphome/components/power_supply/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "power_supply" power_supply_ns = cg.esphome_ns.namespace("power_supply") PowerSupply = power_supply_ns.class_("PowerSupply", cg.Component) MULTI_CONF = True diff --git a/esphome/components/preferences/__init__.py b/esphome/components/preferences/__init__.py index abd050ec3b2..142cfb32dce 100644 --- a/esphome/components/preferences/__init__.py +++ b/esphome/components/preferences/__init__.py @@ -7,6 +7,7 @@ from esphome.coroutine import CoroPriority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "preferences" preferences_ns = cg.esphome_ns.namespace("preferences") IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.PollingComponent) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 3624fb24421..8bf0163fa78 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_types import EntityBase from esphome.types import ConfigType AUTO_LOAD = ["web_server_base"] +DOMAIN = "prometheus" prometheus_ns = cg.esphome_ns.namespace("prometheus") PrometheusHandler = prometheus_ns.class_("PrometheusHandler", cg.Component) diff --git a/esphome/components/pulse_counter/__init__.py b/esphome/components/pulse_counter/__init__.py index e69de29bb2d..9efceae6deb 100644 --- a/esphome/components/pulse_counter/__init__.py +++ b/esphome/components/pulse_counter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pulse_counter" diff --git a/esphome/components/pulse_meter/__init__.py b/esphome/components/pulse_meter/__init__.py index e69de29bb2d..7c4fef139cf 100644 --- a/esphome/components/pulse_meter/__init__.py +++ b/esphome/components/pulse_meter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pulse_meter" diff --git a/esphome/components/pulse_width/__init__.py b/esphome/components/pulse_width/__init__.py index e69de29bb2d..97cf2e1c39c 100644 --- a/esphome/components/pulse_width/__init__.py +++ b/esphome/components/pulse_width/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pulse_width" diff --git a/esphome/components/pvvx_mithermometer/__init__.py b/esphome/components/pvvx_mithermometer/__init__.py index e69de29bb2d..56767de8044 100644 --- a/esphome/components/pvvx_mithermometer/__init__.py +++ b/esphome/components/pvvx_mithermometer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pvvx_mithermometer" diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 242a613a6c3..f9eef4c8096 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -10,6 +10,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@functionpointer"] DEPENDENCIES = ["uart"] +DOMAIN = "pylontech" MULTI_CONF = True CONF_PYLONTECH_ID = "pylontech_id" diff --git a/esphome/components/pzem004t/__init__.py b/esphome/components/pzem004t/__init__.py index e69de29bb2d..f8d5b7a0c1c 100644 --- a/esphome/components/pzem004t/__init__.py +++ b/esphome/components/pzem004t/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzem004t" diff --git a/esphome/components/pzem6l24/__init__.py b/esphome/components/pzem6l24/__init__.py index e69de29bb2d..45ecb06188c 100644 --- a/esphome/components/pzem6l24/__init__.py +++ b/esphome/components/pzem6l24/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzem6l24" diff --git a/esphome/components/pzemac/__init__.py b/esphome/components/pzemac/__init__.py index e69de29bb2d..600881c3243 100644 --- a/esphome/components/pzemac/__init__.py +++ b/esphome/components/pzemac/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzemac" diff --git a/esphome/components/pzemdc/__init__.py b/esphome/components/pzemdc/__init__.py index e69de29bb2d..81e2bf067d5 100644 --- a/esphome/components/pzemdc/__init__.py +++ b/esphome/components/pzemdc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzemdc" diff --git a/esphome/components/qmc5883l/__init__.py b/esphome/components/qmc5883l/__init__.py index e69de29bb2d..fb646a60c3b 100644 --- a/esphome/components/qmc5883l/__init__.py +++ b/esphome/components/qmc5883l/__init__.py @@ -0,0 +1 @@ +DOMAIN = "qmc5883l" diff --git a/esphome/components/qmi8658/__init__.py b/esphome/components/qmi8658/__init__.py index 67838dbc3c1..6c871dfc4e0 100644 --- a/esphome/components/qmi8658/__init__.py +++ b/esphome/components/qmi8658/__init__.py @@ -4,6 +4,7 @@ from esphome.components.motion import MotionComponent CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c", "motion"] +DOMAIN = "qmi8658" CONF_QMI8658_ID = "qmi8658_id" # C++ namespace / class diff --git a/esphome/components/qmp6988/__init__.py b/esphome/components/qmp6988/__init__.py index 09bcf515895..86498d192a4 100644 --- a/esphome/components/qmp6988/__init__.py +++ b/esphome/components/qmp6988/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@andrewpc"] +DOMAIN = "qmp6988" diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 8b773dcdf54..222041837bd 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -7,6 +7,7 @@ CONF_SCALE = "scale" CONF_ECC = "ecc" CODEOWNERS = ["@wjtje"] +DOMAIN = "qr_code" DEPENDENCIES = ["display"] MULTI_CONF = True diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index aebbe2fcc82..abf64f675af 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "qspi_dbi" CONF_DRAW_FROM_ORIGIN = "draw_from_origin" diff --git a/esphome/components/qwiic_pir/__init__.py b/esphome/components/qwiic_pir/__init__.py index e69de29bb2d..2232f1e6707 100644 --- a/esphome/components/qwiic_pir/__init__.py +++ b/esphome/components/qwiic_pir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "qwiic_pir" diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index ebba97d1a3e..cd680450d50 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] AUTO_LOAD = ["ir_rf_base"] +DOMAIN = "radio_frequency" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/radon_eye_ble/__init__.py b/esphome/components/radon_eye_ble/__init__.py index 0ca9f643264..e2af9763991 100644 --- a/esphome/components/radon_eye_ble/__init__.py +++ b/esphome/components/radon_eye_ble/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeffeb3"] +DOMAIN = "radon_eye_ble" radon_eye_ble_ns = cg.esphome_ns.namespace("radon_eye_ble") RadonEyeListener = radon_eye_ble_ns.class_( diff --git a/esphome/components/radon_eye_rd200/__init__.py b/esphome/components/radon_eye_rd200/__init__.py index 0740f6967b2..a2076ef9451 100644 --- a/esphome/components/radon_eye_rd200/__init__.py +++ b/esphome/components/radon_eye_rd200/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jeffeb3"] +DOMAIN = "radon_eye_rd200" diff --git a/esphome/components/rc522/__init__.py b/esphome/components/rc522/__init__.py index e9e8dd7b73c..69cc713813b 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -13,6 +13,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] AUTO_LOAD = ["binary_sensor"] +DOMAIN = "rc522" CONF_RC522_ID = "rc522_id" diff --git a/esphome/components/rc522_i2c/__init__.py b/esphome/components/rc522_i2c/__init__.py index c3fd368bbde..3fa9ef8a7b2 100644 --- a/esphome/components/rc522_i2c/__init__.py +++ b/esphome/components/rc522_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["rc522"] +DOMAIN = "rc522_i2c" MULTI_CONF = True rc522_i2c_ns = cg.esphome_ns.namespace("rc522_i2c") diff --git a/esphome/components/rc522_spi/__init__.py b/esphome/components/rc522_spi/__init__.py index f2820bbaee2..6f517458bbf 100644 --- a/esphome/components/rc522_spi/__init__.py +++ b/esphome/components/rc522_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["rc522"] +DOMAIN = "rc522_spi" MULTI_CONF = True rc522_spi_ns = cg.esphome_ns.namespace("rc522_spi") diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 4fff41e4f63..96bf4ffd743 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["uart"] +DOMAIN = "rd03d" MULTI_CONF = True CONF_RD03D_ID = "rd03d_id" diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index d3803bd3f7f..86eb2cd7380 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] AUTO_LOAD = ["binary_sensor"] +DOMAIN = "rdm6300" MULTI_CONF = True rdm6300_ns = cg.esphome_ns.namespace("rdm6300") diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e9708d0a9f9..872106e405d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -51,6 +51,7 @@ from esphome.types import ConfigType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] +DOMAIN = "remote_base" CONF_RECEIVER_ID = "receiver_id" diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 866e108131e..481acaa06c9 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -29,6 +29,7 @@ CONF_FILTER_SYMBOLS = "filter_symbols" CONF_RECEIVE_SYMBOLS = "receive_symbols" AUTO_LOAD = ["remote_base"] +DOMAIN = "remote_receiver" remote_receiver_ns = cg.esphome_ns.namespace("remote_receiver") remote_base_ns = cg.esphome_ns.namespace("remote_base") diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 68eac315d51..7553ed767b3 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -26,6 +26,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["remote_base"] +DOMAIN = "remote_transmitter" CONF_EOT_LEVEL = "eot_level" CONF_NON_BLOCKING = "non_blocking" diff --git a/esphome/components/resampler/__init__.py b/esphome/components/resampler/__init__.py index b9b070e0154..e41b004b86c 100644 --- a/esphome/components/resampler/__init__.py +++ b/esphome/components/resampler/__init__.py @@ -3,6 +3,8 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv +DOMAIN = "resampler" + resampler_ns = cg.esphome_ns.namespace("resampler") CONF_TAPS = "taps" diff --git a/esphome/components/resistance/__init__.py b/esphome/components/resistance/__init__.py index e69de29bb2d..5bba63cc1b1 100644 --- a/esphome/components/resistance/__init__.py +++ b/esphome/components/resistance/__init__.py @@ -0,0 +1 @@ +DOMAIN = "resistance" diff --git a/esphome/components/restart/__init__.py b/esphome/components/restart/__init__.py index f70ffa95208..6337520ebd9 100644 --- a/esphome/components/restart/__init__.py +++ b/esphome/components/restart/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "restart" diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index fed8365e9ee..e967335c180 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( DEPENDENCIES = ["uart"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "rf_bridge" rf_bridge_ns = cg.esphome_ns.namespace("rf_bridge") RFBridgeComponent = rf_bridge_ns.class_( diff --git a/esphome/components/rgb/__init__.py b/esphome/components/rgb/__init__.py index e69de29bb2d..11e05410bec 100644 --- a/esphome/components/rgb/__init__.py +++ b/esphome/components/rgb/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgb" diff --git a/esphome/components/rgbct/__init__.py b/esphome/components/rgbct/__init__.py index e69de29bb2d..b8a02914c57 100644 --- a/esphome/components/rgbct/__init__.py +++ b/esphome/components/rgbct/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgbct" diff --git a/esphome/components/rgbw/__init__.py b/esphome/components/rgbw/__init__.py index e69de29bb2d..0635bc36c37 100644 --- a/esphome/components/rgbw/__init__.py +++ b/esphome/components/rgbw/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgbw" diff --git a/esphome/components/rgbww/__init__.py b/esphome/components/rgbww/__init__.py index e69de29bb2d..ce95d11431f 100644 --- a/esphome/components/rgbww/__init__.py +++ b/esphome/components/rgbww/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgbww" diff --git a/esphome/components/ring_buffer/__init__.py b/esphome/components/ring_buffer/__init__.py index b53476dcacb..d053a7e409c 100644 --- a/esphome/components/ring_buffer/__init__.py +++ b/esphome/components/ring_buffer/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["esp32"] +DOMAIN = "ring_buffer" ring_buffer_ns = cg.esphome_ns.namespace("ring_buffer") RingBuffer = ring_buffer_ns.class_("RingBuffer") diff --git a/esphome/components/rotary_encoder/__init__.py b/esphome/components/rotary_encoder/__init__.py index e69de29bb2d..5f448e531cd 100644 --- a/esphome/components/rotary_encoder/__init__.py +++ b/esphome/components/rotary_encoder/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rotary_encoder" diff --git a/esphome/components/router/__init__.py b/esphome/components/router/__init__.py index e69de29bb2d..49a5eec4264 100644 --- a/esphome/components/router/__init__.py +++ b/esphome/components/router/__init__.py @@ -0,0 +1 @@ +DOMAIN = "router" diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 23346ca6f4b..76dd240f7ac 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -57,6 +57,7 @@ from .gpio import rp2_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["preferences"] +DOMAIN = "rp2" IS_TARGET_PLATFORM = True # Legacy top-level YAML keys that route here. The framework diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index d2a08e9fc05..bd0157d60ec 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] +DOMAIN = "rp2040_ble" CONF_RP2040_BLE_ID = "rp2040_ble_id" diff --git a/esphome/components/rp2040_pio_led_strip/__init__.py b/esphome/components/rp2040_pio_led_strip/__init__.py index 4c9aa2d1552..10b24efa099 100644 --- a/esphome/components/rp2040_pio_led_strip/__init__.py +++ b/esphome/components/rp2040_pio_led_strip/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Papa-DMan"] +DOMAIN = "rp2040_pio_led_strip" diff --git a/esphome/components/rp2040_pwm/__init__.py b/esphome/components/rp2040_pwm/__init__.py index e69de29bb2d..3abe0b8c831 100644 --- a/esphome/components/rp2040_pwm/__init__.py +++ b/esphome/components/rp2040_pwm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rp2040_pwm" diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 12857a556fc..4a3d3f788bf 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -33,6 +33,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] AUTO_LOAD = ["ble_device_base", "rp2040_ble"] CODEOWNERS = ["@bdraco"] +DOMAIN = "rp2_ble_tracker" ble_device_base.register_hub_provider("rp2_ble_tracker") diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 915ab73b876..1b2236ae8e9 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.types import ConfigType DEPENDENCIES = ["rp2"] +DOMAIN = "rp2_pio" PIOASM_REPO_VERSION = "1.5.0-b" diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index 8628f0e0639..20629fd7305 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "rpi_dpi_rgb" DEPRECATED_COMPONENT = """ The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 88928b2bcee..d3d5150819c 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -35,6 +35,7 @@ from .boards import RTL87XX_BOARD_PINS, RTL87XX_BOARDS CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["libretiny"] +DOMAIN = "rtl87xx" IS_TARGET_PLATFORM = True COMPONENT_DATA = LibreTinyComponent( diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4f4d17b441e..47b8f26e3ff 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@ximex"] +DOMAIN = "rtttl" CONF_RTTTL = "rtttl" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index e27fcadbee9..fcf5cb770c1 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -17,6 +17,7 @@ from esphome.core import CORE AUTO_LOAD = ["image"] CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"] +DOMAIN = "runtime_image" CONF_PLACEHOLDER = "placeholder" CONF_TRANSPARENCY = "transparency" diff --git a/esphome/components/runtime_stats/__init__.py b/esphome/components/runtime_stats/__init__.py index 62f5f46cb40..2d1a6ac910b 100644 --- a/esphome/components/runtime_stats/__init__.py +++ b/esphome/components/runtime_stats/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@bdraco"] +DOMAIN = "runtime_stats" CONF_LOG_INTERVAL = "log_interval" diff --git a/esphome/components/ruuvi_ble/__init__.py b/esphome/components/ruuvi_ble/__init__.py index ac4bafbb1a3..ccfa8ecf104 100644 --- a/esphome/components/ruuvi_ble/__init__.py +++ b/esphome/components/ruuvi_ble/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] +DOMAIN = "ruuvi_ble" ruuvi_ble_ns = cg.esphome_ns.namespace("ruuvi_ble") RuuviListener = ruuvi_ble_ns.class_( diff --git a/esphome/components/ruuvitag/__init__.py b/esphome/components/ruuvitag/__init__.py index e69de29bb2d..2a0e78efc83 100644 --- a/esphome/components/ruuvitag/__init__.py +++ b/esphome/components/ruuvitag/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ruuvitag" diff --git a/esphome/components/rx8025t/__init__.py b/esphome/components/rx8025t/__init__.py index e69de29bb2d..e0b74646569 100644 --- a/esphome/components/rx8025t/__init__.py +++ b/esphome/components/rx8025t/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rx8025t" diff --git a/esphome/components/rx8130/__init__.py b/esphome/components/rx8130/__init__.py index e69de29bb2d..3b5225c024f 100644 --- a/esphome/components/rx8130/__init__.py +++ b/esphome/components/rx8130/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rx8130" diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 5d03ed70a83..8cb2653547a 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -15,6 +15,7 @@ from esphome.cpp_generator import RawExpression from esphome.types import ConfigType CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] +DOMAIN = "safe_mode" CONF_BOOT_IS_GOOD_AFTER = "boot_is_good_after" CONF_BOOT_IS_GOOD_ON_SHUTDOWN = "boot_is_good_on_shutdown" diff --git a/esphome/components/scd30/__init__.py b/esphome/components/scd30/__init__.py index e69de29bb2d..71e20c7e131 100644 --- a/esphome/components/scd30/__init__.py +++ b/esphome/components/scd30/__init__.py @@ -0,0 +1 @@ +DOMAIN = "scd30" diff --git a/esphome/components/scd4x/__init__.py b/esphome/components/scd4x/__init__.py index e69de29bb2d..338bfdf59bb 100644 --- a/esphome/components/scd4x/__init__.py +++ b/esphome/components/scd4x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "scd4x" diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 2c79e9ed0a1..e7ffcb140ee 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_RESTART from esphome.core import CORE, EsphomeError CODEOWNERS = ["@esphome/core"] +DOMAIN = "script" script_ns = cg.esphome_ns.namespace("script") Script = script_ns.class_("Script", automation.Trigger.template()) ScriptExecuteAction = script_ns.class_("ScriptExecuteAction", automation.Action) diff --git a/esphome/components/sdl/__init__.py b/esphome/components/sdl/__init__.py index 872d8318500..a8a2391a3c6 100644 --- a/esphome/components/sdl/__init__.py +++ b/esphome/components/sdl/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] +DOMAIN = "sdl" SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode") diff --git a/esphome/components/sdm_meter/__init__.py b/esphome/components/sdm_meter/__init__.py index e69de29bb2d..b13d6e18b62 100644 --- a/esphome/components/sdm_meter/__init__.py +++ b/esphome/components/sdm_meter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sdm_meter" diff --git a/esphome/components/sdp3x/__init__.py b/esphome/components/sdp3x/__init__.py index e69de29bb2d..50ffb263bf5 100644 --- a/esphome/components/sdp3x/__init__.py +++ b/esphome/components/sdp3x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sdp3x" diff --git a/esphome/components/sds011/__init__.py b/esphome/components/sds011/__init__.py index e69de29bb2d..386bc61be5d 100644 --- a/esphome/components/sds011/__init__.py +++ b/esphome/components/sds011/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sds011" diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index 56630f18f42..4ec01e46c67 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] # is the code owner of the relevant code base CODEOWNERS = ["@limengdu"] +DOMAIN = "seeed_mr24hpc1" # The current component or platform can be configured or defined multiple times in the same configuration file. MULTI_CONF = True diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 6bf8657af9a..7a85567510a 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] +DOMAIN = "seeed_mr60bha2" MULTI_CONF = True mr60bha2_ns = cg.esphome_ns.namespace("seeed_mr60bha2") diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index 159a1ece9c2..cc227415831 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] +DOMAIN = "seeed_mr60fda2" MULTI_CONF = True mr60fda2_ns = cg.esphome_ns.namespace("seeed_mr60fda2") diff --git a/esphome/components/selec_meter/__init__.py b/esphome/components/selec_meter/__init__.py index e69de29bb2d..469119b8ac7 100644 --- a/esphome/components/selec_meter/__init__.py +++ b/esphome/components/selec_meter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "selec_meter" diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index fa266fc81f2..07f86c1330f 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -30,6 +30,7 @@ from esphome.cpp_types import global_ns from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "select" IS_PLATFORM_COMPONENT = True select_ns = cg.esphome_ns.namespace("select") diff --git a/esphome/components/sen0321/__init__.py b/esphome/components/sen0321/__init__.py index 458ffa67f95..905618bfba3 100644 --- a/esphome/components/sen0321/__init__.py +++ b/esphome/components/sen0321/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@notjj"] +DOMAIN = "sen0321" diff --git a/esphome/components/sen21231/__init__.py b/esphome/components/sen21231/__init__.py index e69de29bb2d..c2802220f61 100644 --- a/esphome/components/sen21231/__init__.py +++ b/esphome/components/sen21231/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sen21231" diff --git a/esphome/components/sen5x/__init__.py b/esphome/components/sen5x/__init__.py index e69de29bb2d..e58c474fae4 100644 --- a/esphome/components/sen5x/__init__.py +++ b/esphome/components/sen5x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sen5x" diff --git a/esphome/components/sen6x/__init__.py b/esphome/components/sen6x/__init__.py index e69de29bb2d..abd5e7e50c1 100644 --- a/esphome/components/sen6x/__init__.py +++ b/esphome/components/sen6x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sen6x" diff --git a/esphome/components/senseair/__init__.py b/esphome/components/senseair/__init__.py index e69de29bb2d..5973c85b6a7 100644 --- a/esphome/components/senseair/__init__.py +++ b/esphome/components/senseair/__init__.py @@ -0,0 +1 @@ +DOMAIN = "senseair" diff --git a/esphome/components/sensirion_common/__init__.py b/esphome/components/sensirion_common/__init__.py index 5d9424d1e7c..e1fc793d6db 100644 --- a/esphome/components/sensirion_common/__init__.py +++ b/esphome/components/sensirion_common/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c CODEOWNERS = ["@martgras"] +DOMAIN = "sensirion_common" sensirion_common_ns = cg.esphome_ns.namespace("sensirion_common") diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index f3293d4ed56..4a00904c4b5 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -127,6 +127,7 @@ from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] +DOMAIN = "sensor" DEVICE_CLASSES = [ DEVICE_CLASS_ABSOLUTE_HUMIDITY, diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 74acaa34962..44ec294765b 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -19,6 +19,7 @@ servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) CONF_AUTO_DETACH_TIME = "auto_detach_time" +DOMAIN = "servo" MULTI_CONF = True CONFIG_SCHEMA = cv.Schema( { diff --git a/esphome/components/sfa30/__init__.py b/esphome/components/sfa30/__init__.py index 28b665906f5..d608a07f79e 100644 --- a/esphome/components/sfa30/__init__.py +++ b/esphome/components/sfa30/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ghsensdev"] +DOMAIN = "sfa30" diff --git a/esphome/components/sfa40/__init__.py b/esphome/components/sfa40/__init__.py index 79568d458a3..077f0d2e9f9 100644 --- a/esphome/components/sfa40/__init__.py +++ b/esphome/components/sfa40/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@NoQuarrel"] +DOMAIN = "sfa40" diff --git a/esphome/components/sgp30/__init__.py b/esphome/components/sgp30/__init__.py index e69de29bb2d..4754f35d59f 100644 --- a/esphome/components/sgp30/__init__.py +++ b/esphome/components/sgp30/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sgp30" diff --git a/esphome/components/sgp40/__init__.py b/esphome/components/sgp40/__init__.py index e69de29bb2d..f18cc0d930c 100644 --- a/esphome/components/sgp40/__init__.py +++ b/esphome/components/sgp40/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sgp40" diff --git a/esphome/components/sgp4x/__init__.py b/esphome/components/sgp4x/__init__.py index e69de29bb2d..f8030fdae05 100644 --- a/esphome/components/sgp4x/__init__.py +++ b/esphome/components/sgp4x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sgp4x" diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index 5db0e77b769..5856d230a2f 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -5,6 +5,7 @@ from esphome.helpers import IS_MACOS from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "sha256" sha256_ns = cg.esphome_ns.namespace("sha256") diff --git a/esphome/components/shelly_dimmer/__init__.py b/esphome/components/shelly_dimmer/__init__.py index accefbbc34b..72586d16371 100644 --- a/esphome/components/shelly_dimmer/__init__.py +++ b/esphome/components/shelly_dimmer/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@rnauber", "@edge90"] +DOMAIN = "shelly_dimmer" diff --git a/esphome/components/sht3xd/__init__.py b/esphome/components/sht3xd/__init__.py index e69de29bb2d..f58b3fb1503 100644 --- a/esphome/components/sht3xd/__init__.py +++ b/esphome/components/sht3xd/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sht3xd" diff --git a/esphome/components/sht4x/__init__.py b/esphome/components/sht4x/__init__.py index e69de29bb2d..2e9124bdab7 100644 --- a/esphome/components/sht4x/__init__.py +++ b/esphome/components/sht4x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sht4x" diff --git a/esphome/components/shtcx/__init__.py b/esphome/components/shtcx/__init__.py index e69de29bb2d..30c06bdaecf 100644 --- a/esphome/components/shtcx/__init__.py +++ b/esphome/components/shtcx/__init__.py @@ -0,0 +1 @@ +DOMAIN = "shtcx" diff --git a/esphome/components/shutdown/__init__.py b/esphome/components/shutdown/__init__.py index 480a6f3e31c..949ea69bb9d 100644 --- a/esphome/components/shutdown/__init__.py +++ b/esphome/components/shutdown/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core", "@jsuanet"] +DOMAIN = "shutdown" diff --git a/esphome/components/sigma_delta_output/__init__.py b/esphome/components/sigma_delta_output/__init__.py index 3356e61bb25..2af211995bd 100644 --- a/esphome/components/sigma_delta_output/__init__.py +++ b/esphome/components/sigma_delta_output/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Cat-Ion"] +DOMAIN = "sigma_delta_output" diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index c472e01f83a..e20c091b75c 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_MESSAGE DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] +DOMAIN = "sim800l" MULTI_CONF = True sim800l_ns = cg.esphome_ns.namespace("sim800l") diff --git a/esphome/components/slow_pwm/__init__.py b/esphome/components/slow_pwm/__init__.py index e69de29bb2d..c46da34e38c 100644 --- a/esphome/components/slow_pwm/__init__.py +++ b/esphome/components/slow_pwm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "slow_pwm" diff --git a/esphome/components/sm10bit_base/__init__.py b/esphome/components/sm10bit_base/__init__.py index 2b555a59df5..05ba98562d7 100644 --- a/esphome/components/sm10bit_base/__init__.py +++ b/esphome/components/sm10bit_base/__init__.py @@ -6,6 +6,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] +DOMAIN = "sm10bit_base" MULTI_CONF = True CONF_MAX_POWER_COLOR_CHANNELS = "max_power_color_channels" diff --git a/esphome/components/sm16716/__init__.py b/esphome/components/sm16716/__init__.py index 3fb9ac3f06b..ae3c54ab7c7 100644 --- a/esphome/components/sm16716/__init__.py +++ b/esphome/components/sm16716/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( from esphome.types import ConfigType AUTO_LOAD = ["output"] +DOMAIN = "sm16716" sm16716_ns = cg.esphome_ns.namespace("sm16716") SM16716 = sm16716_ns.class_("SM16716", cg.Component) diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index 537db5bbe49..c42ce4c8b3f 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["output"] CODEOWNERS = ["@BoukeHaarsma23", "@matika77", "@dd32"] +DOMAIN = "sm2135" sm2135_ns = cg.esphome_ns.namespace("sm2135") SM2135 = sm2135_ns.class_("SM2135", cg.Component) diff --git a/esphome/components/sm2235/__init__.py b/esphome/components/sm2235/__init__.py index 95037fd2756..803cd54be59 100644 --- a/esphome/components/sm2235/__init__.py +++ b/esphome/components/sm2235/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["sm10bit_base", "output"] CODEOWNERS = ["@Cossid"] +DOMAIN = "sm2235" MULTI_CONF = True sm2235_ns = cg.esphome_ns.namespace("sm2235") diff --git a/esphome/components/sm2335/__init__.py b/esphome/components/sm2335/__init__.py index 145c5a0117d..a9fdfcef391 100644 --- a/esphome/components/sm2335/__init__.py +++ b/esphome/components/sm2335/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["sm10bit_base", "output"] CODEOWNERS = ["@Cossid"] +DOMAIN = "sm2335" MULTI_CONF = True sm2335_ns = cg.esphome_ns.namespace("sm2335") diff --git a/esphome/components/sm300d2/__init__.py b/esphome/components/sm300d2/__init__.py index e69de29bb2d..da0a0637ed4 100644 --- a/esphome/components/sm300d2/__init__.py +++ b/esphome/components/sm300d2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sm300d2" diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 07ca5bf4447..35ca231f023 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -9,6 +9,7 @@ from esphome.const import CONF_ID, CONF_ON_DATA from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] +DOMAIN = "sml" DEPENDENCIES = ["uart"] diff --git a/esphome/components/smt100/__init__.py b/esphome/components/smt100/__init__.py index 44ed591400a..572df68d99b 100644 --- a/esphome/components/smt100/__init__.py +++ b/esphome/components/smt100/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@piechade"] +DOMAIN = "smt100" diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index 4f21312fec8..af566cebda9 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -15,6 +15,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = [] +DOMAIN = "sn74hc165" MULTI_CONF = True sn74hc165_ns = cg.esphome_ns.namespace("sn74hc165") diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 367b65176ba..78ed703ed9e 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "sn74hc595" MULTI_CONF = True sn74hc595_ns = cg.esphome_ns.namespace("sn74hc595") diff --git a/esphome/components/sntp/__init__.py b/esphome/components/sntp/__init__.py index e69de29bb2d..481183e0208 100644 --- a/esphome/components/sntp/__init__.py +++ b/esphome/components/sntp/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sntp" diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 3d2cd8ef50d..cc5f672ad55 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -14,6 +14,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] +DOMAIN = "socket" socket_ns = cg.esphome_ns.namespace("socket") Ipv4AllowEntry = socket_ns.struct("Ipv4AllowEntry") diff --git a/esphome/components/sonoff_d1/__init__.py b/esphome/components/sonoff_d1/__init__.py index 18b4d30d183..40a6c103db6 100644 --- a/esphome/components/sonoff_d1/__init__.py +++ b/esphome/components/sonoff_d1/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@anatoly-savchenkov"] +DOMAIN = "sonoff_d1" diff --git a/esphome/components/sound_level/__init__.py b/esphome/components/sound_level/__init__.py index e69de29bb2d..93f0f4b2e38 100644 --- a/esphome/components/sound_level/__init__.py +++ b/esphome/components/sound_level/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sound_level" diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index e084020a141..c08d92f9d8b 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -19,6 +19,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@danielkent-net"] +DOMAIN = "spa06_base" spa06_ns = cg.esphome_ns.namespace("spa06_base") diff --git a/esphome/components/spa06_i2c/__init__.py b/esphome/components/spa06_i2c/__init__.py index e69de29bb2d..928c6c157b5 100644 --- a/esphome/components/spa06_i2c/__init__.py +++ b/esphome/components/spa06_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "spa06_i2c" diff --git a/esphome/components/spa06_spi/__init__.py b/esphome/components/spa06_spi/__init__.py index e69de29bb2d..b4739219991 100644 --- a/esphome/components/spa06_spi/__init__.py +++ b/esphome/components/spa06_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "spa06_spi" diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index d0a743543e5..d34cfc32c0f 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -8,6 +8,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] +DOMAIN = "speaker" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/speaker_source/__init__.py b/esphome/components/speaker_source/__init__.py index e69de29bb2d..ebe19a4ead5 100644 --- a/esphome/components/speaker_source/__init__.py +++ b/esphome/components/speaker_source/__init__.py @@ -0,0 +1 @@ +DOMAIN = "speaker_source" diff --git a/esphome/components/speed/__init__.py b/esphome/components/speed/__init__.py index b75374863be..9f048ef37d2 100644 --- a/esphome/components/speed/__init__.py +++ b/esphome/components/speed/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "speed" + speed_ns = cg.esphome_ns.namespace("speed") diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index dd2dce01c5d..75408d2fd84 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -44,6 +44,7 @@ import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core", "@clydebarrow"] +DOMAIN = "spi" spi_ns = cg.esphome_ns.namespace("spi") SPIComponent = spi_ns.class_("SPIComponent", cg.Component) QuadSPIComponent = spi_ns.class_("QuadSPIComponent", cg.Component) diff --git a/esphome/components/spi_device/__init__.py b/esphome/components/spi_device/__init__.py index bd2bfe64524..3f64dfc3d16 100644 --- a/esphome/components/spi_device/__init__.py +++ b/esphome/components/spi_device/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["spi"] CODEOWNERS = ["@clydebarrow"] +DOMAIN = "spi_device" MULTI_CONF = True spi_device_ns = cg.esphome_ns.namespace("spi_device") diff --git a/esphome/components/spi_led_strip/__init__.py b/esphome/components/spi_led_strip/__init__.py index 850a1f6e027..2d856e1b106 100644 --- a/esphome/components/spi_led_strip/__init__.py +++ b/esphome/components/spi_led_strip/__init__.py @@ -1,2 +1,3 @@ CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["spi"] +DOMAIN = "spi_led_strip" diff --git a/esphome/components/split_buffer/__init__.py b/esphome/components/split_buffer/__init__.py index be7472936f3..e66c0c6efae 100644 --- a/esphome/components/split_buffer/__init__.py +++ b/esphome/components/split_buffer/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@jesserockz"] +DOMAIN = "split_buffer" # Allows split_buffer to be configured in yaml, to allow use of the C++ api. diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 00144adacd7..fe096514d07 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -23,6 +23,7 @@ from esphome.helpers import docs_url AUTO_LOAD = ["number", "switch"] CODEOWNERS = ["@kbx81"] +DOMAIN = "sprinkler" CONF_AUTO_ADVANCE_SWITCH = "auto_advance_switch" CONF_DIVIDER = "divider" diff --git a/esphome/components/sps30/__init__.py b/esphome/components/sps30/__init__.py index e69de29bb2d..4b06a323ab8 100644 --- a/esphome/components/sps30/__init__.py +++ b/esphome/components/sps30/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sps30" diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 26eda3b4eaa..9577f9d4f7d 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -18,6 +18,8 @@ from esphome.const import ( from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "ssd1306_base" + ssd1306_base_ns = cg.esphome_ns.namespace("ssd1306_base") SSD1306 = ssd1306_base_ns.class_("SSD1306", cg.PollingComponent, display.DisplayBuffer) SSD1306Model = ssd1306_base_ns.enum("SSD1306Model") diff --git a/esphome/components/ssd1306_i2c/__init__.py b/esphome/components/ssd1306_i2c/__init__.py index e69de29bb2d..9389c74a447 100644 --- a/esphome/components/ssd1306_i2c/__init__.py +++ b/esphome/components/ssd1306_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1306_i2c" diff --git a/esphome/components/ssd1306_spi/__init__.py b/esphome/components/ssd1306_spi/__init__.py index e69de29bb2d..fc62e74a436 100644 --- a/esphome/components/ssd1306_spi/__init__.py +++ b/esphome/components/ssd1306_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1306_spi" diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index daa03458572..d66ae8d5c97 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -13,6 +13,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1322_base" ssd1322_base_ns = cg.esphome_ns.namespace("ssd1322_base") SSD1322 = ssd1322_base_ns.class_("SSD1322", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1322_spi/__init__.py b/esphome/components/ssd1322_spi/__init__.py index e69de29bb2d..52b9de59b26 100644 --- a/esphome/components/ssd1322_spi/__init__.py +++ b/esphome/components/ssd1322_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1322_spi" diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index e48a8218254..fbbf329d53b 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -13,6 +13,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1325_base" ssd1325_base_ns = cg.esphome_ns.namespace("ssd1325_base") SSD1325 = ssd1325_base_ns.class_("SSD1325", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1325_spi/__init__.py b/esphome/components/ssd1325_spi/__init__.py index e69de29bb2d..218baca080a 100644 --- a/esphome/components/ssd1325_spi/__init__.py +++ b/esphome/components/ssd1325_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1325_spi" diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 462f197696d..2c9c0faf3b2 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1327_base" ssd1327_base_ns = cg.esphome_ns.namespace("ssd1327_base") SSD1327 = ssd1327_base_ns.class_("SSD1327", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1327_i2c/__init__.py b/esphome/components/ssd1327_i2c/__init__.py index e69de29bb2d..b093087fa14 100644 --- a/esphome/components/ssd1327_i2c/__init__.py +++ b/esphome/components/ssd1327_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1327_i2c" diff --git a/esphome/components/ssd1327_spi/__init__.py b/esphome/components/ssd1327_spi/__init__.py index e69de29bb2d..f95fe105798 100644 --- a/esphome/components/ssd1327_spi/__init__.py +++ b/esphome/components/ssd1327_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1327_spi" diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index d1c0b585f83..2345acdf650 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1331_base" ssd1331_base_ns = cg.esphome_ns.namespace("ssd1331_base") SSD1331 = ssd1331_base_ns.class_("SSD1331", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1331_spi/__init__.py b/esphome/components/ssd1331_spi/__init__.py index e69de29bb2d..4e37ffcabac 100644 --- a/esphome/components/ssd1331_spi/__init__.py +++ b/esphome/components/ssd1331_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1331_spi" diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index a516b4d9516..f693004d633 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1351_base" ssd1351_base_ns = cg.esphome_ns.namespace("ssd1351_base") SSD1351 = ssd1351_base_ns.class_("SSD1351", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1351_spi/__init__.py b/esphome/components/ssd1351_spi/__init__.py index e69de29bb2d..0cb07c8b5ee 100644 --- a/esphome/components/ssd1351_spi/__init__.py +++ b/esphome/components/ssd1351_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1351_spi" diff --git a/esphome/components/st7123/__init__.py b/esphome/components/st7123/__init__.py index 335bc238be8..6e3a763eb44 100644 --- a/esphome/components/st7123/__init__.py +++ b/esphome/components/st7123/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@miniskipper"] DEPENDENCIES = ["i2c"] +DOMAIN = "st7123" st7123_ns = cg.esphome_ns.namespace("st7123") diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index 51225c50236..8fdc0a7f7af 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -14,6 +14,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@latonita"] +DOMAIN = "st7567_base" st7567_base_ns = cg.esphome_ns.namespace("st7567_base") ST7567 = st7567_base_ns.class_("ST7567", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/st7567_i2c/__init__.py b/esphome/components/st7567_i2c/__init__.py index dd06cfffea0..3c54c70741b 100644 --- a/esphome/components/st7567_i2c/__init__.py +++ b/esphome/components/st7567_i2c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "st7567_i2c" diff --git a/esphome/components/st7567_spi/__init__.py b/esphome/components/st7567_spi/__init__.py index dd06cfffea0..ed601048578 100644 --- a/esphome/components/st7567_spi/__init__.py +++ b/esphome/components/st7567_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "st7567_spi" diff --git a/esphome/components/st7701s/__init__.py b/esphome/components/st7701s/__init__.py index c58ce8a01e8..812c6caa8f4 100644 --- a/esphome/components/st7701s/__init__.py +++ b/esphome/components/st7701s/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "st7701s" diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index 62dfa6f4136..176eb233e17 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -1,5 +1,7 @@ import esphome.codegen as cg +DOMAIN = "st7735" + st7735_ns = cg.esphome_ns.namespace("st7735") DEPRECATED_COMPONENT = """ diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index 7915cf119c9..e67f4809128 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -1,5 +1,7 @@ import esphome.codegen as cg +DOMAIN = "st7789v" + st7789v_ns = cg.esphome_ns.namespace("st7789v") DEPRECATED_COMPONENT = """ diff --git a/esphome/components/st7920/__init__.py b/esphome/components/st7920/__init__.py index e69de29bb2d..a1512e4e575 100644 --- a/esphome/components/st7920/__init__.py +++ b/esphome/components/st7920/__init__.py @@ -0,0 +1 @@ +DOMAIN = "st7920" diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index 58d4be97fae..16df864082d 100644 --- a/esphome/components/statsd/__init__.py +++ b/esphome/components/statsd/__init__.py @@ -14,6 +14,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["socket"] CODEOWNERS = ["@Links2004"] DEPENDENCIES = ["network"] +DOMAIN = "statsd" CONF_PREFIX = "prefix" diff --git a/esphome/components/status/__init__.py b/esphome/components/status/__init__.py index e69de29bb2d..dee924f6cf8 100644 --- a/esphome/components/status/__init__.py +++ b/esphome/components/status/__init__.py @@ -0,0 +1 @@ +DOMAIN = "status" diff --git a/esphome/components/status_led/__init__.py b/esphome/components/status_led/__init__.py index 700296c0dfa..8bd98c0ef8d 100644 --- a/esphome/components/status_led/__init__.py +++ b/esphome/components/status_led/__init__.py @@ -5,6 +5,8 @@ from esphome.const import CONF_ID, CONF_PIN from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType +DOMAIN = "status_led" + status_led_ns = cg.esphome_ns.namespace("status_led") StatusLED = status_led_ns.class_("StatusLED", cg.Component) diff --git a/esphome/components/stcc4/__init__.py b/esphome/components/stcc4/__init__.py index e69de29bb2d..d0f16805b80 100644 --- a/esphome/components/stcc4/__init__.py +++ b/esphome/components/stcc4/__init__.py @@ -0,0 +1 @@ +DOMAIN = "stcc4" diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 017fd95cd89..f5c1a304335 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import SafeExpType +DOMAIN = "stepper" IS_PLATFORM_COMPONENT = True stepper_ns = cg.esphome_ns.namespace("stepper") diff --git a/esphome/components/sts3x/__init__.py b/esphome/components/sts3x/__init__.py index e69de29bb2d..d7d6491925e 100644 --- a/esphome/components/sts3x/__init__.py +++ b/esphome/components/sts3x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sts3x" diff --git a/esphome/components/stts22h/__init__.py b/esphome/components/stts22h/__init__.py index a33c0b554b7..15a864ef0c7 100644 --- a/esphome/components/stts22h/__init__.py +++ b/esphome/components/stts22h/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@B48D81EFCC"] +DOMAIN = "stts22h" diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index d59f39b9c22..845240dcff1 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -25,6 +25,7 @@ from esphome.yaml_util import ( from .jinja import Jinja, JinjaError, Missing, Resolver, UndefinedError, has_jinja CODEOWNERS = ["@esphome/core"] +DOMAIN = "substitutions" _LOGGER = logging.getLogger(__name__) ContextVars = ChainMap[str, Any] diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 73ddefe9729..71441e97823 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] +DOMAIN = "sun" sun_ns = cg.esphome_ns.namespace("sun") Sun = sun_ns.class_("Sun") diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index 0f5ae277532..61643cf4883 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] +DOMAIN = "sun_gtil2" MULTI_CONF = True DEPENDENCIES = ["uart"] diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index c34a73a36d1..e965b552f48 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -32,6 +32,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "switch" IS_PLATFORM_COMPONENT = True DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 63c3711b10d..c8b920d6cb7 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -10,6 +10,7 @@ from esphome.core import ID, TimePeriod from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType +DOMAIN = "sx126x" MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] DEPENDENCIES = ["spi"] diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 68c56cdff49..ea7fe38ba3c 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -10,6 +10,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +DOMAIN = "sx127x" MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] DEPENDENCIES = ["spi"] diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index 942911c7fe8..f96479498d6 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -29,6 +29,7 @@ CONF_SX1509_ID = "sx1509_id" AUTO_LOAD = ["key_provider", "gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "sx1509" MULTI_CONF = True sx1509_ns = cg.esphome_ns.namespace("sx1509") diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index cb9d64aee70..90bd9f46911 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] DEPENDENCIES = ["i2c"] +DOMAIN = "sy6970" MULTI_CONF = True CONF_SY6970_ID = "sy6970_id" diff --git a/esphome/components/syslog/__init__.py b/esphome/components/syslog/__init__.py index e25567c783d..b48192ddd29 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -9,6 +9,7 @@ from esphome.cpp_types import Component, Parented from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] +DOMAIN = "syslog" DEPENDENCIES = ["udp", "logger", "time"] diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index 0695b43d416..49ac214d400 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -6,6 +6,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] +DOMAIN = "systa_bus" DEPENDENCIES = ["uart"] diff --git a/esphome/components/t6615/__init__.py b/esphome/components/t6615/__init__.py index e69de29bb2d..bbbca72b13f 100644 --- a/esphome/components/t6615/__init__.py +++ b/esphome/components/t6615/__init__.py @@ -0,0 +1 @@ +DOMAIN = "t6615" diff --git a/esphome/components/tas2780/__init__.py b/esphome/components/tas2780/__init__.py index 81e00ca74ba..f1fed4b698e 100644 --- a/esphome/components/tas2780/__init__.py +++ b/esphome/components/tas2780/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@remcom"] +DOMAIN = "tas2780" diff --git a/esphome/components/tas58xx/__init__.py b/esphome/components/tas58xx/__init__.py index 0931d7ba1f3..56700aeafdf 100644 --- a/esphome/components/tas58xx/__init__.py +++ b/esphome/components/tas58xx/__init__.py @@ -1,6 +1,7 @@ from esphome.config_helpers import filter_source_files_from_defines CODEOWNERS = ["@mrtoy-me", "@remcom"] +DOMAIN = "tas58xx" FILTER_SOURCE_FILES = filter_source_files_from_defines( { diff --git a/esphome/components/tc74/__init__.py b/esphome/components/tc74/__init__.py index c1407c7cad0..1021a425b8d 100644 --- a/esphome/components/tc74/__init__.py +++ b/esphome/components/tc74/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@sethgirvan"] +DOMAIN = "tc74" diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index 381b84cad65..a1945e97c52 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_CHANNEL, CONF_CHANNELS, CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@andreashergert1984"] +DOMAIN = "tca9548a" DEPENDENCIES = ["i2c"] diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 6ae1afc883b..a039872e164 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -15,6 +15,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@mobrembski"] +DOMAIN = "tca9555" AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] diff --git a/esphome/components/tcl112/__init__.py b/esphome/components/tcl112/__init__.py index e69de29bb2d..03f39088177 100644 --- a/esphome/components/tcl112/__init__.py +++ b/esphome/components/tcl112/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tcl112" diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 796a9fc48db..35326d77d52 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -24,6 +24,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +DOMAIN = "tcp_uart" MULTI_CONF = True CONF_DISCONNECTS = "disconnects" diff --git a/esphome/components/tcs34725/__init__.py b/esphome/components/tcs34725/__init__.py index e69de29bb2d..cf99348a86a 100644 --- a/esphome/components/tcs34725/__init__.py +++ b/esphome/components/tcs34725/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tcs34725" diff --git a/esphome/components/tee501/__init__.py b/esphome/components/tee501/__init__.py index e69de29bb2d..d3790674e22 100644 --- a/esphome/components/tee501/__init__.py +++ b/esphome/components/tee501/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tee501" diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 67aad11d0f1..8caf0202129 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@0hax"] +DOMAIN = "teleinfo" MULTI_CONF = True teleinfo_ns = cg.esphome_ns.namespace("teleinfo") diff --git a/esphome/components/tem3200/__init__.py b/esphome/components/tem3200/__init__.py index e69de29bb2d..728db67d549 100644 --- a/esphome/components/tem3200/__init__.py +++ b/esphome/components/tem3200/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tem3200" diff --git a/esphome/components/template/__init__.py b/esphome/components/template/__init__.py index 6253af9090f..542e78809c7 100644 --- a/esphome/components/template/__init__.py +++ b/esphome/components/template/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "template" + template_ns = cg.esphome_ns.namespace("template_") diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 2f82f19ff54..fbbc09bd2d1 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -23,6 +23,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] +DOMAIN = "text" IS_PLATFORM_COMPONENT = True text_ns = cg.esphome_ns.namespace("text") diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 08870db64aa..e9c70f7a2e3 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -39,6 +39,7 @@ DEVICE_CLASSES = [ ] +DOMAIN = "text_sensor" IS_PLATFORM_COMPONENT = True text_sensor_ns = cg.esphome_ns.namespace("text_sensor") diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py index 6ba821b1ab8..6914894ba78 100644 --- a/esphome/components/tfluna/__init__.py +++ b/esphome/components/tfluna/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@candrews"] DEPENDENCIES = ["i2c"] +DOMAIN = "tfluna" MULTI_CONF = True tfluna_ns = cg.esphome_ns.namespace("tfluna") diff --git a/esphome/components/thermopro_ble/__init__.py b/esphome/components/thermopro_ble/__init__.py index e69de29bb2d..f2f59ccbc2e 100644 --- a/esphome/components/thermopro_ble/__init__.py +++ b/esphome/components/thermopro_ble/__init__.py @@ -0,0 +1 @@ +DOMAIN = "thermopro_ble" diff --git a/esphome/components/thermostat/__init__.py b/esphome/components/thermostat/__init__.py index e69de29bb2d..7bed41dc56b 100644 --- a/esphome/components/thermostat/__init__.py +++ b/esphome/components/thermostat/__init__.py @@ -0,0 +1 @@ +DOMAIN = "thermostat" diff --git a/esphome/components/time_based/__init__.py b/esphome/components/time_based/__init__.py index ce2f453bda7..39ec2d9a8d5 100644 --- a/esphome/components/time_based/__init__.py +++ b/esphome/components/time_based/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "time_based" + time_based_ns = cg.esphome_ns.namespace("time_based") diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 1b819088cad..66c43aef26f 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +DOMAIN = "tinyusb" CONF_ON_MOUNT = "on_mount" CONF_ON_UNMOUNT = "on_unmount" diff --git a/esphome/components/tlc59208f/__init__.py b/esphome/components/tlc59208f/__init__.py index 1af9f66b957..5d719e40ba2 100644 --- a/esphome/components/tlc59208f/__init__.py +++ b/esphome/components/tlc59208f/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "tlc59208f" MULTI_CONF = True tlc59208f_ns = cg.esphome_ns.namespace("tlc59208f") diff --git a/esphome/components/tlc5947/__init__.py b/esphome/components/tlc5947/__init__.py index 05e2ef037dd..71247dd13ef 100644 --- a/esphome/components/tlc5947/__init__.py +++ b/esphome/components/tlc5947/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType CONF_LAT_PIN = "lat_pin" CODEOWNERS = ["@rnauber"] +DOMAIN = "tlc5947" tlc5947_ns = cg.esphome_ns.namespace("tlc5947") TLC5947 = tlc5947_ns.class_("TLC5947", cg.Component) diff --git a/esphome/components/tlc5971/__init__.py b/esphome/components/tlc5971/__init__.py index 5b9027e61ac..26beec280e1 100644 --- a/esphome/components/tlc5971/__init__.py +++ b/esphome/components/tlc5971/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID, CONF_NUM_CHIPS from esphome.types import ConfigType CODEOWNERS = ["@IJIJI"] +DOMAIN = "tlc5971" tlc5971_ns = cg.esphome_ns.namespace("tlc5971") TLC5971 = tlc5971_ns.class_("TLC5971", cg.Component) diff --git a/esphome/components/tm1621/__init__.py b/esphome/components/tm1621/__init__.py index 2e88d4f3660..1b78f353cac 100644 --- a/esphome/components/tm1621/__init__.py +++ b/esphome/components/tm1621/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Philippe12"] +DOMAIN = "tm1621" diff --git a/esphome/components/tm1637/__init__.py b/esphome/components/tm1637/__init__.py index e69de29bb2d..c8b08abf037 100644 --- a/esphome/components/tm1637/__init__.py +++ b/esphome/components/tm1637/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tm1637" diff --git a/esphome/components/tm1638/__init__.py b/esphome/components/tm1638/__init__.py index e69de29bb2d..4500a10ff43 100644 --- a/esphome/components/tm1638/__init__.py +++ b/esphome/components/tm1638/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tm1638" diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index dfe475f7f2c..14aa1c8842b 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( from esphome.types import ConfigType CODEOWNERS = ["@mrtoy-me"] +DOMAIN = "tm1651" CONF_LEVEL_PERCENT = "level_percent" diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 3e32a230f28..8510f18769a 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -7,3 +7,5 @@ reading temperatures to a resolution of 0.0625°C. https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf """ + +DOMAIN = "tmp102" diff --git a/esphome/components/tmp1075/__init__.py b/esphome/components/tmp1075/__init__.py index ddd04ad11a4..81c0e500337 100644 --- a/esphome/components/tmp1075/__init__.py +++ b/esphome/components/tmp1075/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@sybrenstuvel"] +DOMAIN = "tmp1075" diff --git a/esphome/components/tmp117/__init__.py b/esphome/components/tmp117/__init__.py index e69de29bb2d..e7429bd52e6 100644 --- a/esphome/components/tmp117/__init__.py +++ b/esphome/components/tmp117/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tmp117" diff --git a/esphome/components/tof10120/__init__.py b/esphome/components/tof10120/__init__.py index e69de29bb2d..89b7cffed44 100644 --- a/esphome/components/tof10120/__init__.py +++ b/esphome/components/tof10120/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tof10120" diff --git a/esphome/components/tormatic/__init__.py b/esphome/components/tormatic/__init__.py index 7f3f05a3cd8..e26792c1d33 100644 --- a/esphome/components/tormatic/__init__.py +++ b/esphome/components/tormatic/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ti-mo"] +DOMAIN = "tormatic" diff --git a/esphome/components/toshiba/__init__.py b/esphome/components/toshiba/__init__.py index e69de29bb2d..1e5ec1fc278 100644 --- a/esphome/components/toshiba/__init__.py +++ b/esphome/components/toshiba/__init__.py @@ -0,0 +1 @@ +DOMAIN = "toshiba" diff --git a/esphome/components/total_daily_energy/__init__.py b/esphome/components/total_daily_energy/__init__.py index e69de29bb2d..d3880a5084f 100644 --- a/esphome/components/total_daily_energy/__init__.py +++ b/esphome/components/total_daily_energy/__init__.py @@ -0,0 +1 @@ +DOMAIN = "total_daily_energy" diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index c8b918007b3..7a23d140690 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -23,6 +23,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@nielsnl68"] DEPENDENCIES = ["display"] +DOMAIN = "touchscreen" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/tsl2561/__init__.py b/esphome/components/tsl2561/__init__.py index e69de29bb2d..9986b7fd3ef 100644 --- a/esphome/components/tsl2561/__init__.py +++ b/esphome/components/tsl2561/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tsl2561" diff --git a/esphome/components/tsl2591/__init__.py b/esphome/components/tsl2591/__init__.py index 63331641c5a..c611ad0ebda 100644 --- a/esphome/components/tsl2591/__init__.py +++ b/esphome/components/tsl2591/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@wjcarpenter"] +DOMAIN = "tsl2591" diff --git a/esphome/components/tt21100/__init__.py b/esphome/components/tt21100/__init__.py index a309d34bebd..a8f0ec0dd37 100644 --- a/esphome/components/tt21100/__init__.py +++ b/esphome/components/tt21100/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg CODEOWNERS = ["@kroimon"] +DOMAIN = "tt21100" tt21100_ns = cg.esphome_ns.namespace("tt21100") diff --git a/esphome/components/ttp229_bsf/__init__.py b/esphome/components/ttp229_bsf/__init__.py index 9e2a4ad114f..1b10940570b 100644 --- a/esphome/components/ttp229_bsf/__init__.py +++ b/esphome/components/ttp229_bsf/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID, CONF_SCL_PIN, CONF_SDO_PIN from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor"] +DOMAIN = "ttp229_bsf" CONF_TTP229_ID = "ttp229_id" ttp229_bsf_ns = cg.esphome_ns.namespace("ttp229_bsf") diff --git a/esphome/components/ttp229_lsf/__init__.py b/esphome/components/ttp229_lsf/__init__.py index d32f72fcce9..e89b177a3ee 100644 --- a/esphome/components/ttp229_lsf/__init__.py +++ b/esphome/components/ttp229_lsf/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["binary_sensor"] +DOMAIN = "ttp229_lsf" CONF_TTP229_ID = "ttp229_id" ttp229_lsf_ns = cg.esphome_ns.namespace("ttp229_lsf") diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 2cc60e225c8..6957b57e0e0 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID, CONF_TRIGGER_ID DEPENDENCIES = ["uart"] +DOMAIN = "tuya" CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" diff --git a/esphome/components/tx20/__init__.py b/esphome/components/tx20/__init__.py index e69de29bb2d..83dbfc84f65 100644 --- a/esphome/components/tx20/__init__.py +++ b/esphome/components/tx20/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tx20" diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index cd3cb44ae14..81149f056ba 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -21,6 +21,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["network"] AUTO_LOAD = ["socket"] +DOMAIN = "udp" MULTI_CONF = True udp_ns = cg.esphome_ns.namespace("udp") diff --git a/esphome/components/ufire_ec/__init__.py b/esphome/components/ufire_ec/__init__.py index 08f36c7934c..e5461b4ff7a 100644 --- a/esphome/components/ufire_ec/__init__.py +++ b/esphome/components/ufire_ec/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@pvizeli"] +DOMAIN = "ufire_ec" diff --git a/esphome/components/ufire_ise/__init__.py b/esphome/components/ufire_ise/__init__.py index 08f36c7934c..9fb4cb9c65e 100644 --- a/esphome/components/ufire_ise/__init__.py +++ b/esphome/components/ufire_ise/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@pvizeli"] +DOMAIN = "ufire_ise" diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 85ca0eecaef..e1fcef107d5 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] +DOMAIN = "ufm01" MULTI_CONF = True diff --git a/esphome/components/uln2003/__init__.py b/esphome/components/uln2003/__init__.py index e69de29bb2d..6f2260af57f 100644 --- a/esphome/components/uln2003/__init__.py +++ b/esphome/components/uln2003/__init__.py @@ -0,0 +1 @@ +DOMAIN = "uln2003" diff --git a/esphome/components/ultrasonic/__init__.py b/esphome/components/ultrasonic/__init__.py index 3bca12bffcb..cdc67e78138 100644 --- a/esphome/components/ultrasonic/__init__.py +++ b/esphome/components/ultrasonic/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@swoboda1337", "@ssieb"] +DOMAIN = "ultrasonic" diff --git a/esphome/components/unicode/__init__.py b/esphome/components/unicode/__init__.py index f70ffa95208..c5464cbe6c2 100644 --- a/esphome/components/unicode/__init__.py +++ b/esphome/components/unicode/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "unicode" diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 1533a893ba6..e455a4f7686 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] +DOMAIN = "update" IS_PLATFORM_COMPONENT = True update_ns = cg.esphome_ns.namespace("update") diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index ba686dc22ad..5dc32259ea2 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -6,6 +6,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kroimon"] +DOMAIN = "uponor_smatrix" DEPENDENCIES = ["uart"] diff --git a/esphome/components/uptime/__init__.py b/esphome/components/uptime/__init__.py index e69de29bb2d..74eb20c656e 100644 --- a/esphome/components/uptime/__init__.py +++ b/esphome/components/uptime/__init__.py @@ -0,0 +1 @@ +DOMAIN = "uptime" diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index 8cd078ab496..e49531c80bc 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -15,6 +15,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] AUTO_LOAD = ["uart"] DEPENDENCIES = ["tinyusb"] +DOMAIN = "usb_cdc_acm" CONF_INTERFACES = "interfaces" diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a632b4e38d0..f0cb11244f5 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -24,6 +24,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] CODEOWNERS = ["@clydebarrow"] +DOMAIN = "usb_uart" usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 7df58c7a2aa..b000f6dd3f2 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -33,6 +33,7 @@ from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True CODEOWNERS = ["@esphome/core"] +DOMAIN = "valve" DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index fd546589121..533acfbbb92 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "vbus" DEPENDENCIES = ["uart"] diff --git a/esphome/components/veml3235/__init__.py b/esphome/components/veml3235/__init__.py index e69de29bb2d..57c95f46ef2 100644 --- a/esphome/components/veml3235/__init__.py +++ b/esphome/components/veml3235/__init__.py @@ -0,0 +1 @@ +DOMAIN = "veml3235" diff --git a/esphome/components/veml7700/__init__.py b/esphome/components/veml7700/__init__.py index dd06cfffea0..9c8f1442d7d 100644 --- a/esphome/components/veml7700/__init__.py +++ b/esphome/components/veml7700/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "veml7700" diff --git a/esphome/components/version/__init__.py b/esphome/components/version/__init__.py index f70ffa95208..f9c04b25966 100644 --- a/esphome/components/version/__init__.py +++ b/esphome/components/version/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "version" diff --git a/esphome/components/vl53l0x/__init__.py b/esphome/components/vl53l0x/__init__.py index e69de29bb2d..3363ba99618 100644 --- a/esphome/components/vl53l0x/__init__.py +++ b/esphome/components/vl53l0x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "vl53l0x" diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 14c036de8ee..d306947ec13 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -19,6 +19,7 @@ AUTO_LOAD = ["audio", "ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] CODEOWNERS = ["@jesserockz", "@kahrendt"] +DOMAIN = "voice_assistant" CONF_ON_END = "on_end" CONF_ON_INTENT_END = "on_intent_end" diff --git a/esphome/components/voltage_sampler/__init__.py b/esphome/components/voltage_sampler/__init__.py index e60918096ef..1dad6463229 100644 --- a/esphome/components/voltage_sampler/__init__.py +++ b/esphome/components/voltage_sampler/__init__.py @@ -1,4 +1,6 @@ import esphome.codegen as cg +DOMAIN = "voltage_sampler" + voltage_sampler_ns = cg.esphome_ns.namespace("voltage_sampler") VoltageSampler = voltage_sampler_ns.class_("VoltageSampler") diff --git a/esphome/components/wake_on_lan/__init__.py b/esphome/components/wake_on_lan/__init__.py index 90539e5d3c3..ad052dd85e6 100644 --- a/esphome/components/wake_on_lan/__init__.py +++ b/esphome/components/wake_on_lan/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@willwill2will54", "@clydebarrow"] +DOMAIN = "wake_on_lan" diff --git a/esphome/components/watchdog/__init__.py b/esphome/components/watchdog/__init__.py index 6fb87e45a46..2608ff7c57c 100644 --- a/esphome/components/watchdog/__init__.py +++ b/esphome/components/watchdog/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@oarcher"] +DOMAIN = "watchdog" diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index 6bb2b2f8fe3..b5821e55af5 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -18,6 +18,7 @@ from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@dhoeben"] +DOMAIN = "water_heater" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/waveshare_epaper/__init__.py b/esphome/components/waveshare_epaper/__init__.py index b410406a585..4bcfcdfda5a 100644 --- a/esphome/components/waveshare_epaper/__init__.py +++ b/esphome/components/waveshare_epaper/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "waveshare_epaper" DEPRECATED_COMPONENT = """ The 'waveshare_epaper' component is deprecated and no new models will be added to it. diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 29a939c5236..5e53d30149b 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -14,6 +14,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@latonita"] +DOMAIN = "waveshare_io_ch32v003" AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 5e77c7496ad..808ff6ed179 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -48,6 +48,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["json", "web_server_base"] +DOMAIN = "web_server" AUTH_TYPE_BASIC = "basic" AUTH_TYPE_DIGEST = "digest" diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 546e10ff5e3..c94eea11902 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -10,6 +10,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +DOMAIN = "web_server_base" def AUTO_LOAD() -> list[str]: diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index b9d079b53fb..98e067665ad 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -7,6 +7,7 @@ import esphome.config_validation as cv from esphome.types import ConfigType CODEOWNERS = ["@dentra"] +DOMAIN = "web_server_idf" CONFIG_SCHEMA = cv.All( cv.Schema({}), diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index 8f0cf4ba334..58bfc6f815d 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -17,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] AUTO_LOAD = ["uart"] +DOMAIN = "weikai" MULTI_CONF = True CONF_CRYSTAL = "crystal" diff --git a/esphome/components/weikai_i2c/__init__.py b/esphome/components/weikai_i2c/__init__.py index 2c6a421a0a3..9bbe558dcf9 100644 --- a/esphome/components/weikai_i2c/__init__.py +++ b/esphome/components/weikai_i2c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DrCoolZic"] +DOMAIN = "weikai_i2c" diff --git a/esphome/components/weikai_spi/__init__.py b/esphome/components/weikai_spi/__init__.py index 2c6a421a0a3..2a13bda0345 100644 --- a/esphome/components/weikai_spi/__init__.py +++ b/esphome/components/weikai_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DrCoolZic"] +DOMAIN = "weikai_spi" diff --git a/esphome/components/whirlpool/__init__.py b/esphome/components/whirlpool/__init__.py index e69de29bb2d..4bc63aeba8e 100644 --- a/esphome/components/whirlpool/__init__.py +++ b/esphome/components/whirlpool/__init__.py @@ -0,0 +1 @@ +DOMAIN = "whirlpool" diff --git a/esphome/components/whynter/__init__.py b/esphome/components/whynter/__init__.py index 7316776d78d..f69112ca3d9 100644 --- a/esphome/components/whynter/__init__.py +++ b/esphome/components/whynter/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@aeonsablaze"] +DOMAIN = "whynter" diff --git a/esphome/components/wiegand/__init__.py b/esphome/components/wiegand/__init__.py index dd6e84ac250..93d5e4f0f06 100644 --- a/esphome/components/wiegand/__init__.py +++ b/esphome/components/wiegand/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_ON_KEY, CONF_ON_TAG, CONF_TRIGGER_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "wiegand" AUTO_LOAD = ["key_provider"] diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f357bf75b35..22ce14d27ec 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -81,6 +81,7 @@ from . import wpa2_eap _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["network"] +DOMAIN = "wifi" NO_WIFI_VARIANTS = [ const.VARIANT_ESP32H2, diff --git a/esphome/components/wifi_info/__init__.py b/esphome/components/wifi_info/__init__.py index e69de29bb2d..77d5dd714a5 100644 --- a/esphome/components/wifi_info/__init__.py +++ b/esphome/components/wifi_info/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wifi_info" diff --git a/esphome/components/wifi_signal/__init__.py b/esphome/components/wifi_signal/__init__.py index e69de29bb2d..febfbf73876 100644 --- a/esphome/components/wifi_signal/__init__.py +++ b/esphome/components/wifi_signal/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wifi_signal" diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 3b67aa46d01..069b291574d 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -33,6 +33,7 @@ CONF_WIREGUARD_ID = "wireguard_id" DEPENDENCIES = ["time"] CODEOWNERS = ["@lhoracek", "@droscy", "@thomas0bernard"] +DOMAIN = "wireguard" # The key validation regex has been described by Jason Donenfeld himself # url: https://lists.zx2c4.com/pipermail/wireguard/2020-December/006222.html diff --git a/esphome/components/wk2132_i2c/__init__.py b/esphome/components/wk2132_i2c/__init__.py index ecb7a5da09f..7e0999b08f8 100644 --- a/esphome/components/wk2132_i2c/__init__.py +++ b/esphome/components/wk2132_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2132_i2c" MULTI_CONF = True weikai_i2c_ns = cg.esphome_ns.namespace("weikai_i2c") diff --git a/esphome/components/wk2132_spi/__init__.py b/esphome/components/wk2132_spi/__init__.py index 94b692f1703..de482839e11 100644 --- a/esphome/components/wk2132_spi/__init__.py +++ b/esphome/components/wk2132_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2132_spi" MULTI_CONF = True weikai_spi_ns = cg.esphome_ns.namespace("weikai_spi") diff --git a/esphome/components/wk2168_i2c/__init__.py b/esphome/components/wk2168_i2c/__init__.py index d4aacaf97db..c450717ea2c 100644 --- a/esphome/components/wk2168_i2c/__init__.py +++ b/esphome/components/wk2168_i2c/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2168_i2c" MULTI_CONF = True CONF_WK2168_I2C = "wk2168_i2c" diff --git a/esphome/components/wk2168_spi/__init__.py b/esphome/components/wk2168_spi/__init__.py index b4efaf65ac8..ef12117a2c7 100644 --- a/esphome/components/wk2168_spi/__init__.py +++ b/esphome/components/wk2168_spi/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2168_spi" MULTI_CONF = True CONF_WK2168_SPI = "wk2168_spi" diff --git a/esphome/components/wk2204_i2c/__init__.py b/esphome/components/wk2204_i2c/__init__.py index 1502cc6cdb0..b8882d41c11 100644 --- a/esphome/components/wk2204_i2c/__init__.py +++ b/esphome/components/wk2204_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2204_i2c" MULTI_CONF = True weikai_i2c_ns = cg.esphome_ns.namespace("weikai_i2c") diff --git a/esphome/components/wk2204_spi/__init__.py b/esphome/components/wk2204_spi/__init__.py index 930f534bb0f..2de911aebde 100644 --- a/esphome/components/wk2204_spi/__init__.py +++ b/esphome/components/wk2204_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2204_spi" MULTI_CONF = True weikai_spi_ns = cg.esphome_ns.namespace("weikai_spi") diff --git a/esphome/components/wk2212_i2c/__init__.py b/esphome/components/wk2212_i2c/__init__.py index 00018dd735f..1f51fea3145 100644 --- a/esphome/components/wk2212_i2c/__init__.py +++ b/esphome/components/wk2212_i2c/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2212_i2c" MULTI_CONF = True CONF_WK2212_I2C = "wk2212_i2c" diff --git a/esphome/components/wk2212_spi/__init__.py b/esphome/components/wk2212_spi/__init__.py index c1a050e8ca0..a0cce3331ad 100644 --- a/esphome/components/wk2212_spi/__init__.py +++ b/esphome/components/wk2212_spi/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2212_spi" MULTI_CONF = True CONF_WK2212_SPI = "wk2212_spi" diff --git a/esphome/components/wl_134/__init__.py b/esphome/components/wl_134/__init__.py index e69de29bb2d..03fae78280b 100644 --- a/esphome/components/wl_134/__init__.py +++ b/esphome/components/wl_134/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wl_134" diff --git a/esphome/components/wled/__init__.py b/esphome/components/wled/__init__.py index a7135975f0f..6b3263e6a3b 100644 --- a/esphome/components/wled/__init__.py +++ b/esphome/components/wled/__init__.py @@ -6,6 +6,8 @@ from esphome.const import CONF_NAME, CONF_PORT from esphome.core import CORE, ID from esphome.types import ConfigType +DOMAIN = "wled" + wled_ns = cg.esphome_ns.namespace("wled") WLEDLightEffect = wled_ns.class_("WLEDLightEffect", AddressableLightEffect) diff --git a/esphome/components/wts01/__init__.py b/esphome/components/wts01/__init__.py index e69de29bb2d..5d5a06ea058 100644 --- a/esphome/components/wts01/__init__.py +++ b/esphome/components/wts01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wts01" diff --git a/esphome/components/x9c/__init__.py b/esphome/components/x9c/__init__.py index e69de29bb2d..46a7316bd14 100644 --- a/esphome/components/x9c/__init__.py +++ b/esphome/components/x9c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "x9c" diff --git a/esphome/components/xdb401/__init__.py b/esphome/components/xdb401/__init__.py index 943139e19a3..181b38caca3 100644 --- a/esphome/components/xdb401/__init__.py +++ b/esphome/components/xdb401/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@RT530"] +DOMAIN = "xdb401" diff --git a/esphome/components/xgzp68xx/__init__.py b/esphome/components/xgzp68xx/__init__.py index 122ffaf6edf..6cebeffba1e 100644 --- a/esphome/components/xgzp68xx/__init__.py +++ b/esphome/components/xgzp68xx/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gcormier"] +DOMAIN = "xgzp68xx" diff --git a/esphome/components/xiaomi_ble/__init__.py b/esphome/components/xiaomi_ble/__init__.py index 16da83934e6..de7998b290a 100644 --- a/esphome/components/xiaomi_ble/__init__.py +++ b/esphome/components/xiaomi_ble/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] +DOMAIN = "xiaomi_ble" xiaomi_ble_ns = cg.esphome_ns.namespace("xiaomi_ble") XiaomiListener = xiaomi_ble_ns.class_( diff --git a/esphome/components/xiaomi_body_scale/__init__.py b/esphome/components/xiaomi_body_scale/__init__.py index 3d75ef24ecb..aeb29d234f3 100644 --- a/esphome/components/xiaomi_body_scale/__init__.py +++ b/esphome/components/xiaomi_body_scale/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@dckiller51"] +DOMAIN = "xiaomi_body_scale" diff --git a/esphome/components/xiaomi_cgd1/__init__.py b/esphome/components/xiaomi_cgd1/__init__.py index e69de29bb2d..f76e529bb2a 100644 --- a/esphome/components/xiaomi_cgd1/__init__.py +++ b/esphome/components/xiaomi_cgd1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgd1" diff --git a/esphome/components/xiaomi_cgdk2/__init__.py b/esphome/components/xiaomi_cgdk2/__init__.py index e69de29bb2d..5a9b433236a 100644 --- a/esphome/components/xiaomi_cgdk2/__init__.py +++ b/esphome/components/xiaomi_cgdk2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgdk2" diff --git a/esphome/components/xiaomi_cgg1/__init__.py b/esphome/components/xiaomi_cgg1/__init__.py index e69de29bb2d..ea01df8c579 100644 --- a/esphome/components/xiaomi_cgg1/__init__.py +++ b/esphome/components/xiaomi_cgg1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgg1" diff --git a/esphome/components/xiaomi_cgpr1/__init__.py b/esphome/components/xiaomi_cgpr1/__init__.py index e69de29bb2d..b17553724dc 100644 --- a/esphome/components/xiaomi_cgpr1/__init__.py +++ b/esphome/components/xiaomi_cgpr1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgpr1" diff --git a/esphome/components/xiaomi_gcls002/__init__.py b/esphome/components/xiaomi_gcls002/__init__.py index e69de29bb2d..643a26ffe20 100644 --- a/esphome/components/xiaomi_gcls002/__init__.py +++ b/esphome/components/xiaomi_gcls002/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_gcls002" diff --git a/esphome/components/xiaomi_hhccjcy01/__init__.py b/esphome/components/xiaomi_hhccjcy01/__init__.py index e69de29bb2d..e8e38dc51f7 100644 --- a/esphome/components/xiaomi_hhccjcy01/__init__.py +++ b/esphome/components/xiaomi_hhccjcy01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_hhccjcy01" diff --git a/esphome/components/xiaomi_hhccjcy10/__init__.py b/esphome/components/xiaomi_hhccjcy10/__init__.py index d47cef13c66..eeb0e7d7efa 100644 --- a/esphome/components/xiaomi_hhccjcy10/__init__.py +++ b/esphome/components/xiaomi_hhccjcy10/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@fariouche"] +DOMAIN = "xiaomi_hhccjcy10" diff --git a/esphome/components/xiaomi_hhccpot002/__init__.py b/esphome/components/xiaomi_hhccpot002/__init__.py index e69de29bb2d..4d74c2f432b 100644 --- a/esphome/components/xiaomi_hhccpot002/__init__.py +++ b/esphome/components/xiaomi_hhccpot002/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_hhccpot002" diff --git a/esphome/components/xiaomi_jqjcy01ym/__init__.py b/esphome/components/xiaomi_jqjcy01ym/__init__.py index e69de29bb2d..e381fe61729 100644 --- a/esphome/components/xiaomi_jqjcy01ym/__init__.py +++ b/esphome/components/xiaomi_jqjcy01ym/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_jqjcy01ym" diff --git a/esphome/components/xiaomi_lywsd02/__init__.py b/esphome/components/xiaomi_lywsd02/__init__.py index e69de29bb2d..e8eb4933388 100644 --- a/esphome/components/xiaomi_lywsd02/__init__.py +++ b/esphome/components/xiaomi_lywsd02/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsd02" diff --git a/esphome/components/xiaomi_lywsd02mmc/__init__.py b/esphome/components/xiaomi_lywsd02mmc/__init__.py index e69de29bb2d..2398660e95d 100644 --- a/esphome/components/xiaomi_lywsd02mmc/__init__.py +++ b/esphome/components/xiaomi_lywsd02mmc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsd02mmc" diff --git a/esphome/components/xiaomi_lywsd03mmc/__init__.py b/esphome/components/xiaomi_lywsd03mmc/__init__.py index e69de29bb2d..3d1638fc11d 100644 --- a/esphome/components/xiaomi_lywsd03mmc/__init__.py +++ b/esphome/components/xiaomi_lywsd03mmc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsd03mmc" diff --git a/esphome/components/xiaomi_lywsdcgq/__init__.py b/esphome/components/xiaomi_lywsdcgq/__init__.py index e69de29bb2d..bbf235aa96c 100644 --- a/esphome/components/xiaomi_lywsdcgq/__init__.py +++ b/esphome/components/xiaomi_lywsdcgq/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsdcgq" diff --git a/esphome/components/xiaomi_mccgq02hl/__init__.py b/esphome/components/xiaomi_mccgq02hl/__init__.py index 3aecbd82079..a65bad1cb14 100644 --- a/esphome/components/xiaomi_mccgq02hl/__init__.py +++ b/esphome/components/xiaomi_mccgq02hl/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ahpohl", "@morph027"] +DOMAIN = "xiaomi_mccgq02hl" diff --git a/esphome/components/xiaomi_mhoc303/__init__.py b/esphome/components/xiaomi_mhoc303/__init__.py index e7005b3957c..5ffa02332f8 100644 --- a/esphome/components/xiaomi_mhoc303/__init__.py +++ b/esphome/components/xiaomi_mhoc303/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@drug123"] +DOMAIN = "xiaomi_mhoc303" diff --git a/esphome/components/xiaomi_mhoc401/__init__.py b/esphome/components/xiaomi_mhoc401/__init__.py index e69de29bb2d..14fe18af57c 100644 --- a/esphome/components/xiaomi_mhoc401/__init__.py +++ b/esphome/components/xiaomi_mhoc401/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_mhoc401" diff --git a/esphome/components/xiaomi_miscale/__init__.py b/esphome/components/xiaomi_miscale/__init__.py index e69de29bb2d..ddda4a0a5b5 100644 --- a/esphome/components/xiaomi_miscale/__init__.py +++ b/esphome/components/xiaomi_miscale/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_miscale" diff --git a/esphome/components/xiaomi_miscale2/__init__.py b/esphome/components/xiaomi_miscale2/__init__.py index e69de29bb2d..346db024455 100644 --- a/esphome/components/xiaomi_miscale2/__init__.py +++ b/esphome/components/xiaomi_miscale2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_miscale2" diff --git a/esphome/components/xiaomi_mjyd02yla/__init__.py b/esphome/components/xiaomi_mjyd02yla/__init__.py index e69de29bb2d..eb881ca39ac 100644 --- a/esphome/components/xiaomi_mjyd02yla/__init__.py +++ b/esphome/components/xiaomi_mjyd02yla/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_mjyd02yla" diff --git a/esphome/components/xiaomi_mue4094rt/__init__.py b/esphome/components/xiaomi_mue4094rt/__init__.py index e69de29bb2d..3d539b267da 100644 --- a/esphome/components/xiaomi_mue4094rt/__init__.py +++ b/esphome/components/xiaomi_mue4094rt/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_mue4094rt" diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 7b289a8ee3e..71d7e0a265b 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "xiaomi_rtcgq02lm" MULTI_CONF = True xiaomi_rtcgq02lm_ns = cg.esphome_ns.namespace("xiaomi_rtcgq02lm") diff --git a/esphome/components/xiaomi_wx08zm/__init__.py b/esphome/components/xiaomi_wx08zm/__init__.py index e69de29bb2d..d795931df95 100644 --- a/esphome/components/xiaomi_wx08zm/__init__.py +++ b/esphome/components/xiaomi_wx08zm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_wx08zm" diff --git a/esphome/components/xiaomi_xmwsdj04mmc/__init__.py b/esphome/components/xiaomi_xmwsdj04mmc/__init__.py index e69de29bb2d..98af32069cf 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/__init__.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_xmwsdj04mmc" diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 2656b60c103..1c6548bb5fe 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -17,6 +17,7 @@ CONF_XL9535 = "xl9535" DEPENDENCIES = ["i2c"] CODEOWNERS = ["@mreditor97"] +DOMAIN = "xl9535" xl9535_ns = cg.esphome_ns.namespace(CONF_XL9535) diff --git a/esphome/components/xpt2046/__init__.py b/esphome/components/xpt2046/__init__.py index 3b8a925bb23..a2b1372b626 100644 --- a/esphome/components/xpt2046/__init__.py +++ b/esphome/components/xpt2046/__init__.py @@ -1,5 +1,7 @@ import esphome.config_validation as cv +DOMAIN = "xpt2046" + CONFIG_SCHEMA = cv.invalid( "This component sould now be used as platform of the Touchscreen component." ) diff --git a/esphome/components/xxtea/__init__.py b/esphome/components/xxtea/__init__.py index 7a7bce1781a..79d31dc44a1 100644 --- a/esphome/components/xxtea/__init__.py +++ b/esphome/components/xxtea/__init__.py @@ -1,3 +1,4 @@ """ESPHome XXTEA encryption component.""" CODEOWNERS = ["@clydebarrow"] +DOMAIN = "xxtea" diff --git a/esphome/components/yashima/__init__.py b/esphome/components/yashima/__init__.py index e69de29bb2d..587acabe787 100644 --- a/esphome/components/yashima/__init__.py +++ b/esphome/components/yashima/__init__.py @@ -0,0 +1 @@ +DOMAIN = "yashima" diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 49654736cc2..4f69f00339e 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -26,6 +26,7 @@ from .const import ( ) CODEOWNERS = ["@tomaszduda23"] +DOMAIN = "zephyr" class HexValue: diff --git a/esphome/components/zephyr_mcumgr/__init__.py b/esphome/components/zephyr_mcumgr/__init__.py index e69de29bb2d..945cc5807dc 100644 --- a/esphome/components/zephyr_mcumgr/__init__.py +++ b/esphome/components/zephyr_mcumgr/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zephyr_mcumgr" diff --git a/esphome/components/zephyr_pwm/__init__.py b/esphome/components/zephyr_pwm/__init__.py index 4bcce84845a..4d3382d6171 100644 --- a/esphome/components/zephyr_pwm/__init__.py +++ b/esphome/components/zephyr_pwm/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@wiomoc"] +DOMAIN = "zephyr_pwm" diff --git a/esphome/components/zhlt01/__init__.py b/esphome/components/zhlt01/__init__.py index e69de29bb2d..c17009d722b 100644 --- a/esphome/components/zhlt01/__init__.py +++ b/esphome/components/zhlt01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zhlt01" diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 0a99db8958d..c5dc50ee5fe 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -57,6 +57,7 @@ from .zigbee_zephyr import ( _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] +DOMAIN = "zigbee" CONFLICTS_WITH = ["openthread"] diff --git a/esphome/components/zio_ultrasonic/__init__.py b/esphome/components/zio_ultrasonic/__init__.py index e69de29bb2d..7b2178dc7cd 100644 --- a/esphome/components/zio_ultrasonic/__init__.py +++ b/esphome/components/zio_ultrasonic/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zio_ultrasonic" diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index 660e3f49a0e..4817a854a86 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["api", "uart"] +DOMAIN = "zwave_proxy" zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy") ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) diff --git a/esphome/components/zyaura/__init__.py b/esphome/components/zyaura/__init__.py index e69de29bb2d..dcabd7a6bdf 100644 --- a/esphome/components/zyaura/__init__.py +++ b/esphome/components/zyaura/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zyaura" diff --git a/script/ci-custom.py b/script/ci-custom.py index d41d5b4302a..bd9b4597539 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import ast import codecs import collections from collections.abc import Iterator @@ -811,6 +812,40 @@ def lint_const_py_frozen(fname, content): return None +@lint_content_check(include=["esphome/components/*/__init__.py"]) +def lint_component_domain(fname: Path, content: str) -> str | None: + """Require every component's __init__.py to define DOMAIN as its own name.""" + if len(fname.parts) != 4: + # Platform packages such as esphome/components//sensor/__init__.py + return None + domain = fname.parts[2] + expected = f'DOMAIN = "{domain}"' + try: + tree = ast.parse(content, filename=str(fname)) + except SyntaxError: + # Reported by the Python linters; nothing useful to add here. + return None + for node in tree.body: + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, ast.AnnAssign): + targets = [node.target] + else: + continue + if ( + any(isinstance(t, ast.Name) and t.id == "DOMAIN" for t in targets) + and isinstance(node.value, ast.Constant) + and node.value.value == domain + ): + return None + return ( + f"Component is missing the {highlight(expected)} constant. " + "Add it in alphabetical order with the other component metadata such as " + "CODEOWNERS and DEPENDENCIES, so other code can refer to the component by " + "name, e.g. CORE.data[DOMAIN]." + ) + + def relative_cpp_search_text(fname: Path, content) -> str: parts = fname.parts integration = parts[2] diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 4459631293a..998650079e2 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -442,3 +442,75 @@ def test_log_tag_as_string_flags_string_uses(line: str) -> None: ) def test_log_tag_as_string_ignores_logging(line: str) -> None: assert not ci_custom.lint_log_tag_as_string("test.cpp", line + "\n") + + +# --- rule: every component __init__.py defines DOMAIN as its own name --- + + +def _domain_check() -> dict: + return next( + c + for c in ci_custom.LINT_CONTENT_CHECKS + if c["func"] is ci_custom.lint_component_domain + ) + + +def _lint_domain(fname: str, content: str) -> str | None: + return ci_custom.run_check(_domain_check(), fname, Path(fname), content) + + +@pytest.mark.parametrize( + "content", + [ + 'CODEOWNERS = ["@x"]\nDOMAIN = "uart"\n', + 'DOMAIN: str = "uart"\n', + ], +) +def test_domain_matching_component_name_passes(content: str) -> None: + assert _lint_domain("esphome/components/uart/__init__.py", content) is None + + +def test_domain_leaves_syntax_errors_to_the_python_linters() -> None: + assert _lint_domain("esphome/components/uart/__init__.py", "def (\n") is None + + +@pytest.mark.parametrize( + "content", + [ + 'CODEOWNERS = ["@x"]\n', + 'DOMAIN = "spi"\n', + "DOMAIN = CONF_UART\n", + '"""Docstring.\n\nDOMAIN = "uart"\n"""\n', + 'def f() -> None:\n DOMAIN = "uart"\n', + 'OTHER = "uart"\n', + "DOMAIN: str\n", + ], +) +def test_domain_missing_or_wrong_is_flagged(content: str) -> None: + err = _lint_domain("esphome/components/uart/__init__.py", content) + assert err is not None + assert 'DOMAIN = "uart"' in err + + +@pytest.mark.parametrize( + "fname", + [ + "esphome/components/uart/sensor/__init__.py", + "esphome/components/uart/sensor.py", + "esphome/core/__init__.py", + ], +) +def test_domain_ignores_files_other_than_component_init(fname: str) -> None: + assert _lint_domain(fname, "") is None + + +def test_every_component_defines_its_domain() -> None: + components = SCRIPT_DIR.parent / "esphome" / "components" + missing = [ + init.parent.name + for init in sorted(components.glob("*/__init__.py")) + if ci_custom.lint_component_domain( + init.relative_to(SCRIPT_DIR.parent), init.read_text(encoding="utf-8") + ) + ] + assert missing == [] From ff0d4347d64d3483f0b6591f7660a8db86c7b1c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 14:05:18 -1000 Subject: [PATCH 072/435] [api] Fix C++ unit test link failure when api is only a dependency (#20418) --- tests/components/api/__init__.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index 4a66287c33f..bd84c8d9bb4 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -1,6 +1,12 @@ +from pathlib import Path + import esphome.codegen as cg +from esphome.const import CONF_ESPHOME, CONF_INCLUDES +from esphome.core import CORE from tests.testing_helpers import ComponentManifestOverride +TESTS_DIR = Path(__file__).parent + def override_manifest(manifest: ComponentManifestOverride) -> None: # USE_API compiles every api source, so emit what they need. No socket @@ -10,16 +16,18 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_API_PLAINTEXT") # Linked wizard inputs only exist next to homeassistant entities, which need this cg.add_define("USE_API_HOMEASSISTANT_STATES") - # test_wizard.cpp supplies the tables that codegen emits for a real build - for define in ( - "USE_API_WIZARD", - "USE_API_WIZARD_INPUTS", - "USE_API_WIZARD_LINKED_INPUTS", - "USE_API_WIZARD_STANDALONE_INPUTS", - ): - cg.add_define(define) - cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + # test_wizard.cpp supplies the tables that codegen emits for a real build; + # it is only compiled when api's own tests are, not when api is a dependency + if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): + for define in ( + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + ): + cg.add_define(define) + cg.add_define("API_WIZARD_DATA_SIZE", 200) + cg.add_define("API_WIZARD_INPUT_COUNT", 2) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") From 5ca272b262713f42f8a18b76f2d74a2b024e44a0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:20:52 +1300 Subject: [PATCH 073/435] [mipi_dsi] Make config tests pass in isolation (#20420) --- tests/component_tests/mipi_dsi/test_mipi_dsi_config.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index ac385cc2d81..0b15be3e3a6 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -10,9 +10,11 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4 -# Importing xl9535 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that -# models (e.g. SEEED-RETERMINAL-D1001) that reference xl9535-backed pins in their -# defaults can be validated by the mipi_dsi CONFIG_SCHEMA in this test. +# Importing the I/O expanders registers their pin schemas with +# pins.PIN_SCHEMA_REGISTRY so that models whose defaults reference expander-backed +# pins (e.g. SEEED-RETERMINAL-D1001 uses xl9535, the M5Stack Tab5 models use +# pi4ioe5v6408) can be validated by the mipi_dsi CONFIG_SCHEMA in this test. +import esphome.components.pi4ioe5v6408 # noqa: F401 import esphome.components.xl9535 # noqa: F401 from esphome.const import ( CONF_DIMENSIONS, From 0d596a20f869d5640eeb75fb644b2cd8cc4d5394 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 14:25:11 -1000 Subject: [PATCH 074/435] [esp32] Record the PC for cache error and interrupt watchdog crashes (#20415) --- esphome/components/esp32/crash_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 78326249add..182bc5f2d6c 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -531,6 +531,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; + // IDF leaves addr unset for SoC-level panics (cache error, interrupt WDT) + if (info->pseudo_excause) + s_raw_crash_data.pc = xt_frame->pc; if (!g_panic_abort) { // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots // never wrote them and abort() traps describe only the synthetic trap. From 479232b6c0e42b0742eb68202da18057dc1c4870 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:31:24 -1000 Subject: [PATCH 075/435] Bump aioesphomeapi from 46.6.0 to 46.7.0 (#20317) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91a36ecbf70..5eff3a7d09b 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.6.0 +aioesphomeapi==46.7.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 4d42352ec57a87225dd743fc79f06b814192a7ae Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:57:46 +1000 Subject: [PATCH 076/435] [tas58xx] Improve TAS5805M audio performance and use GPIO1 for TAS5825M faults (#20319) --- esphome/components/tas58xx/model_tas5805m.cpp | 7 +++-- esphome/components/tas58xx/model_tas5825m.cpp | 27 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index de0a0c5d750..09b369435e9 100644 --- a/esphome/components/tas58xx/model_tas5805m.cpp +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -15,9 +15,12 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { {0x60, 0x01}, // ADR_PIN_CTRL: output {0x7D, 0x11}, {0x7E, 0xFF}, - {0x00, 0x01}, + {0x00, 0x01}, // Page 1 {0x51, 0x05}, - {0x00, 0x00}, + {0x00, 0x00}, // Page 0 + + // Additional register configuration + {0x53, 0x60}, // ANA_CTRL: for high audio performance use 175kHz bandwidth with Fsw=768kHz }; static const LogString *model_name() { return LOG_STR("TAS5805M"); } diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp index 3aa74c675e5..cf5861cb4a4 100644 --- a/esphome/components/tas58xx/model_tas5825m.cpp +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -4,13 +4,11 @@ namespace esphome::tas58xx { -// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page. -// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console. -// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet. - -// clang-format off +// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page. +// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k". +// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet. static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { - {0x03, 0x00}, // Deep Sleep + {0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from {0x7D, 0x11}, {0x7E, 0xFF}, {0x00, 0x01}, // Page 1 @@ -20,15 +18,16 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { {0x19, 0x80}, {0x00, 0x00}, // Page 0 {0x46, 0x11}, - {0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE - {0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase - {0x54, 0x00}, // AGAIN 0dB - {0x03, 0x02}, // Hi-Z - {0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output - {0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output - {0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults + // PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML + {0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz + {0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z + + // Additional register configuration + // GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ + {0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ + {0x60, 0x02}, // GPIO_CTRL: GPIO1 is output + {0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults }; -// clang-format on static const LogString *model_name() { return LOG_STR("TAS5825M"); } From 5bf8ceac66a2a2587549f9a4822450d5896fd16d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:17:23 +0000 Subject: [PATCH 077/435] Bump aioesphomeapi from 46.7.0 to 46.8.0 (#20321) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5eff3a7d09b..87821677e30 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.7.0 +aioesphomeapi==46.8.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 96a66f3ddd359da6fff9ae29540dd8633773cf7f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 7 Oct 2026 21:17:16 -0500 Subject: [PATCH 078/435] [core] Wake the main loop on its own task notification index on ESP32 (#20326) Co-authored-by: Claude Opus 5.5 (1M context) --- esphome/components/esp32/__init__.py | 4 ++++ esphome/core/application.h | 4 ++-- esphome/core/lwip_fast_select.c | 10 +++++----- esphome/core/lwip_fast_select.h | 8 ++++---- esphome/core/main_task.h | 26 ++++++++++++++++++++++++++ esphome/core/wake/wake_freertos.h | 8 ++++---- sdkconfig.defaults | 1 + 7 files changed, 46 insertions(+), 15 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index df72c590a33..c825deaefd9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3074,6 +3074,10 @@ async def to_code(config): # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) + # Main loop wakes use notification index 1; index 0 stays free for ESP-IDF waits + # such as pthread_join(), which a stray main-loop wake would otherwise end early. + add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES", 2) + # Place non-ISR FreeRTOS functions into flash instead of IRAM # This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM. # In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH diff --git a/esphome/core/application.h b/esphome/core/application.h index 2f399cbdcd5..b91bc187e6f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -673,8 +673,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) // Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so // subsequent Socket::ready() checks in this iter observe the published state - // without a per-call memw. Wake is independent (xTaskNotifyGive/ - // ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. + // without a per-call memw. Wake is independent (esphome_main_task_notify/ + // esphome_main_task_wait), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. // BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data() // instead. std::atomic_thread_fence(std::memory_order_acquire); diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 2042c438044..8e68594a030 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -99,10 +99,10 @@ // ESP32-C3/C6/H2) cross-core visibility is not an issue. // // FreeRTOS task notification value: -// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks -// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake). +// Written by TCP/IP thread (esphome_main_task_notify in callback) and background tasks +// (esphome_main_task_notify in wake_main_loop). Read by main loop (esphome_main_task_wait). // Safe: FreeRTOS notification APIs are thread-safe by design (use internal -// critical sections). Multiple concurrent xTaskNotifyGive calls are safe — +// critical sections). Multiple concurrent notify calls are safe — // the notification count simply increments. // USE_LWIP_FAST_SELECT is set via -D build flag (not cg.add_define) so it is @@ -178,7 +178,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // s_original_callback is always valid here: hook_socket() sets it before swapping // the callback pointer, so this wrapper cannot run until it's initialized. s_original_callback(conn, evt, len); - // Wake the main loop task if sleeping in ulTaskNotifyTake(). + // Wake the main loop task if sleeping in esphome_main_task_wait(). // Only notify on receive events to avoid spurious wakeups from send-ready events. // NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions @@ -186,7 +186,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt if (evt == NETCONN_EVT_RCVPLUS) { #ifdef USE_OTA_PLATFORM_ESPHOME // Mark OTA pending-enable only for events on its listen socket. MUST happen - // before xTaskNotifyGive so the flags are visible when the main task wakes. + // before the main task is notified so the flags are visible when it wakes. if (conn == s_ota_listener_conn) { esphome_wake_ota_component_any_context(); } diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 4ba2606d76c..69798fd8eb4 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -33,9 +33,9 @@ struct lwip_sock *esphome_lwip_get_sock(int fd); /// per-call `memw` that volatile would emit on Xtensa under default /// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped /// and the volatile load provides ordering on its own. -/// Stale reads are harmless either way: the hooked event_callback -/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and -/// ulTaskNotifyTake never loses a wake. +/// Stale reads are harmless either way: the hooked event_callback calls +/// esphome_main_task_notify() on RCVPLUS, so the next iteration re-snapshots and +/// esphome_main_task_wait() never loses a wake. /// The offset and size are verified at compile time in lwip_fast_select.c. static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS @@ -46,7 +46,7 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { } /// Hook a socket's netconn callback to notify the main loop task on receive events. -/// Wraps the original event_callback with one that also calls xTaskNotifyGive(). +/// Wraps the original event_callback with one that also calls esphome_main_task_notify(). /// Must be called from the main loop after socket creation. /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index 3aa8669e445..836d13d8f77 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -19,12 +19,25 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; +#ifdef USE_ESP32 +/// ESP-IDF waits on index 0 from the main task (e.g. pthread_join() uses an unfiltered +/// xTaskNotifyWait), so main-loop wakes on index 0 would end those waits early. +enum { ESPHOME_MAIN_TASK_NOTIFY_INDEX = 1 }; +#if configTASK_NOTIFICATION_ARRAY_ENTRIES < 2 +#error "CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES must be at least 2" +#endif +#endif + /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. /// always_inline so callers placed in IRAM do not reference a flash-resident copy. __attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { +#ifdef USE_ESP32 + xTaskNotifyGiveIndexed(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX); +#else xTaskNotifyGive(task); +#endif } } @@ -33,10 +46,23 @@ __attribute__((always_inline)) static inline void esphome_main_task_notify_from_ BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { +#ifdef USE_ESP32 + vTaskNotifyGiveIndexedFromISR(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX, px_higher_priority_task_woken); +#else vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); +#endif } } +/// Block the main loop task until it is woken or the timeout expires. +__attribute__((always_inline)) static inline void esphome_main_task_wait(TickType_t ticks) { +#ifdef USE_ESP32 + ulTaskNotifyTakeIndexed(ESPHOME_MAIN_TASK_NOTIFY_INDEX, pdTRUE, ticks); +#else + ulTaskNotifyTake(pdTRUE, ticks); +#endif +} + #ifdef __cplusplus } #endif diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h index 167a422c614..16afa38fda7 100644 --- a/esphome/core/wake/wake_freertos.h +++ b/esphome/core/wake/wake_freertos.h @@ -43,15 +43,15 @@ namespace internal { inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: + // socket wake-ups. Every NETCONN_EVT_RCVPLUS calls esphome_main_task_notify(), so any notification + // that lands between wakes keeps the counter non-zero (next esphome_main_task_wait() returns + // immediately) or wakes a blocked wait directly. Additional wake sources: // wake_loop_threadsafe() from background tasks, and the ms timeout. if (ms == 0) [[unlikely]] { yield(); return; } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); + esphome_main_task_wait(pdMS_TO_TICKS(ms)); } } // namespace internal diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f4fe331df45..25c4cc4d36c 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -13,6 +13,7 @@ CONFIG_LIBC_NEWLIB=y # esp32 CONFIG_COMPILER_OPTIMIZATION_SIZE=y CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n From 10f30ddb5900db80fb7c57e62b235ae35d57a703 Mon Sep 17 00:00:00 2001 From: David Buezas Date: Thu, 8 Oct 2026 04:53:12 +0200 Subject: [PATCH 079/435] [cc1101] Keep the remote_receiver interrupt on a shared GDO0 pin in begin_tx (#20296) --- esphome/components/cc1101/cc1101.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index f7b90b91cf6..a71e6d2fa89 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -253,11 +253,17 @@ void CC1101Component::dump_config() { } void CC1101Component::begin_tx() { + // Read the mode before the PKTCTRL0 write below overwrites it in state_ + const bool packet_mode = this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO); // Ensure Packet Format is 3 (Async Serial) this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); if (this->gdo0_pin_ != nullptr) { - this->gdo0_pin_->detach_interrupt(); + // setup() only attaches the interrupt in packet mode. In async serial mode the pin may be + // shared with remote_receiver, and detaching here would remove the receiver's interrupt. + if (packet_mode) { + this->gdo0_pin_->detach_interrupt(); + } this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); } // Transition through IDLE to bypass CCA (Clear Channel Assessment) which can From 23f6dedb423ab3ce59b5e60eae62087bc10832a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:14:40 -1000 Subject: [PATCH 080/435] Pin smp to 4.1.0 on Intel macOS so cbor2 installs from a wheel (#20302) --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 87821677e30..6e1dfa1ba51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,9 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 smpclient==7.3.0 +# smp 4.2+ needs cbor2 6+, which ships no Intel macOS wheels (arm64 only). +smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" +smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.3 # native esp-idf toolchain global cache dir From 2586e78127b3557afd8876dd1d2d50d896722e88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:23:01 -1000 Subject: [PATCH 081/435] [core] Warn on every run that Intel Mac support ends by 2027.6.0 (#20303) --- esphome/__main__.py | 36 +++++++++++++++++++++++++++----- tests/unit_tests/test_main.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6c984050f42..905339d2bcc 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -7,6 +7,7 @@ import importlib import logging import os from pathlib import Path +import platform import re import sys import time @@ -54,7 +55,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError, coroutine from esphome.enum import StrEnum -from esphome.helpers import get_bool_env, indent, is_ip_address +from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.stacktrace import LogLineProcessor from esphome.types import ConfigType @@ -1311,9 +1312,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: # platform's final-validate hook merges duplicates anyway. available: dict[str, None] = {} for ota_item in config.get(CONF_OTA, []): - platform = ota_item.get(CONF_PLATFORM) - if platform in (CONF_ESPHOME, CONF_WEB_SERVER): - available[platform] = None + ota_platform = ota_item.get(CONF_PLATFORM) + if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER): + available[ota_platform] = None if not available: raise EsphomeError( @@ -1945,7 +1946,10 @@ def run_multiple_configs( safe_print() cmd = command_builder(f) - rc = run_external_process(*cmd) + # The parent already logged the Intel macOS warning; children skip it. + rc = run_external_process( + *cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"} + ) if rc == 0: print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") @@ -2614,6 +2618,27 @@ def _warn_if_source_tree_mismatch() -> None: ) +_INTEL_MACOS_REMOVAL = "2027.6.0" +_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED" + + +def _warn_if_intel_macos() -> None: + """Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL.""" + if ( + not IS_MACOS + or platform.machine() != "x86_64" + or _INTEL_MACOS_WARNED_ENV in os.environ + ): + return + _LOGGER.warning( + "Support for Intel Macs will end in ESPHome %s or earlier. The Python " + "packages ESPHome depends on have stopped publishing Intel macOS builds, " + "so future releases will not install on this machine. On an Apple " + "Silicon Mac, switch to a native arm64 Python.", + _INTEL_MACOS_REMOVAL, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2633,6 +2658,7 @@ def run_esphome(argv): setup_log(log_level=args.log_level) _warn_if_source_tree_mismatch() + _warn_if_intel_macos() if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4edf6d5fc31..37d8e7486f4 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -6705,6 +6705,45 @@ def test_check_permissions_unreadable_port() -> None: check_permissions("/dev/ttyUSB99") +@pytest.mark.parametrize( + ("is_macos", "machine", "warned", "warns"), + [ + pytest.param(True, "x86_64", False, True, id="intel_mac"), + pytest.param(True, "x86_64", True, False, id="intel_mac_child"), + pytest.param(True, "arm64", False, False, id="apple_silicon"), + pytest.param(False, "x86_64", False, False, id="linux_x86_64"), + ], +) +def test_warn_if_intel_macos( + is_macos: bool, + machine: str, + warned: bool, + warns: bool, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Only Intel Python on macOS gets the warning, and only once per invocation.""" + monkeypatch.setattr(main, "IS_MACOS", is_macos) + monkeypatch.setattr(main.platform, "machine", lambda: machine) + if warned: + monkeypatch.setenv(main._INTEL_MACOS_WARNED_ENV, "1") + else: + monkeypatch.delenv(main._INTEL_MACOS_WARNED_ENV, raising=False) + + with caplog.at_level(logging.WARNING): + main._warn_if_intel_macos() + + assert (main._INTEL_MACOS_REMOVAL in caplog.text) is warns + + +def test_run_multiple_configs_marks_children_warned(tmp_path: Path) -> None: + """Multi-config children get the env marker so they skip the Intel warning.""" + with patch.object(main, "run_external_process", return_value=0) as mock_run: + main.run_multiple_configs([tmp_path / "a.yaml"], lambda f: ["esphome", str(f)]) + + assert mock_run.call_args.kwargs["env"][main._INTEL_MACOS_WARNED_ENV] == "1" + + def _make_checkout(root: Path) -> Path: """Create a directory that looks like an esphome checkout.""" (root / "esphome").mkdir(parents=True) From e7ba9a572654d6a120f1613598ee099b6a43aa59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:24:27 -1000 Subject: [PATCH 082/435] [api] Add a progmem option for bytes fields kept in flash (#20318) --- esphome/components/api/api.proto | 4 +- esphome/components/api/api_connection.h | 3 -- esphome/components/api/api_options.proto | 5 ++ esphome/components/api/api_pb2.cpp | 2 +- esphome/components/api/api_pb2_dump.cpp | 17 ++++-- esphome/components/api/api_wizard.cpp | 21 +------- esphome/components/api/api_wizard.h | 4 -- esphome/components/api/proto.h | 16 ++++++ script/api_protobuf/api_protobuf.py | 46 ++++++++++++++-- tests/components/api/test_wizard.cpp | 6 +-- .../api/test_api_protobuf_generator.py | 53 +++++++++++++++++++ 11 files changed, 138 insertions(+), 39 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2648b5d0d88..0d2ac36937c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -468,11 +468,9 @@ message DeviceWizardResponse { option (id) = 157; option (source) = SOURCE_SERVER; option (ifdef) = "USE_API_WIZARD"; - // Not logged: the data is in flash, which ESP8266 cannot read for a dump - option (log) = false; // The data is in flash, and the device sends it from there - bytes data = 1 [(pointer_to_buffer) = true]; + bytes data = 1 [(pointer_to_buffer) = true, (progmem) = true]; } // Sets the entity id of a wizard input (see WizardInputField.key). Clients send diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4d927b28857..b7f0bda0ed7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -423,9 +423,6 @@ class APIConnection final : public APIServerConnectionBase { bool send_ping_response_(); bool send_device_info_response_(); bool send_device_capabilities_response_(); -#ifdef USE_API_WIZARD - bool send_device_wizard_response_(); -#endif #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 66295b3d535..8e8fa99833d 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions { // to true when the field arrives, so an all-default submessage can be told apart // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). optional bool track_presence = 50020 [default=false]; + + // progmem: The pointer_to_buffer bytes field points into flash (PROGMEM). + // The generated encode and dump copy it with progmem_memcpy, as ESP8266 + // cannot read flash with ordinary loads. + optional bool progmem = 50021 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 67a5d9792e5..646a8f4211e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -352,7 +352,7 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #ifdef USE_API_WIZARD uint8_t *DeviceWizardResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); return pos; } uint32_t DeviceWizardResponse::calc_size_msg(const void *self) { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 966ba8f2bf5..196a4249cc6 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -114,15 +114,26 @@ template static void dump_field(DumpBuffer &out, const char *field_n out.append("\n"); } +// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer +static constexpr size_t DUMP_BYTES_MAX = 160; + // Helper for bytes fields - uses stack buffer to avoid heap allocation -// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer // field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms) static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) { - char hex_buf[format_hex_pretty_size(160)]; + char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)]; append_field_prefix(out, field_name, indent); format_hex_pretty_to(hex_buf, data, len); out.append(hex_buf).append("\n"); } + +// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first +static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, + int indent = 2) { + uint8_t data_buf[DUMP_BYTES_MAX]; + len = std::min(len, sizeof(data_buf)); + progmem_memcpy(data_buf, data, len); + dump_bytes_field(out, field_name, data_buf, len, indent); +} #pragma GCC diagnostic pop template<> const char *proto_enum_to_string(enums::DisconnectReason value) { @@ -1119,7 +1130,7 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { #ifdef USE_API_WIZARD const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse")); - dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); + dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); return out.c_str(); } #endif diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index ec270ab4111..986017a138d 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -13,17 +13,6 @@ namespace esphome::api { static const char *const TAG = "api.wizard"; -uint8_t *wizard_encode_response(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { - const auto &msg = *static_cast(self); - if (msg.data_len == 0) - return pos; - pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited - pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len); - PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len); - progmem_memcpy(pos, msg.data, msg.data_len); - return pos + msg.data_len; -} - #ifdef USE_API_WIZARD_INPUTS static bool wizard_entity_id_valid(const char *entity_id, size_t length) { return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; @@ -49,17 +38,11 @@ const char *wizard_set_input(const WizardInputSetRequest &msg) { } #endif // USE_API_WIZARD_INPUTS -bool APIConnection::send_device_wizard_response_() { +void APIConnection::on_device_wizard_request() { DeviceWizardResponse resp; resp.data = API_WIZARD_DATA; resp.data_len = API_WIZARD_DATA_SIZE; - // Not send_message: the data is in flash, so wizard_encode_response copies it out - return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE, - &wizard_encode_response, &resp); -} - -void APIConnection::on_device_wizard_request() { - if (!this->send_device_wizard_response_()) { + if (!this->send_message(resp)) { this->on_fatal_error(); } } diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 948b9445f22..7d5a566b550 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -22,10 +22,6 @@ static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256; /// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long. extern const uint8_t API_WIZARD_DATA[] PROGMEM; -/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read -/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere. -uint8_t *wizard_encode_response(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); - #ifdef USE_API_WIZARD_INPUTS /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 5f2a90d5472..0fdda5067ea 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -3,6 +3,7 @@ #include "api_pb2_defines.h" #include "api_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -465,6 +466,21 @@ class ProtoEncode { uint32_t field_id, const uint8_t *data, size_t len) { return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len); } + /// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy. + [[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + if (len == 0) + return pos; + return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len); + } + [[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited + pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len); + PROTO_ENCODE_CHECK_BOUNDS(pos, len); + progmem_memcpy(pos, data, len); + return pos + len; + } [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f644859d013..701765bb419 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -536,6 +536,16 @@ def create_field_type_info( f"track_presence on field '{field.name}' has no effect; it requires " "a non-repeated message field in a message that is decoded" ) + if get_field_opt(field, pb.progmem, False) and ( + field.label == FieldDescriptorProto.LABEL_REPEATED + or field.type != 12 + or not get_field_opt(field, pb.pointer_to_buffer, False) + or get_field_opt(field, pb.fixed_array_size) is not None + ): + raise ValueError( + f"progmem on field '{field.name}' requires a non-repeated bytes field " + "with pointer_to_buffer" + ) if field.label == FieldDescriptorProto.LABEL_REPEATED: # Check if this is a packed_buffer field (zero-copy packed repeated) if get_field_opt(field, pb.packed_buffer, False): @@ -1158,6 +1168,11 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): reference_type = "const uint8_t*" const_reference_type = "const uint8_t*" + @property + def progmem(self) -> bool: + """Whether the data is in flash, so encode and dump copy it with progmem_memcpy.""" + return get_field_opt(self._field, pb.progmem, False) + @property def public_content(self) -> list[str]: # Use uint16_t for length - max packet size is well below 65535 @@ -1168,6 +1183,14 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): @property def encode_content(self) -> str: + if self.progmem: + return _encode_call( + "encode_progmem_bytes", + str(self.number), + f"this->{self.field_name}", + f"this->{self.field_name}_len", + force=self.force, + ) if result := self._encode_bytes_with_precomputed_tag( f"this->{self.field_name}", f"this->{self.field_name}_len" ): @@ -1194,8 +1217,9 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): @property def dump_content(self) -> str: + dump_fn = "dump_progmem_bytes_field" if self.progmem else "dump_bytes_field" return ( - f'dump_bytes_field(out, ESPHOME_PSTR("{self.name}"), ' + f'{dump_fn}(out, ESPHOME_PSTR("{self.name}"), ' f"this->{self.field_name}, this->{self.field_name}_len);" ) @@ -2609,6 +2633,11 @@ def build_message_type( ): fixed_vector_fields.append((field.name, field.number)) + if needs_decode and get_field_opt(field, pb.progmem, False): + raise ValueError( + f"progmem on field '{field.name}' of {desc.name} requires a message " + "that is only encoded; received data is never in flash" + ) ti = create_field_type_info(field, needs_decode, needs_encode) # Skip field declarations for fields that are in the base class @@ -3319,15 +3348,26 @@ template static void dump_field(DumpBuffer &out, const char *field_n out.append("\\n"); } +// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer +static constexpr size_t DUMP_BYTES_MAX = 160; + // Helper for bytes fields - uses stack buffer to avoid heap allocation -// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer // field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms) static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) { - char hex_buf[format_hex_pretty_size(160)]; + char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)]; append_field_prefix(out, field_name, indent); format_hex_pretty_to(hex_buf, data, len); out.append(hex_buf).append("\\n"); } + +// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first +static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, + int indent = 2) { + uint8_t data_buf[DUMP_BYTES_MAX]; + len = std::min(len, sizeof(data_buf)); + progmem_memcpy(data_buf, data, len); + dump_bytes_field(out, field_name, data_buf, len, indent); +} #pragma GCC diagnostic pop """ diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 2e6b38edb99..033c8b627e5 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -68,7 +68,7 @@ TEST(DeviceWizard, ResponseSendsTheDataUnchanged) { Bytes expected{0x0a, 0xc8, 0x01}; expected.insert(expected.end(), API_WIZARD_DATA, API_WIZARD_DATA + API_WIZARD_DATA_SIZE); - EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected); } TEST(DeviceWizard, ShortDataHasAOneByteLength) { @@ -77,13 +77,13 @@ TEST(DeviceWizard, ShortDataHasAOneByteLength) { resp.data_len = 3; Bytes expected{0x0a, 0x03, API_WIZARD_DATA[0], API_WIZARD_DATA[1], API_WIZARD_DATA[2]}; - EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected); } TEST(DeviceWizard, NoDataEncodesNothing) { DeviceWizardResponse resp; EXPECT_EQ(resp.calculate_size(), 0u); - EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response).empty()); + EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg).empty()); } TEST(DeviceWizard, CapabilitiesAnnounceTheWizard) { diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 2e4eb67a241..5e29b06ef07 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -360,3 +360,56 @@ def test_repeated_strings_generate_in_every_direction(source: int) -> None: assert "nullptr" not in header if source != SOURCE_SERVER: assert "case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED):" in cpp + + +def _progmem_field( + *, pointer_to_buffer: bool = True, force: bool = False, repeated: bool = False +) -> descriptor_pb2.FieldDescriptorProto: + field = _field(12, force=force, repeated=repeated) + field.options.Extensions[pb.progmem] = True + if pointer_to_buffer: + field.options.Extensions[pb.pointer_to_buffer] = True + return field + + +@pytest.mark.parametrize( + ("force", "encode_fn"), + [(False, "encode_progmem_bytes("), (True, "encode_progmem_bytes_force(")], +) +def test_progmem_bytes_field_copies_from_flash(force: bool, encode_fn: str) -> None: + """A (progmem) field encodes and dumps through the progmem_memcpy helpers.""" + ti = create_field_type_info(_progmem_field(force=force), needs_decode=False) + assert encode_fn in ti.encode_content + assert "encode_bytes" not in ti.encode_content + assert "dump_progmem_bytes_field(" in ti.dump_content + + +@pytest.mark.parametrize( + "field", + [ + pytest.param(_progmem_field(pointer_to_buffer=False), id="no_pointer"), + pytest.param(_progmem_field(repeated=True), id="repeated"), + ], +) +def test_progmem_rejected_where_it_cannot_apply( + field: descriptor_pb2.FieldDescriptorProto, +) -> None: + """(progmem) on a field that would not copy from flash fails instead of silently using memcpy.""" + with pytest.raises(ValueError, match="progmem on field 'value'"): + create_field_type_info(field, needs_decode=False) + + +def test_progmem_rejected_in_a_decoded_message() -> None: + """Received data is never in flash, so a decoded message cannot use (progmem).""" + desc = descriptor_pb2.DescriptorProto(name="Received") + desc.field.add().CopyFrom(_progmem_field()) + with pytest.raises(ValueError, match="progmem on field 'value' of Received"): + build_message_type(desc, {}, {"Received": SOURCE_CLIENT}) + + +def test_progmem_rejected_on_non_bytes_field() -> None: + """(progmem) only applies to bytes fields.""" + field = _field(9) + field.options.Extensions[pb.progmem] = True + with pytest.raises(ValueError, match="progmem on field 'value'"): + create_field_type_info(field, needs_decode=False) From b28e7a13572c6450c3afe5f95dc6dade1149d909 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 18:05:18 -1000 Subject: [PATCH 083/435] [esp8266] Normalize misspelled boards and fall back to PlatformIO for unknown ones (#20322) --- esphome/components/esp8266/__init__.py | 31 ++++++++++- .../esp8266/test_toolchain_validation.py | 53 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index e4d8dfc4928..d0d870c2894 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -114,9 +114,37 @@ _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) _resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO) +# Known boards keyed by spelling with case and "-" / "_" folded away +_FOLDED_BOARDS = {board.lower().replace("-", "_"): board for board in BOARDS} + + +def _resolve_board(config: ConfigType) -> ConfigType: + """Fix a misspelled board, else build an unknown one with PlatformIO.""" + board = config[CONF_BOARD] + if board in BOARDS: + return config + if canonical := _FOLDED_BOARDS.get(board.lower().replace("-", "_")): + _LOGGER.warning( + "Board '%s' is not a known ESP8266 board; using '%s'", board, canonical + ) + return {**config, CONF_BOARD: canonical} + if CORE.toolchain is None and CONF_TOOLCHAIN not in config: + return {**config, CONF_TOOLCHAIN: Toolchain.PLATFORMIO} + return config + + def _warn_platformio_toolchain(config: ConfigType) -> ConfigType: # Remove before 2027.4.0 - if CORE.using_toolchain_platformio: + if not CORE.using_toolchain_platformio: + return config + if config[CONF_BOARD] not in BOARDS: + _LOGGER.warning( + "Board '%s' is not supported by the native 'arduino' toolchain, so it " + "builds with the deprecated 'platformio' toolchain, which will be " + "removed in ESPHome 2027.4.0", + config[CONF_BOARD], + ) + else: _LOGGER.warning( "The 'platformio' toolchain for ESP8266 is deprecated and will be " "removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the " @@ -329,6 +357,7 @@ CONFIG_SCHEMA = cv.All( ): _validate_toolchain, } ), + _resolve_board, _resolve_toolchain, _warn_platformio_toolchain, _validate_native_toolchain, diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index e7f443c2d23..1092c168ad2 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -12,10 +12,12 @@ import pytest from esphome.components import esp8266 from esphome.components.esp8266 import ( ARDUINO_FRAMEWORK_SCHEMA, + _resolve_board, _resolve_toolchain, _validate_native_toolchain, _warn_platformio_toolchain, ) +from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266 import esphome.config_validation as cv from esphome.const import ( CONF_BOARD, @@ -118,6 +120,57 @@ def test_unsupported_board_rejected() -> None: _validate_native_toolchain(_config(board="not_a_board")) +def test_known_board_passes_unchanged() -> None: + config = {CONF_BOARD: "esp01_1m"} + assert _resolve_board(config) is config + + +@pytest.mark.parametrize( + ("board", "expected"), + [("ESP01-1M", "esp01_1m"), ("SPARKFUNBLYNK", "sparkfunBlynk")], +) +def test_misspelled_board_normalized( + board: str, expected: str, caplog: pytest.LogCaptureFixture +) -> None: + assert _resolve_board({CONF_BOARD: board})[CONF_BOARD] == expected + assert f"using '{expected}'" in caplog.text + + +@pytest.mark.parametrize( + ("board", "expected_board", "expected_toolchain"), + [ + ("esp01-1m", "esp01_1m", Toolchain.ARDUINO), + ("my_custom_board", "my_custom_board", Toolchain.PLATFORMIO), + ], +) +def test_config_schema_resolves_board_before_toolchain( + board: str, + expected_board: str, + expected_toolchain: Toolchain, + caplog: pytest.LogCaptureFixture, +) -> None: + """The full chain normalizes or falls back, and warns once.""" + CORE.toolchain = None + config = esp8266.CONFIG_SCHEMA({CONF_BOARD: board}) + assert config[CONF_BOARD] == expected_board + assert CORE.data[KEY_ESP8266][KEY_BOARD] == expected_board + assert CORE.toolchain == expected_toolchain + assert len(caplog.records) == 1 + + +@pytest.mark.parametrize( + ("cli_toolchain", "yaml"), + [(Toolchain.ARDUINO, {}), (None, {CONF_TOOLCHAIN: Toolchain.ARDUINO})], +) +def test_unknown_board_keeps_explicit_toolchain( + cli_toolchain: Toolchain | None, yaml: ConfigType +) -> None: + """A CLI or YAML toolchain is not overridden.""" + CORE.toolchain = cli_toolchain + config = {CONF_BOARD: "my_custom_board", **yaml} + assert _resolve_board(config) is config + + def test_yaml_toolchain_key_resolves() -> None: """The documented `toolchain: arduino` YAML key selects the native path.""" CORE.toolchain = None From adc6255fd41b68b1a48ed85d07250a73ecf7f448 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:29:56 +1300 Subject: [PATCH 084/435] [core] Let cv.rename_key pass non-dict input through (#20331) --- esphome/config_validation.py | 5 +++++ tests/unit_tests/test_config_validation.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 579da9a315e..79d7068838a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2872,9 +2872,14 @@ def rename_key( When ``removed_in`` is set, a deprecation warning is logged if the old key is present. Pass ``component`` (the platform/component name) alongside ``removed_in`` so the warning identifies where it originates. + + Input that is not a dictionary is returned unchanged, so the schema that + follows reports it as a normal configuration error. """ def validator(config: dict) -> dict: + if not isinstance(config, dict): + return config config = config.copy() if old_key in config: has_at_most_one_key(old_key, new_key)(config) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 230a8e1f9ef..742f07c8dec 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -3120,6 +3120,11 @@ def test_rename_key_absent() -> None: assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} +@pytest.mark.parametrize("value", ["an_id", 1, None, ["old"]]) +def test_rename_key_non_dict_passes_through(value: object) -> None: + assert cv.rename_key("old", "new")(value) == value + + def test_rename_key_no_removed_in_is_silent( caplog: pytest.LogCaptureFixture, ) -> None: From 63b4a5958823e136b4f1133a28e6e8bb075f3334 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:03:30 -1000 Subject: [PATCH 085/435] Bump bundled esphome-device-builder to 1.22.0 (#20334) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9f29062e093..0282011f504 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.22.0 RUN \ platformio settings set enable_telemetry No \ From b903c54be4d2adc1403934e7b319ffdb4d922829 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:15:43 +1300 Subject: [PATCH 086/435] [combination] Use cv.rename_key for the deprecated coeffecient key (#20159) Co-authored-by: J. Nick Koston --- esphome/components/combination/sensor.py | 32 ++++--------------- .../validate-deprecated.esp32-idf.yaml | 15 +++++++++ 2 files changed, 22 insertions(+), 25 deletions(-) create mode 100644 tests/components/combination/validate-deprecated.esp32-idf.yaml diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index 34e9b99ce84..608ebac4d6a 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -1,5 +1,3 @@ -import logging - import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -18,8 +16,6 @@ from esphome.const import ( from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@Cat-Ion", "@kahrendt"] combination_ns = cg.esphome_ns.namespace("combination") @@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema( ) -def _migrate_coeffecient(config: ConfigType) -> ConfigType: - """Migrate deprecated 'coeffecient' spelling to 'coefficient'.""" - if CONF_COEFFECIENT in config: - if CONF_COEFFICIENT in config: - raise cv.Invalid( - f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'" - ) - _LOGGER.warning( - "'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0", - CONF_COEFFECIENT, - CONF_COEFFICIENT, - ) - config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT) - elif CONF_COEFFICIENT not in config: - raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option") - return config - - LINEAR_SOURCE_SCHEMA = cv.All( + cv.rename_key( + CONF_COEFFECIENT, + CONF_COEFFICIENT, + removed_in="2026.12.0", + component="combination", + ), cv.Schema( { cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor), - cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_), - cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_), + cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_), } ), - _migrate_coeffecient, ) SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema( diff --git a/tests/components/combination/validate-deprecated.esp32-idf.yaml b/tests/components/combination/validate-deprecated.esp32-idf.yaml new file mode 100644 index 00000000000..829d76445d5 --- /dev/null +++ b/tests/components/combination/validate-deprecated.esp32-idf.yaml @@ -0,0 +1,15 @@ +sensor: + - platform: template + id: template_temperature1 + lambda: return 0.6; + - platform: template + id: template_temperature2 + lambda: return 0.8; + - platform: combination + type: linear + name: Linearly combined temperatures + sources: + - source: template_temperature1 + coeffecient: 0.5 + - source: template_temperature2 + coefficient: 1.5 From 9d6b50320d09ecd275181abe37c6cb6f6357060e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 00:48:08 -1000 Subject: [PATCH 087/435] [noise] Bump noise-c to 0.1.31 and libsodium to 1.10021.12 (#20345) --- esphome/components/noise/__init__.py | 4 ++-- esphome/idf_component.yml | 4 ++-- platformio.ini | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d9880fe6f9c..66043236b1a 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -13,8 +13,8 @@ CODEOWNERS = ["@esphome/core"] # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its manifests. -NOISE_C_VERSION = "0.1.30" -LIBSODIUM_VERSION = "1.10021.11" +NOISE_C_VERSION = "0.1.31" +LIBSODIUM_VERSION = "1.10021.12" noise_ns = cg.esphome_ns.namespace("noise") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f532e262497..6bae823acb3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -113,11 +113,11 @@ dependencies: # noise (api, ota). Arduino below IDF 6.0 bundles espressif/libsodium, which # clashes with esphome/libsodium, so those builds use the PlatformIO libraries. esphome/noise-c: - version: 0.1.30 + version: 0.1.31 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" # Declared alongside noise-c so the library converter skips wireguard's copy esphome/libsodium: - version: 1.10021.11 + version: 1.10021.12 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" diff --git a/platformio.ini b/platformio.ini index 82463b89532..56a12d8b31c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -79,7 +79,7 @@ lib_deps = ; noise (api, ota) -- on the ESP-IDF framework these come from the ; component registry instead (see esphome/idf_component.yml), so they ; are not in [common]. - esphome/noise-c@0.1.30 ; noise (api, ota) + 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 @@ -247,7 +247,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base esphome/wireguard@0.4.8 ; wireguard - esphome/noise-c@0.1.30 ; noise (api, ota) + esphome/noise-c@0.1.31 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -645,7 +645,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.30 ; used by noise (api, ota) + esphome/noise-c@0.1.31 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From f5c15f66a583548b5423baa4cba5e00c4b7907bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:06:45 -1000 Subject: [PATCH 088/435] [esp8266] Force-include the throw stubs into the core and libraries on the native toolchain (#20336) --- esphome/build_gen/arduino8266.py | 67 ++++++++++++------- esphome/build_helpers/ninja_gen.py | 6 +- esphome/components/esp8266/__init__.py | 9 ++- esphome/components/esp8266/const.py | 2 + esphome/components/esp8266/throw_stubs.h | 3 +- .../unit_tests/build_gen/test_arduino8266.py | 45 +++++++++++-- 6 files changed, 93 insertions(+), 39 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 5b14af5a1d7..44369136a01 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -54,6 +54,7 @@ from esphome.components.esp8266.const import ( KEY_ESP8266, KEY_FLASH_SIZE, KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, ) from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_project_cxx_compile_flags @@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: core_exclude |= _CORE_EXCLUDE_WAVEFORM - archives = [] - # variant_dir existence was already enforced with the include dirs - variant_sources = collect_sources(variant_dir) - if variant_sources: - objs = compile_edges(lines, variant_sources, variant_dir, "variant") - lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") - archives.append("libFrameworkArduinoVariant.a") - - core_objs = compile_edges( - lines, collect_sources(core_dir, core_exclude), core_dir, "core" - ) - if not core_objs: - # An empty archive would link into a wall of undefined references - # (app_entry, the exception vectors) far from the cause - raise EsphomeError( - f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" - ) - lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") - archives.append("libFrameworkArduino.a") - - lib_archives, direct_objs = library_edges(lines, libraries) - archives += lib_archives - - # One source of truth with the PlatformIO path: esp8266/__init__ pins - # build_src_flags (the throw_stubs force-include); -include paths - # resolve against the source root + # esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on + # the PlatformIO path; -include paths resolve against the source root src_other: list[str] = [] src_includes: list[str] = [] src_it = iter( @@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: src_includes.append(tok[len("-include") :]) else: src_other.append(_shell_token(tok)) + # The throw stubs abort: none anywhere with exceptions on, else the core and + # libraries get them too (src keeps them after its pch include) + if config.exceptions: + src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER] include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes] + framework_flags = "" + if THROW_STUBS_HEADER in src_includes: + stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)] + lines.append(f"frameworkflags = {stubs_flag}") + framework_flags = "$frameworkflags" + archives = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = collect_sources(variant_dir) + if variant_sources: + objs = compile_edges( + lines, variant_sources, variant_dir, "variant", flags=framework_flags + ) + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = compile_edges( + lines, + collect_sources(core_dir, core_exclude), + core_dir, + "core", + flags=framework_flags, + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + lib_archives, direct_objs = library_edges(lines, libraries, framework_flags) + archives += lib_archives + # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) lines.append(f"srcflags = {' '.join(src_other + include_flags)}") diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index 8e72b36b3fd..a2ee244a29f 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -207,10 +207,12 @@ def compile_edges( def library_edges( - lines: list[str], libraries: list[ArduinoLibrary] + lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = "" ) -> tuple[list[str], list[str]]: """Emit every library's compile and archive edges. + ``extra_flags`` (raw ninja text) precede each library's own flags. + Returns the archive names and the objects that link directly. A library's own include dirs lead its compile lines, as PlatformIO searched them first: the include path is one global list, so another library's @@ -232,7 +234,7 @@ def library_edges( lib.sources, common_parent(lib.sources), f"lib/{lib.name}", - flags=" ".join(_shell_token(f) for f in lib.flags), + flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])), own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs), ) if not lib.lib_archive: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d0d870c2894..57fac2dc98a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,6 +53,7 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + THROW_STUBS_HEADER, enable_serial, enable_serial1, esp8266_ns, @@ -477,11 +478,9 @@ async def to_code(config: ConfigType) -> None: # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h. Unconditional: the native build generator reads - # the same option, keeping one source of truth. - cg.add_platformio_option( - "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" - ) + # See throw_stubs.h. The native build generator reads this option, also + # passes it to the core and libraries, and drops it with exceptions on. + cg.add_platformio_option("build_src_flags", f"-include {THROW_STUBS_HEADER}") # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index bf4dd1a3584..fb50b6a5ca2 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -17,6 +17,8 @@ KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator KEY_SCANF_FLOAT = "scanf_float" +# Force-included std::__throw_* overrides, relative to the src dir +THROW_STUBS_HEADER = "esphome/components/esp8266/throw_stubs.h" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h index a650935a5e2..c18814d4d80 100644 --- a/esphome/components/esp8266/throw_stubs.h +++ b/esphome/components/esp8266/throw_stubs.h @@ -15,7 +15,8 @@ * unused, dead-strip the load, and drop the string entirely -- no LTO needed. * Behavior is identical to today: a bare abort() (the message was never * printed). This header MUST be force-included before , so it is - * wired up via build_src_flags "-include ..." in this component's __init__.py. + * wired up via build_src_flags "-include ..." in this component's __init__.py; + * the native toolchain also force-includes it into the core and libraries. * * Note: this defines functions in namespace std (technically UB). It is safe * here because the definitions match the existing abort() behavior exactly. diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 066746d36d8..2ea4ffabe6e 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -34,7 +34,12 @@ from esphome.components.esp8266.boards import ( board_ld_script, ) from esphome.components.esp8266.build_surgery import RATETABLE_RULE -from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT +from esphome.components.esp8266.const import ( + KEY_BOARD, + KEY_ESP8266, + KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, +) import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -360,18 +365,22 @@ def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: # Assembly and C sources compile through their own rules assert "cont.S.o: aspp" in content assert "abi.c.o: c" in content - # throw_stubs is force-included for ESPHome sources only, via one shared - # srcflags variable rather than a copy of the flags line per edge + # throw_stubs reaches src through srcflags (after the pch include) and the + # core through frameworkflags: one shared variable each, not a copy per edge src_lines = [line for line in content.splitlines() if "obj/src/" in line] assert any("main.cpp.o: cxx" in line for line in src_lines) - assert content.count("throw_stubs.h") == 1 + assert content.count("throw_stubs.h") == 2 assert "srcflags = -include" in content flags_lines = [ line for line in content.splitlines() if line.startswith(" flags = ") ] assert flags_lines # C++ src edges consume the precompiled header; C/assembly keep srcflags - assert set(flags_lines) == {" flags = $srcflags", " flags = $srccxxflags"} + assert set(flags_lines) == { + " flags = $srcflags", + " flags = $srccxxflags", + " flags = $frameworkflags", + } def test_write_project_pch(tmp_path: Path) -> None: @@ -715,6 +724,8 @@ def test_write_project_libraries_and_variant( assert "libHeadersOnly.a" not in content assert "Library HeadersOnly has no source files" in caplog.text assert " flags = -DMYLIB=1" in content + # With exceptions on, nothing gets the stubs + assert "throw_stubs.h" not in content # A library's own include dirs lead its compile lines assert " own_includes = -I" in content assert "$own_includes $cxxflags $flags" in content @@ -729,6 +740,30 @@ def test_write_project_libraries_and_variant( assert f"ccache = {_shq('/cc/ccache')}" in content +def test_write_project_throw_stubs_reach_core_and_libraries(tmp_path: Path) -> None: + """Without exceptions the core and libraries take the throw stubs too.""" + paths = _make_framework(tmp_path) + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + ) + content = _write_ninja(paths, libraries=[library]) + stubs = CORE.relative_src_path() / THROW_STUBS_HEADER + assert f"frameworkflags = -include {_shq(str(stubs))}" in content + # Before the library's own flags; core edges take the variable alone + assert " flags = $frameworkflags -DMYLIB=1" in content + lines = content.splitlines() + core_edge = next( + i for i, line in enumerate(lines) if "core_esp8266_main.cpp.o: cxx" in line + ) + assert lines[core_edge + 1] == " flags = $frameworkflags" + + def test_get_flash_ld_path(tmp_path: Path) -> None: paths = InstalledPaths( From 686252f151359a38711401366949e6e80a044ff1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:09:39 -1000 Subject: [PATCH 089/435] [esp8266] Fix Arduino core regressions that keep data in RAM and link the unused unwinder (#20337) --- esphome/arduino8266/framework.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index b731b746751..48fd72d1da7 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple): # Arduino core version -> its build in esphome-libs/arduino-esp8266 FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { Version(3, 1, 2): FrameworkRelease( - "3.1.2-esphome.1", - "e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582", - 37189311, + "3.1.2-esphome.2", + "9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7", + 37192128, ), } From 1d86cc39e99722153c58e87cbb0af230401e00b6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 12:33:46 -0400 Subject: [PATCH 090/435] [sendspin] Update to sendspin-cpp v0.9.3 (#20347) --- esphome/components/sendspin/__init__.py | 16 +- esphome/components/sendspin/image/__init__.py | 16 +- .../sendspin/image/sendspin_image.cpp | 4 +- .../sendspin/image/sendspin_image.h | 4 +- .../media_player/sendspin_media_player.cpp | 2 +- .../media_player/sendspin_media_player.h | 2 +- .../sendspin/media_source/__init__.py | 7 + .../media_source/sendspin_media_source.cpp | 14 +- .../media_source/sendspin_media_source.h | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 232 +++++++++++++----- esphome/components/sendspin/sendspin_hub.h | 63 ++--- .../sendspin/sensor/sendspin_sensor.cpp | 2 +- .../sendspin/sensor/sendspin_sensor.h | 2 +- .../sendspin/switch/sendspin_switch.cpp | 4 +- .../sendspin/switch/sendspin_switch.h | 4 +- .../text_sensor/sendspin_text_sensor.cpp | 2 +- .../text_sensor/sendspin_text_sensor.h | 2 +- esphome/idf_component.yml | 5 +- .../sendspin/test_framework.py | 27 ++ tests/component_tests/sendspin/test_image.py | 8 + .../sendspin/test_media_source.py | 10 + 21 files changed, 303 insertions(+), 125 deletions(-) create mode 100644 tests/component_tests/sendspin/test_framework.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 42dae464ede..2fb8d4a17ec 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -76,7 +76,6 @@ OPUS_SAMPLE_RATE = 48000 SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") -IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") @@ -211,6 +210,8 @@ CONFIG_SCHEMA = cv.All( } ), cv.only_on_esp32, + # sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 cannot use. + cv.only_with_framework("esp-idf"), _request_high_performance_networking, ) @@ -265,7 +266,9 @@ async def to_code(config: ConfigType) -> None: cg.add(setter(value)) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") + # esp_websocket_client links esp_tls even for ws:// connections. + esp32.request_tls() cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -276,8 +279,9 @@ async def to_code(config: ConfigType) -> None: data = _get_data() - # The color role is not yet wired up in ESPHome; disable it in the library for now. + # The color and source roles are not yet wired up in ESPHome; disable them in the library for now. esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False) + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False) # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). @@ -351,7 +355,8 @@ async def to_code(config: ConfigType) -> None: ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), - ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), + # The released YAML key name is kept for compatibility. + ("initial_output_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), ("psram_stack", psram_stack), ] if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None: @@ -366,6 +371,9 @@ async def to_code(config: ConfigType) -> None: else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) + if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False) + if data.visualizer_support: cg.add_define("USE_SENDSPIN_VISUALIZER", True) else: diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index e26e8204aaf..a5336d5e052 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -1,5 +1,7 @@ """Sendspin image platform.""" +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image @@ -21,7 +23,6 @@ from esphome.types import ConfigType from .. import ( CONF_DISPLAY_OFFSET, CONF_SENDSPIN_ID, - IMAGE_FORMAT_BMP, IMAGE_FORMAT_JPEG, IMAGE_FORMAT_PNG, IMAGE_SOURCE_ALBUM, @@ -57,9 +58,16 @@ _FORMAT_TO_SENDSPIN_ENUM = { "JPEG": IMAGE_FORMAT_JPEG, "JPG": IMAGE_FORMAT_JPEG, "PNG": IMAGE_FORMAT_PNG, - "BMP": IMAGE_FORMAT_BMP, } + +# Remove before 2027.4.0 +def _reject_bmp(value: Any) -> Any: + if isinstance(value, str) and value.upper() == "BMP": + raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.") + return value + + # The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it # would never receive artwork while still paying for two frame buffers, so it is not offered here. IMAGE_SOURCES = { @@ -121,7 +129,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), # Narrow runtime_image's format list to what the library can request, so the # accepted set and the enum map below cannot drift apart. - cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_FORMAT): cv.All( + _reject_bmp, cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True) + ), cv.Required(CONF_RESIZE): cv.dimensions, cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 558a292d5b0..8605048f331 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -1,6 +1,6 @@ #include "sendspin_image.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/core/log.h" @@ -125,7 +125,7 @@ bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const } // A decode that asked for other dimensions had the buffer taken away from it, so it painted - // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // nothing (or stopped partway). JPEG reports that as an error above; PNG carries on // regardless, so the frame is dropped here. return this->decode_sink_.decoded_into(target); } diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h index 2f6f4e8a4d9..43dd4ed66b3 100644 --- a/esphome/components/sendspin/image/sendspin_image.h +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/components/image/image.h" #include "esphome/components/runtime_image/runtime_image.h" @@ -29,7 +29,7 @@ class ArtworkDecodeSink : public runtime_image::RuntimeImage { /// @brief True when the decode ended with the given buffer still in place. /// /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions - /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// leaves the sink holding nothing. The JPEG decoder reports that as a decode error, but /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } }; diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index 59ead1bb539..ec5d5b2cbc0 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_player.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/core/application.h" #include "esphome/core/log.h" diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index ff764731898..bc07254efe4 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/components/media_player/media_player.h" #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 743d0bb62cf..31c454fb71c 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -11,7 +11,9 @@ from esphome.const import ( from esphome.types import ConfigType from .. import ( + CODEC_FLAC, CODEC_OPUS, + CODEC_PCM, CODECS, CONF_CODECS, CONF_DECODE_MEMORY, @@ -58,6 +60,11 @@ def _resolve_codecs(config: ConfigType) -> ConfigType: f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", path=[CONF_CODECS], ) + if CODEC_FLAC not in codecs and CODEC_PCM not in codecs: + raise cv.Invalid( + f"Codecs must include '{CODEC_FLAC}' or '{CODEC_PCM}'", + path=[CONF_CODECS], + ) return config diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index c3fb1fe1cbd..f1706a260bd 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_source.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/audio/audio.h" #include "esphome/core/log.h" @@ -24,7 +24,7 @@ void SendspinMediaSource::setup() { // Push cached states to player role. They may have been set before setup() ran. this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f)); this->player_role_->update_muted(this->cached_muted_); - this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_); + this->player_role_->set_output_delay_adjustable(this->static_delay_adjustable_); } void SendspinMediaSource::dump_config() { @@ -35,7 +35,7 @@ void SendspinMediaSource::dump_config() { void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) { this->static_delay_adjustable_ = adjustable; if (this->player_role_) { - this->player_role_->set_static_delay_adjustable(adjustable); + this->player_role_->set_output_delay_adjustable(adjustable); } } @@ -102,8 +102,8 @@ void SendspinMediaSource::handle_command(media_source::MediaSourceCommand comman if (!this->pending_start_) { // Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before // play_uri - ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE"); - this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE); + ESP_LOGD(TAG, "Received STOP command, leaving the Sendspin group"); + this->parent_->leave_group(); } break; } @@ -185,8 +185,6 @@ size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_ // THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) void SendspinMediaSource::on_stream_start() { - this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - if (!this->pending_start_) { // Dedup rapid on_stream_start() calls this->pending_start_ = true; @@ -211,4 +209,4 @@ void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(mute } // namespace esphome::sendspin_ -#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER +#endif // USE_ESP_IDF && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 1c5cb625bf4..f0800dbcb1c 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 15d358d50fb..0348850225e 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -1,6 +1,6 @@ #include "sendspin_hub.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/components/network/util.h" #ifdef USE_ETHERNET @@ -17,26 +17,98 @@ #include +#include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +namespace { + +// Zeroes through a volatile pointer so dead-store elimination cannot drop it. +void secure_wipe(void *data, size_t len) { + volatile auto *p = static_cast(data); + for (size_t i = 0; i < len; ++i) { + p[i] = 0; + } +} + +// Call once per key, from setup(). +ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) { + return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name)); +} + +// Loads straight into the returned buffer, which the library wipes after use. A failed load can +// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers +// this buffer: the preference backend keeps its own copy of a pending write until it syncs. +std::optional> read_blob(ESPPreferenceObject &pref, size_t size) { + std::vector out(size); + if (!pref.load(out.data(), out.size())) { + secure_wipe(out.data(), out.size()); + return std::nullopt; + } + return out; +} + +// The library always writes a key's fixed size; any other length is misuse and is not stored. +bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) { + if (len != size) { + ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size); + return false; + } + if (!pref.save(data, len)) { + ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len); + return false; + } + return true; +} + +// "rec_" to n; nullopt for any other key or a slot with no storage here. +std::optional parse_record_slot(const std::string &key) { + const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX; + const size_t prefix_len = std::strlen(prefix); + if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) { + return std::nullopt; + } + size_t slot = 0; + for (size_t i = prefix_len; i < key.size(); i++) { + const char c = key[i]; + if (c < '0' || c > '9') { + return std::nullopt; + } + slot = slot * 10 + static_cast(c - '0'); + if (slot >= SENDSPIN_RECORD_SLOTS) { + return std::nullopt; + } + } + return slot; +} + +} // namespace + #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; -static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"}; #endif void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); - // Set up persistence (preferences must be initialized before providers are added to the client) - this->last_played_server_pref_ = - global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); -#ifdef USE_SENDSPIN_PLAYER - this->static_delay_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_static_delay")); -#endif + // Set up persistence (preferences must be initialized before providers are added to the client). These key names + // are frozen; see PREFERENCE KEYS in sendspin_hub.h. + namespace keys = sendspin::persistence_keys; + this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE); + this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE); + this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE); + // Released firmware's name, so a calibrated delay survives. + this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE); + for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) { + this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE); + } + this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS); // Wire providers and client listener this->client_->set_listener(this); @@ -62,6 +134,9 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif + // Any server may play without pairing, as before rc1. + this->client_->set_unpaired_access_enabled(true); + #ifndef USE_SENDSPIN_SWITCH this->enabled_ = true; #endif @@ -83,17 +158,35 @@ void SendspinHub::loop() { #endif } +// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up +// before this hub, so it shuts down after it and the goodbyes can still go out. +void SendspinHub::on_shutdown() { + // Keeps loop() from starting the client again. + this->enabled_ = false; + if (this->is_client_running()) { + this->client_->stop(); + } +} + void SendspinHub::dump_config() { + // client_id exists only once start() has run. + const char *client_id = "(unavailable)"; + if (this->client_ != nullptr && !this->client_->client_id().empty()) { + client_id = this->client_->client_id().c_str(); + } char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " MAC address: %s\n" " Manufacturer: %s\n" " Model: %s\n" " Firmware version: %s\n" - " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), - this->firmware_version_, YESNO(this->task_stack_in_psram_)); + " Task stack in PSRAM: %s\n" + " Unpaired access: %s", + client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -154,22 +247,33 @@ void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) } // THREAD CONTEXT: Main loop (invoked from Sendspin components) -void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_client_running()) { - this->client_->update_state(state); +void SendspinHub::leave_group() { + if (this->is_client_running() && + this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) { + this->client_->leave(); } } -const char *SendspinHub::get_client_id_into_buffer(std::span buf) { - // The server matches client_id against the L2 source MAC of the device's multicast traffic. +const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { + // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the // eFuse base MAC when ethernet is the active interface. #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) { - return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + } else { + get_mac_address_pretty_into_buffer(buf); } +#else + get_mac_address_pretty_into_buffer(buf); #endif - return get_mac_address_pretty_into_buffer(buf); + // The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase. + for (char &c : buf) { + if (c >= 'A' && c <= 'F') { + c += 'a' - 'A'; + } + } + return buf.data(); } const char *SendspinHub::get_product_name_() const { @@ -180,12 +284,14 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); + config.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf); config.name = App.get_friendly_name(); config.product_name = this->get_product_name_(); config.manufacturer = this->manufacturer_; config.software_version = this->firmware_version_; config.httpd_psram_stack = this->task_stack_in_psram_; + config.protocol_task_psram_stack = this->task_stack_in_psram_; + config.max_pairing_records = SENDSPIN_RECORD_SLOTS; return config; } @@ -217,33 +323,56 @@ void SendspinHub::on_release_high_performance() { // --- SendspinNetworkProvider override --- -// THREAD CONTEXT: Main loop (polled by client_->loop()) +// THREAD CONTEXT: Main loop (polled by start() and client_->loop()) bool SendspinHub::is_network_ready() { return network::is_connected(); } // --- SendspinPersistenceProvider overrides --- +// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop()) -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -bool SendspinHub::save_last_server_hash(uint32_t hash) { - LastPlayedServerPref pref{.server_id_hash = hash}; - bool ok = this->last_played_server_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); - } else { - ESP_LOGW(TAG, "Failed to persist last played server hash"); +std::pair SendspinHub::pref_for_key_(const std::string &key) { + namespace keys = sendspin::persistence_keys; + if (auto slot = parse_record_slot(key); slot.has_value()) { + return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE}; } - return ok; + if (key == keys::RECORD_ORDER) { + return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS}; + } + if (key == keys::KEYPAIR) { + return {&this->keypair_pref_, keys::KEYPAIR_SIZE}; + } + if (key == keys::PAIRING_PSK) { + return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE}; + } + if (key == keys::LAST_PLAYED) { + return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE}; + } + if (key == keys::OUTPUT_DELAY) { + return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE}; + } + return {nullptr, 0}; } -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -std::optional SendspinHub::load_last_server_hash() { - LastPlayedServerPref pref{}; - if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); - return pref.server_id_hash; +std::optional> SendspinHub::load_blob(const std::string &key) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str()); + return std::nullopt; } - return std::nullopt; + return read_blob(*pref, size); } +bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str()); + return false; + } + return write_blob(*pref, key.c_str(), size, data, len); +} + +// Writes every component's queued preferences, so a failure may belong to another component. +bool SendspinHub::commit() { return global_preferences->sync(); } + // --- Sendspin role specific methods/overrides --- #ifdef USE_SENDSPIN_ARTWORK @@ -285,11 +414,7 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman } // THREAD CONTEXT: Main loop (invoked from the sendspin.switch action) -void SendspinHub::switch_client() { - // Clear any EXTERNAL_SOURCE state so the switch command is followed - this->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); -} +void SendspinHub::switch_client() { this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); } // THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { @@ -332,31 +457,8 @@ sendspin::PlayerRole *SendspinHub::get_player_role() { } return nullptr; } - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -bool SendspinHub::save_static_delay(uint16_t delay_ms) { - StaticDelayPref pref{.delay_ms = delay_ms}; - bool ok = this->static_delay_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms); - } else { - ESP_LOGW(TAG, "Failed to persist static delay"); - } - return ok; -} - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -std::optional SendspinHub::load_static_delay() { - StaticDelayPref pref{}; - if (this->static_delay_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms); - return pref.delay_ms; - } - return std::nullopt; -} - #endif } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index ea09dafe689..43c56feca7a 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/core/automation.h" #include "esphome/core/component.h" @@ -31,9 +31,15 @@ #include #endif +#include +#include +#include #include #include #include +#include +#include +#include namespace esphome::sendspin_ { @@ -44,22 +50,16 @@ namespace esphome::sendspin_ { /// one step later than hub so they can assume hub's setup() has already completed. namespace sendspin_priority { // AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active -// interface's MAC for client_id. +// interface's MAC address for the device info. inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI; inline constexpr float CHILD = HUB - 1.0f; } // namespace sendspin_priority -/// @brief Persistent storage structure for last played server hash. -struct LastPlayedServerPref { - uint32_t server_id_hash; -}; +// PREFERENCE KEYS: each library persistence key has its own preference, hashed from "sendspin_" + name. Renaming +// one erases it. -#ifdef USE_SENDSPIN_PLAYER -/// @brief Persistent storage structure for player static delay. -struct StaticDelayPref { - uint16_t delay_ms; -}; -#endif +/// Pairing-record slots, handed to the client as max_pairing_records. +inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; /// @brief Thin adapter over sendspin::SendspinClient. /// @@ -93,6 +93,7 @@ class SendspinHub final : public Component, float get_setup_priority() const override { return sendspin_priority::HUB; } void setup() override; void loop() override; + void on_shutdown() override; void dump_config() override; /// @brief Connects the underlying client to the given Sendspin server. @@ -113,14 +114,11 @@ class SendspinHub final : public Component, /// - `USER_REQUEST`: user explicitly requested disconnect. void disconnect_from_server(sendspin::SendspinGoodbyeReason reason); - /// @brief Updates the client's reported playback state on the server. + /// @brief Leaves the current group so another source can use the speaker. /// + /// Sends `client/leave` while the group is playing. A stopped group is left alone, so the device stays grouped. /// No-op if the hub's client is not running. Must be called from the main loop thread. - /// @param state New client state: - /// - `SYNCHRONIZED`: client is synchronized and playing from the server. - /// - `ERROR`: client encountered a playback error. - /// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source. - void update_state(sendspin::SendspinClientState state); + void leave_group(); // --- Configuration setters (called from codegen) --- @@ -219,12 +217,16 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// The preference a library key is stored in and its blob size, or {nullptr, 0} for a key with + /// no storage here. + std::pair pref_for_key_(const std::string &key); + /// @brief Returns the product name reported to the server: the configured model, or the device name. const char *get_product_name_() const; - /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. + /// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). - static const char *get_client_id_into_buffer(std::span buf); + static const char *get_mac_address_into_buffer(std::span buf); #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. @@ -242,8 +244,10 @@ class SendspinHub final : public Component, bool is_network_ready() override; // --- SendspinPersistenceProvider overrides --- - bool save_last_server_hash(uint32_t hash) override; - std::optional load_last_server_hash() override; + // The library calls commit() after writing pairing secrets, so they reach flash right away. + std::optional> load_blob(const std::string &key) override; + bool save_blob(const std::string &key, const uint8_t *data, size_t len) override; + bool commit() override; // --- Sendspin role specific methods/overrides/member variables --- @@ -291,16 +295,17 @@ class SendspinHub final : public Component, #ifdef USE_SENDSPIN_PLAYER sendspin::PlayerRoleListener *player_listener_{nullptr}; sendspin::PlayerRoleConfig player_config_{}; - - // Part of SendspinPersistenceProvider overrides - ESPPreferenceObject static_delay_pref_; - std::optional load_static_delay() override; - bool save_static_delay(uint16_t delay_ms) override; #endif // --- Core member variables --- - ESPPreferenceObject last_played_server_pref_; + // Built once in setup(): make_preference() allocates a backend that is never freed. + ESPPreferenceObject keypair_pref_; + ESPPreferenceObject pairing_psk_pref_; + ESPPreferenceObject last_played_pref_; + ESPPreferenceObject output_delay_pref_; + std::array record_slot_prefs_; + ESPPreferenceObject record_order_pref_; std::unique_ptr client_; @@ -349,4 +354,4 @@ class SendspinPollingChild : public PollingComponent, public Parented diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index 5b29fff55f8..77bb8355986 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/sensor/sensor.h" diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index 0bf029d4c77..af4d0538543 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,6 +1,6 @@ #include "sendspin_switch.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/core/log.h" @@ -23,4 +23,4 @@ void SendspinSwitch::write_state(bool state) { } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index 253d952b220..f9173c362dd 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -21,4 +21,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 554e01cf880..69dd32e7857 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -1,6 +1,6 @@ #include "sendspin_text_sensor.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) #include diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index d38f360d94d..9fe1c9381ef 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6bae823acb3..ed1202fdb96 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -101,8 +101,11 @@ dependencies: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 + # sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below) sendspin/sendspin-cpp: - version: 0.8.0 + version: 0.9.3 + rules: + - if: "$ESPHOME_ARDUINO_COMPONENT == 0" lvgl/lvgl: version: 9.5.0 fastled/FastLED: diff --git a/tests/component_tests/sendspin/test_framework.py b/tests/component_tests/sendspin/test_framework.py new file mode 100644 index 00000000000..0add0d0f0a1 --- /dev/null +++ b/tests/component_tests/sendspin/test_framework.py @@ -0,0 +1,27 @@ +"""Validation tests for the frameworks the sendspin hub builds on. + +sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 +cannot use, so the hub is ESP-IDF only. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +HUB_CONFIG = {"id": "sendspin_hub_id"} + + +def test_arduino_rejected(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_ARDUINO) + + with pytest.raises(cv.Invalid, match="esp-idf"): + CONFIG_SCHEMA(HUB_CONFIG) + + +def test_esp_idf_accepted(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + assert CONFIG_SCHEMA(HUB_CONFIG)["id"].id == "sendspin_hub_id" diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py index be3b7d6684d..dc9e4978c6b 100644 --- a/tests/component_tests/sendspin/test_image.py +++ b/tests/component_tests/sendspin/test_image.py @@ -53,6 +53,14 @@ def test_jpeg_alias_maps_to_one_enum( assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG +def test_bmp_rejected(set_core_config: SetCoreConfigCallable) -> None: + """The Sendspin protocol only allows JPEG and PNG artwork.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="BMP artwork is no longer supported"): + CONFIG_SCHEMA(_slot_config(format="BMP")) + + def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" set_core_config(PlatformFramework.ESP32_IDF) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py index 6c2f79198dd..db576b37afd 100644 --- a/tests/component_tests/sendspin/test_media_source.py +++ b/tests/component_tests/sendspin/test_media_source.py @@ -88,3 +88,13 @@ def test_opus_at_wrong_sample_rate_rejected( with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) + + +def test_opus_only_codec_list_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Every server supports FLAC or PCM, so sendspin-cpp requires one of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="must include 'flac' or 'pcm'"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"])) From eea11834f85e8f776ba1a1aa6999b1e14de00eb1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 13:19:14 -0400 Subject: [PATCH 091/435] [sendspin] Add the static pairing code, pairing window and unpaired access option (#20348) --- esphome/components/sendspin/__init__.py | 119 ++++++++++++++++-- esphome/components/sendspin/sendspin_hub.cpp | 56 ++++++++- esphome/components/sendspin/sendspin_hub.h | 50 ++++++++ .../component_tests/sendspin/test_pairing.py | 77 ++++++++++++ .../sendspin/test_unpaired_access.py | 75 +++++++++++ tests/components/sendspin/common-hub.yaml | 24 ++++ tests/components/sendspin/common-pairing.yaml | 14 +++ .../sendspin/test-pairing.esp32-idf.yaml | 2 + 8 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/sendspin/test_pairing.py create mode 100644 tests/component_tests/sendspin/test_unpaired_access.py create mode 100644 tests/components/sendspin/common-pairing.yaml create mode 100644 tests/components/sendspin/test-pairing.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 2fb8d4a17ec..fcc53dde4ba 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,6 @@ from dataclasses import dataclass, field +import logging +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,6 +27,8 @@ from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + # mdns for autodiscovery AUTO_LOAD = ["mdns"] CODEOWNERS = ["@kahrendt"] @@ -46,6 +50,31 @@ CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" CONF_CODECS = "codecs" +CONF_STATIC_PAIRING_CODE = "static_pairing_code" +CONF_UNPAIRED_ACCESS = "unpaired_access" +CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" +CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" +CONF_ON_PAIRING_FAILED = "on_pairing_failed" + +# A static pairing code is exactly 8 decimal digits. +STATIC_PAIRING_CODE_DIGITS = 8 + + +def _validate_static_pairing_code(value: Any) -> str: + # string_strict so leading zeros survive and `!secret` works. + value = cv.string_strict(value) + if len(value) != STATIC_PAIRING_CODE_DIGITS or not ( + value.isascii() and value.isdigit() + ): + raise cv.Invalid( + f"{CONF_STATIC_PAIRING_CODE} must be exactly " + f"{STATIC_PAIRING_CODE_DIGITS} decimal digits " + '(quote the value so leading zeros are preserved, e.g. "01234567")' + ) + return value + + # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -108,6 +137,26 @@ SendspinHub = sendspin_ns.class_( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN_PAIRING_WINDOW, "add_on_open_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_SUCCEEDED, + "add_on_pairing_succeeded_callback", + [(cg.std_string, "server_id")], + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_FAILED, + "add_on_pairing_failed_callback", + [(cg.std_string, "server_id"), (cg.StringRef, "reason")], + ), +) + + @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -207,6 +256,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, + cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( + _validate_static_pairing_code + ), + cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, + cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), + cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } ), cv.only_on_esp32, @@ -216,21 +277,42 @@ CONFIG_SCHEMA = cv.All( ) +def _has_pairing_method(config: ConfigType) -> bool: + """Whether the config gives a server any way to pair with the device.""" + return CONF_STATIC_PAIRING_CODE in config + + +def _final_validate(config: ConfigType) -> ConfigType: + if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): + _LOGGER.warning( + "'%s' is off but nothing lets a server pair (%s), so no server can play on this " + "device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _request_controller_role(config: ConfigType) -> ConfigType: """Request the controller role for the sendspin.switch action.""" request_controller_support() return config +# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. +SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) +) + SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( - automation.maybe_simple_id( - cv.Schema( - { - cv.GenerateID(): cv.use_id(SendspinHub), - } - ) - ), - _request_controller_role, + SENDSPIN_HUB_ACTION_SCHEMA, _request_controller_role ) @@ -241,6 +323,19 @@ automation.register_apply_action( ) +automation.register_apply_action( + "sendspin.confirm_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("confirm_pairing_window()"), +) + +automation.register_apply_action( + "sendspin.cancel_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("cancel_pairing_window()"), +) + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -265,6 +360,14 @@ async def to_code(config: ConfigType) -> None: if value: cg.add(setter(value)) + if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: + cg.add(var.set_static_pairing_code(code)) + + if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: + cg.add(var.set_default_unpaired_access(unpaired_access)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") # esp_websocket_client links esp_tls even for ws:// connections. diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 0348850225e..927e0e49ad2 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -26,6 +26,26 @@ static const char *const TAG = "sendspin.hub"; namespace { +// The reason passed to on_pairing_failed automations, in the protocol's own spelling. +StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) { + using sendspin::SendspinPairAbortReason; + switch (reason) { + case SendspinPairAbortReason::ATTEMPT_TIMEOUT: + return StringRef::from_lit("attempt_timeout"); + case SendspinPairAbortReason::CONCURRENT_ATTEMPT: + return StringRef::from_lit("concurrent_attempt"); + case SendspinPairAbortReason::METHOD_NOT_SUPPORTED: + return StringRef::from_lit("method_not_supported"); + case SendspinPairAbortReason::PAIRING_CODE_MISMATCH: + return StringRef::from_lit("pairing_code_mismatch"); + case SendspinPairAbortReason::USER_CANCELLED: + return StringRef::from_lit("user_cancelled"); + case SendspinPairAbortReason::UNKNOWN: + break; + } + return StringRef::from_lit("unknown"); +} + // Zeroes through a volatile pointer so dead-store elimination cannot drop it. void secure_wipe(void *data, size_t len) { volatile auto *p = static_cast(data); @@ -134,8 +154,7 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - // Any server may play without pairing, as before rc1. - this->client_->set_unpaired_access_enabled(true); + this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); #ifndef USE_SENDSPIN_SWITCH this->enabled_ = true; @@ -183,9 +202,11 @@ void SendspinHub::dump_config() { " Model: %s\n" " Firmware version: %s\n" " Task stack in PSRAM: %s\n" + " Static pairing code: %s\n" " Unpaired access: %s", client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->static_pairing_code_ != nullptr), YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); #ifdef USE_SENDSPIN_ARTWORK @@ -254,6 +275,20 @@ void SendspinHub::leave_group() { } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.confirm_pairing_window action) +void SendspinHub::confirm_pairing_window() { + if (this->is_client_running()) { + this->client_->confirm_pairing_window(); + } +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.cancel_pairing_window action) +void SendspinHub::cancel_pairing_window() { + if (this->is_client_running()) { + this->client_->cancel_pairing_window(); + } +} + const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the @@ -293,6 +328,11 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { config.protocol_task_psram_stack = this->task_stack_in_psram_; config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + if (this->static_pairing_code_ != nullptr) { + config.static_pairing_code_locations = {"operator"}; + config.static_pairing_code = this->static_pairing_code_; + } + return config; } @@ -321,6 +361,18 @@ void SendspinHub::on_release_high_performance() { #endif } +void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks_.call(); } + +void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } + +void SendspinHub::on_pairing_succeeded(const std::string &server_id) { + this->pairing_succeeded_callbacks_.call(server_id); +} + +void SendspinHub::on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) { + this->pairing_failed_callbacks_.call(server_id, pair_abort_reason_to_string(reason)); +} + // --- SendspinNetworkProvider override --- // THREAD CONTEXT: Main loop (polled by start() and client_->loop()) diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 43c56feca7a..7c3708c374d 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "esphome/core/version.h" #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE @@ -120,12 +121,39 @@ class SendspinHub final : public Component, /// No-op if the hub's client is not running. Must be called from the main loop thread. void leave_group(); + /// @brief Confirms a pairing attempt on the device. With no attempt waiting, it opens the pairing window for the + /// next one. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void confirm_pairing_window(); + + /// @brief Closes an open pairing window, so a waiting pairing attempt is not confirmed. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void cancel_pairing_window(); + // --- Configuration setters (called from codegen) --- template void add_group_update_callback(F &&callback) { this->group_update_callbacks_.add(std::forward(callback)); } + template void add_on_open_pairing_window_callback(F &&callback) { + this->open_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_close_pairing_window_callback(F &&callback) { + this->close_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_succeeded_callback(F &&callback) { + this->pairing_succeeded_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_failed_callback(F &&callback) { + this->pairing_failed_callbacks_.add(std::forward(callback)); + } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or @@ -136,6 +164,9 @@ class SendspinHub final : public Component, /// called this once. Must be called from the main loop thread. void set_enabled(bool enabled); + /// Turns unpaired (Sentinel) access on or off from setup(); see SendspinClient::set_unpaired_access_enabled(). + void set_default_unpaired_access(bool enabled) { this->default_unpaired_access_ = enabled; } + /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -152,6 +183,9 @@ class SendspinHub final : public Component, void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } #endif + /// The static pairing code, used on every boot. + void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -240,6 +274,14 @@ class SendspinHub final : public Component, void on_release_high_performance() override; + void on_open_pairing_window() override; + + void on_close_pairing_window() override; + + void on_pairing_succeeded(const std::string &server_id) override; + + void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; + // --- SendspinNetworkProvider override --- bool is_network_ready() override; @@ -312,6 +354,14 @@ class SendspinHub final : public Component, // Callback fan-out to child components CallbackManager group_update_callbacks_{}; + // Lazy: each pairing callback is fed by an optional YAML surface. + LazyCallbackManager open_pairing_window_callbacks_{}; + LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager pairing_succeeded_callbacks_{}; + LazyCallbackManager pairing_failed_callbacks_{}; + + const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE bool mdns_advertised_{false}; // Last state requested from mdns diff --git a/tests/component_tests/sendspin/test_pairing.py b/tests/component_tests/sendspin/test_pairing.py new file mode 100644 index 00000000000..dcfce8cece8 --- /dev/null +++ b/tests/component_tests/sendspin/test_pairing.py @@ -0,0 +1,77 @@ +"""Validation tests for the sendspin hub's pairing options. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_STATIC_PAIRING_CODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _hub_config(**overrides: Any) -> ConfigType: + """Build a minimal valid hub config, allowing field overrides.""" + config: ConfigType = {"id": "sendspin_hub_id"} + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_hub_config()) + + assert CONF_STATIC_PAIRING_CODE not in config + + +@pytest.mark.parametrize("code", ["01234567", "00000000", "99999999"]) +def test_static_pairing_code_accepted( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Eight decimal digits, leading zeros included, pass through unchanged.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert ( + CONFIG_SCHEMA(_hub_config(static_pairing_code=code))[CONF_STATIC_PAIRING_CODE] + == code + ) + + +@pytest.mark.parametrize( + "code", + [ + "0123456", # too short + "012345678", # too long + "0123456a", # not all decimal digits + "0123 567", # whitespace is not a digit + "01234567", # full-width digits are not ASCII + "", + ], +) +def test_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Anything that is not exactly eight decimal digits is refused here, since the + library would otherwise refuse to start the client.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="exactly 8 decimal digits"): + CONFIG_SCHEMA(_hub_config(static_pairing_code=code)) + + +def test_unquoted_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """An unquoted YAML code arrives as an int, having already lost its leading + zeros, so it is refused rather than silently pairing with the wrong value.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567)) diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py new file mode 100644 index 00000000000..a66de471ab5 --- /dev/null +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -0,0 +1,75 @@ +"""Validation tests for the hub's unpaired access policy.""" + +import logging + +import pytest + +from esphome.components.sendspin import ( + CONF_UNPAIRED_ACCESS, + CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, +) +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "sendspin_hub_id" +NO_PAIRING_WARNING = "nothing lets a server pair" + + +def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None: + """Codegen leaves the hub's built-in default of on in place when the key is not set.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + +@pytest.mark.parametrize("value", [True, False]) +def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: value}) + + assert config[CONF_UNPAIRED_ACCESS] is value + + +def test_off_without_pairing_method_warns( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """With unpaired access off and no way to pair, no server could ever play.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +@pytest.mark.parametrize("method", ["static_pairing_code"]) +def test_off_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + method: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False} + if method == "static_pairing_code": + hub_config["static_pairing_code"] = "01234567" + config = HUB_CONFIG_SCHEMA(hub_config) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_on_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index bd6747ee07b..147227b2f1d 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -7,3 +7,27 @@ sendspin: manufacturer: Test Manufacturer model: Test Model firmware_version: 1.2.3 + on_open_pairing_window: + then: + - logger.log: "Pairing window requested" + - sendspin.confirm_pairing_window: + # Calling cancel_pairing_window here only compiles the action; a real config cancels from a user input. + on_close_pairing_window: + then: + - logger.log: "Pairing window prompt dismissed" + - sendspin.cancel_pairing_window: + on_pairing_succeeded: + then: + - logger.log: + format: "Paired with server %s" + args: ["server_id.c_str()"] + on_pairing_failed: + then: + - logger.log: + format: "Pairing with server %s failed: %s" + args: ["server_id.c_str()", "reason.c_str()"] + - if: + condition: + lambda: 'return reason == "user_cancelled";' + then: + - logger.log: "Pairing was cancelled" diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml new file mode 100644 index 00000000000..cd9cd82bec8 --- /dev/null +++ b/tests/components/sendspin/common-pairing.yaml @@ -0,0 +1,14 @@ +# The hub on its own, with no other sendspin platforms, to build the pairing options. +# +# Deliberately does not include common.yaml: that file pulls in the shared hub package, and +# the point here is a separate hub configuration. sendspin depends on network, so the interface +# has to be declared locally instead. +wifi: + ap: + +# Also the static-pairing-code build. +sendspin: + id: sendspin_hub_id + static_pairing_code: "01234567" + # Covers the hub's unpaired access option. + unpaired_access: false diff --git a/tests/components/sendspin/test-pairing.esp32-idf.yaml b/tests/components/sendspin/test-pairing.esp32-idf.yaml new file mode 100644 index 00000000000..5862e841df3 --- /dev/null +++ b/tests/components/sendspin/test-pairing.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-pairing.yaml From baf1682635dafd5a33372f6a414c40f6fcf94e44 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 13:36:12 -0400 Subject: [PATCH 092/435] [sendspin] Add the dynamic pairing code (#20349) --- esphome/components/sendspin/__init__.py | 46 +++++++++++++- esphome/components/sendspin/sendspin_hub.cpp | 33 ++++++++-- esphome/components/sendspin/sendspin_hub.h | 21 +++++++ .../sendspin/text_sensor/__init__.py | 43 ++++++++++--- .../text_sensor/sendspin_text_sensor.cpp | 15 ++++- .../text_sensor/sendspin_text_sensor.h | 13 +++- .../component_tests/sendspin/test_pairing.py | 20 ++++++- .../sendspin/test_text_sensor.py | 60 +++++++++++++++++++ .../sendspin/test_unpaired_access.py | 5 +- tests/components/sendspin/common-hub.yaml | 11 ++++ .../sendspin/common-text_sensor-pairing.yaml | 17 ++++++ .../sendspin/common-text_sensor.yaml | 4 ++ .../test-text_sensor-pairing.esp32-idf.yaml | 2 + 13 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/sendspin/test_text_sensor.py create mode 100644 tests/components/sendspin/common-text_sensor-pairing.yaml create mode 100644 tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index fcc53dde4ba..5e104fe58aa 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -54,6 +54,8 @@ CONF_STATIC_PAIRING_CODE = "static_pairing_code" CONF_UNPAIRED_ACCESS = "unpaired_access" CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_DISPLAY_PAIRING_CODE = "on_display_pairing_code" +CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code" CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" CONF_ON_PAIRING_FAILED = "on_pairing_failed" @@ -144,6 +146,14 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" ), + automation.CallbackAutomation( + CONF_ON_DISPLAY_PAIRING_CODE, + "add_on_display_pairing_code_callback", + [(cg.std_string, "code")], + ), + automation.CallbackAutomation( + CONF_ON_CLEAR_PAIRING_CODE, "add_on_clear_pairing_code_callback" + ), automation.CallbackAutomation( CONF_ON_PAIRING_SUCCEEDED, "add_on_pairing_succeeded_callback", @@ -164,6 +174,7 @@ class SendspinConfiguration: metadata_support: bool = False player_support: bool = False visualizer_support: bool = False + pairing_code_display_support: bool = False artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -200,6 +211,11 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def request_pairing_code_display_support() -> None: + """Mark that the device can emit a dynamic pairing code (e.g. a pairing_code text sensor).""" + _get_data().pairing_code_display_support = True + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -266,6 +282,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( {} ), + cv.Optional(CONF_ON_DISPLAY_PAIRING_CODE): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}), cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } @@ -277,16 +297,33 @@ CONFIG_SCHEMA = cv.All( ) +def _offers_dynamic_pairing_code(config: ConfigType) -> bool: + """Whether the device can show a dynamic pairing code, so dynamic_pairing_code is advertised.""" + return bool( + config.get(CONF_ON_DISPLAY_PAIRING_CODE) + or _get_data().pairing_code_display_support + ) + + def _has_pairing_method(config: ConfigType) -> bool: """Whether the config gives a server any way to pair with the device.""" - return CONF_STATIC_PAIRING_CODE in config + return CONF_STATIC_PAIRING_CODE in config or _offers_dynamic_pairing_code(config) def _final_validate(config: ConfigType) -> ConfigType: + dynamic_code = _offers_dynamic_pairing_code(config) + # The protocol allows only one pairing code method. + if dynamic_code and CONF_STATIC_PAIRING_CODE in config: + raise cv.Invalid( + f"'{CONF_STATIC_PAIRING_CODE}' cannot be used with a dynamic pairing code " + f"({CONF_ON_DISPLAY_PAIRING_CODE} or a pairing_code text sensor), since only " + "one pairing code method can be offered", + path=[CONF_STATIC_PAIRING_CODE], + ) if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): _LOGGER.warning( - "'%s' is off but nothing lets a server pair (%s), so no server can play on this " - "device", + "'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no " + "server can play on this device", CONF_UNPAIRED_ACCESS, CONF_STATIC_PAIRING_CODE, ) @@ -366,6 +403,9 @@ async def to_code(config: ConfigType) -> None: if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: cg.add(var.set_default_unpaired_access(unpaired_access)) + if _offers_dynamic_pairing_code(config): + cg.add(var.set_pairing_code_display_supported(True)) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) # sendspin-cpp library diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 927e0e49ad2..3f8fcd7af44 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -202,12 +202,12 @@ void SendspinHub::dump_config() { " Model: %s\n" " Firmware version: %s\n" " Task stack in PSRAM: %s\n" - " Static pairing code: %s\n" - " Unpaired access: %s", + " Unpaired access: %s\n" + " Pairing code method: %s", client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), this->firmware_version_, YESNO(this->task_stack_in_psram_), - YESNO(this->static_pairing_code_ != nullptr), - YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); + YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()), + this->pairing_code_method_()); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -311,6 +311,17 @@ const char *SendspinHub::get_mac_address_into_buffer(std::spanpairing_code_display_supported_) { + return LOG_STR_LITERAL("dynamic"); + } + if (this->static_pairing_code_ != nullptr) { + return LOG_STR_LITERAL("static"); + } + return LOG_STR_LITERAL("none"); +} + const char *SendspinHub::get_product_name_() const { return this->model_ != nullptr ? this->model_ : App.get_name().c_str(); } @@ -328,6 +339,13 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { config.protocol_task_psram_stack = this->task_stack_in_psram_; config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + // The dynamic code needs a channel and a format. Only digits, since automations get the bare string and could not + // tell a QR code token apart. + if (this->pairing_code_display_supported_) { + config.pairing_code_out_channels = {sendspin::SendspinPairingCodeChannel::DISPLAY}; + config.pairing_code_formats = {sendspin::SendspinPairingCodeFormat::DIGITS}; + } + if (this->static_pairing_code_ != nullptr) { config.static_pairing_code_locations = {"operator"}; config.static_pairing_code = this->static_pairing_code_; @@ -365,6 +383,13 @@ void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } +// Only digits are offered, and the library refuses an activation in any other format. +void SendspinHub::on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat /*format*/) { + this->display_pairing_code_callbacks_.call(code); +} + +void SendspinHub::on_clear_pairing_code() { this->clear_pairing_code_callbacks_.call(); } + void SendspinHub::on_pairing_succeeded(const std::string &server_id) { this->pairing_succeeded_callbacks_.call(server_id); } diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 7c3708c374d..d33269048e8 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -146,6 +146,14 @@ class SendspinHub final : public Component, this->close_pairing_window_callbacks_.add(std::forward(callback)); } + template void add_on_display_pairing_code_callback(F &&callback) { + this->display_pairing_code_callbacks_.add(std::forward(callback)); + } + + template void add_on_clear_pairing_code_callback(F &&callback) { + this->clear_pairing_code_callbacks_.add(std::forward(callback)); + } + template void add_on_pairing_succeeded_callback(F &&callback) { this->pairing_succeeded_callbacks_.add(std::forward(callback)); } @@ -186,6 +194,10 @@ class SendspinHub final : public Component, /// The static pairing code, used on every boot. void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + /// Set when on_display_pairing_code or a pairing_code text sensor is configured, so the device offers the dynamic + /// pairing code. + void set_pairing_code_display_supported(bool supported) { this->pairing_code_display_supported_ = supported; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -258,6 +270,8 @@ class SendspinHub final : public Component, /// @brief Returns the product name reported to the server: the configured model, or the device name. const char *get_product_name_() const; + const char *pairing_code_method_() const; + /// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_mac_address_into_buffer(std::span buf); @@ -278,6 +292,10 @@ class SendspinHub final : public Component, void on_close_pairing_window() override; + void on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat format) override; + + void on_clear_pairing_code() override; + void on_pairing_succeeded(const std::string &server_id) override; void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; @@ -357,10 +375,13 @@ class SendspinHub final : public Component, // Lazy: each pairing callback is fed by an optional YAML surface. LazyCallbackManager open_pairing_window_callbacks_{}; LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager display_pairing_code_callbacks_{}; + LazyCallbackManager clear_pairing_code_callbacks_{}; LazyCallbackManager pairing_succeeded_callbacks_{}; LazyCallbackManager pairing_failed_callbacks_{}; const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + bool pairing_code_display_supported_{false}; bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index 87f6c9b9362..24aa2d57346 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -4,16 +4,29 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TYPE from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + SendspinHub, + request_metadata_support, + request_pairing_code_display_support, + sendspin_ns, +) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] +CONF_PAIRING_CODE = "pairing_code" + SendspinTextSensor = sendspin_ns.class_( "SendspinTextSensor", text_sensor.TextSensor, cg.Component, ) +SendspinPairingCodeTextSensor = sendspin_ns.class_( + "SendspinPairingCodeTextSensor", + text_sensor.TextSensor, + cg.Component, +) SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) SENDSPIN_TEXT_METADATA_TYPES = { @@ -26,18 +39,29 @@ SENDSPIN_TEXT_METADATA_TYPES = { def _request_roles(config: ConfigType) -> ConfigType: """Request the necessary Sendspin roles for the text sensor.""" - request_metadata_support() + if config[CONF_TYPE] == CONF_PAIRING_CODE: + # A display surface for the dynamic code, so advertise dynamic_pairing_code. + request_pairing_code_display_support() + else: + request_metadata_support() return config +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) +_METADATA_SCHEMA = text_sensor.text_sensor_schema(SendspinTextSensor).extend( + _HUB_ID_SCHEMA +) + CONFIG_SCHEMA = cv.All( - text_sensor.text_sensor_schema().extend( + cv.typed_schema( { - cv.GenerateID(): cv.declare_id(SendspinTextSensor), - cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), - } + **dict.fromkeys(SENDSPIN_TEXT_METADATA_TYPES, _METADATA_SCHEMA), + CONF_PAIRING_CODE: text_sensor.text_sensor_schema( + SendspinPairingCodeTextSensor + ).extend(_HUB_ID_SCHEMA), + }, + key=CONF_TYPE, ), cv.only_on_esp32, _request_roles, @@ -50,4 +74,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_parented(var, config[CONF_SENDSPIN_ID]) await text_sensor.register_text_sensor(var, config) - cg.add(var.set_metadata_type(config[CONF_TYPE])) + if ( + metadata_type := SENDSPIN_TEXT_METADATA_TYPES.get(config[CONF_TYPE]) + ) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 69dd32e7857..f975fb3e479 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -1,8 +1,10 @@ #include "sendspin_text_sensor.h" -#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) +#ifdef USE_SENDSPIN_METADATA #include +#endif #include @@ -10,6 +12,7 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.text_sensor"; +#ifdef USE_SENDSPIN_METADATA void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } // A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to @@ -50,6 +53,16 @@ void SendspinTextSensor::publish_if_changed_(const char *value) { this->publish_state(value); } } +#endif // USE_SENDSPIN_METADATA + +void SendspinPairingCodeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Pairing Code", this); } + +// THREAD CONTEXT: Main loop. The registered callbacks also fire on the main loop (SendspinHub dispatches them from +// its loop()). +void SendspinPairingCodeTextSensor::setup() { + this->parent_->add_on_display_pairing_code_callback([this](const std::string &code) { this->publish_state(code); }); + this->parent_->add_on_clear_pairing_code_callback([this]() { this->publish_state(""); }); +} } // namespace esphome::sendspin_ diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index 9fe1c9381ef..cc25ab93c7c 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -2,15 +2,19 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" +#ifdef USE_SENDSPIN_METADATA #include +#endif namespace esphome::sendspin_ { +#ifdef USE_SENDSPIN_METADATA + enum class SendspinTextMetadataTypes { TITLE, ARTIST, @@ -31,6 +35,13 @@ class SendspinTextSensor final : public SendspinChild, public text_sensor::TextS SendspinTextMetadataTypes metadata_type_; }; +#endif // USE_SENDSPIN_METADATA + +class SendspinPairingCodeTextSensor final : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; +}; } // namespace esphome::sendspin_ #endif diff --git a/tests/component_tests/sendspin/test_pairing.py b/tests/component_tests/sendspin/test_pairing.py index dcfce8cece8..79612ccff5b 100644 --- a/tests/component_tests/sendspin/test_pairing.py +++ b/tests/component_tests/sendspin/test_pairing.py @@ -9,7 +9,12 @@ from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.sendspin import CONF_STATIC_PAIRING_CODE, CONFIG_SCHEMA +from esphome.components.sendspin import ( + CONF_STATIC_PAIRING_CODE, + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + request_pairing_code_display_support, +) from esphome.const import PlatformFramework from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -75,3 +80,16 @@ def test_unquoted_static_pairing_code_rejected( with pytest.raises(cv.Invalid): CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567)) + + +def test_static_code_with_dynamic_code_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Only one pairing code method can be offered, so a static code next to the dynamic + code would be silently ignored.""" + set_core_config(PlatformFramework.ESP32_IDF) + request_pairing_code_display_support() + config = CONFIG_SCHEMA(_hub_config(static_pairing_code="01234567")) + + with pytest.raises(cv.Invalid, match="cannot be used with a dynamic pairing code"): + FINAL_VALIDATE_SCHEMA(config) diff --git a/tests/component_tests/sendspin/test_text_sensor.py b/tests/component_tests/sendspin/test_text_sensor.py new file mode 100644 index 00000000000..6dc073a59ce --- /dev/null +++ b/tests/component_tests/sendspin/test_text_sensor.py @@ -0,0 +1,60 @@ +"""Validation tests for the sendspin text sensor platform. + +These cover behavior a compile test cannot observe: which roles a sensor type requests. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import _get_data +from esphome.components.sendspin.text_sensor import ( + CONF_PAIRING_CODE, + CONFIG_SCHEMA, + SENDSPIN_TEXT_METADATA_TYPES, +) +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _sensor_config(**overrides: Any) -> ConfigType: + """Build a minimal valid text sensor config, allowing field overrides.""" + config: ConfigType = {"name": "Sendspin Text", "type": "title"} + config.update(overrides) + return config + + +@pytest.mark.parametrize("sensor_type", sorted(SENDSPIN_TEXT_METADATA_TYPES)) +def test_metadata_types_request_metadata_role( + set_core_config: SetCoreConfigCallable, sensor_type: str +) -> None: + """Metadata sensors need the metadata role, and say nothing about pairing codes.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_sensor_config(type=sensor_type)) + + assert _get_data().metadata_support is True + assert _get_data().pairing_code_display_support is False + + +def test_pairing_code_requests_code_display( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pairing_code sensor is a way to show the dynamic code, so it alone makes the + hub offer it. It needs no role, so a pairing-only config builds without metadata.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_sensor_config(type=CONF_PAIRING_CODE)) + + assert _get_data().pairing_code_display_support is True + assert _get_data().metadata_support is False + + +def test_unknown_type_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A misspelled type must fail rather than fall through to a default.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_sensor_config(type="pairing_secret")) diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py index a66de471ab5..80bf5a24178 100644 --- a/tests/component_tests/sendspin/test_unpaired_access.py +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -8,6 +8,7 @@ from esphome.components.sendspin import ( CONF_UNPAIRED_ACCESS, CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, + request_pairing_code_display_support, ) from esphome.const import PlatformFramework from tests.component_tests.types import SetCoreConfigCallable @@ -45,7 +46,7 @@ def test_off_without_pairing_method_warns( assert NO_PAIRING_WARNING in caplog.text -@pytest.mark.parametrize("method", ["static_pairing_code"]) +@pytest.mark.parametrize("method", ["static_pairing_code", "pairing_code"]) def test_off_with_pairing_method_does_not_warn( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture, @@ -55,6 +56,8 @@ def test_off_with_pairing_method_does_not_warn( hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False} if method == "static_pairing_code": hub_config["static_pairing_code"] = "01234567" + else: + request_pairing_code_display_support() config = HUB_CONFIG_SCHEMA(hub_config) with caplog.at_level(logging.WARNING): diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index 147227b2f1d..d89d16856dd 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -7,6 +7,9 @@ sendspin: manufacturer: Test Manufacturer model: Test Model firmware_version: 1.2.3 + # No static_pairing_code here: the on_display_pairing_code automation below makes + # this a dynamic-pairing-code device, and validation rejects both together. + # common-pairing.yaml covers the static-code build. on_open_pairing_window: then: - logger.log: "Pairing window requested" @@ -16,6 +19,14 @@ sendspin: then: - logger.log: "Pairing window prompt dismissed" - sendspin.cancel_pairing_window: + on_display_pairing_code: + then: + - logger.log: + format: "Pairing code: %s" + args: ["code.c_str()"] + on_clear_pairing_code: + then: + - logger.log: "Pairing code cleared" on_pairing_succeeded: then: - logger.log: diff --git a/tests/components/sendspin/common-text_sensor-pairing.yaml b/tests/components/sendspin/common-text_sensor-pairing.yaml new file mode 100644 index 00000000000..c2d92eb453d --- /dev/null +++ b/tests/components/sendspin/common-text_sensor-pairing.yaml @@ -0,0 +1,17 @@ +# The pairing code text sensor on its own, with no metadata text sensor to pull in the +# metadata role. This is the build where USE_SENDSPIN_METADATA is undefined while the text +# sensor platform is still compiled, which is guarded separately in sendspin_text_sensor.{h,cpp}. +# +# Deliberately does not include common.yaml: that file pulls in the shared hub package, and +# the point here is the narrower configuration. sendspin depends on network, so the interface +# has to be declared locally instead. +wifi: + ap: + +sendspin: + id: sendspin_hub_id + +text_sensor: + - platform: sendspin + name: "Pairing Code" + type: pairing_code diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index 23111e8d37c..087bbdb6b0e 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -14,3 +14,7 @@ text_sensor: - platform: sendspin name: "Album Artist" type: album_artist + - platform: sendspin + name: "Pairing Code" + type: pairing_code + entity_category: diagnostic diff --git a/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml new file mode 100644 index 00000000000..2336f877407 --- /dev/null +++ b/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-text_sensor-pairing.yaml From ab66b2094459c840488e554d22b34216a24b1abf Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 14:13:31 -0400 Subject: [PATCH 093/435] [sendspin] Add an unpaired access switch (#20350) --- esphome/components/sendspin/__init__.py | 21 +++- esphome/components/sendspin/sendspin_hub.cpp | 24 ++-- esphome/components/sendspin/sendspin_hub.h | 13 +- .../components/sendspin/switch/__init__.py | 88 ++++++++++++-- .../sendspin/switch/sendspin_switch.cpp | 33 ++++- .../sendspin/switch/sendspin_switch.h | 18 ++- .../sendspin/test_unpaired_access.py | 115 +++++++++++++++++- tests/components/sendspin/common-pairing.yaml | 2 +- tests/components/sendspin/common-switch.yaml | 3 + 9 files changed, 274 insertions(+), 43 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 5e104fe58aa..ad9b22e3670 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -5,7 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32, mdns, network, psram, socket, wifi -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -175,6 +175,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False pairing_code_display_support: bool = False + switch_types: set[str] = field(default_factory=set) artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -216,6 +217,11 @@ def request_pairing_code_display_support() -> None: _get_data().pairing_code_display_support = True +def request_switch(switch_type: str) -> None: + """Mark that a sendspin switch of this type drives the matching hub setting.""" + _get_data().switch_types.add(switch_type) + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -275,6 +281,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( _validate_static_pairing_code ), + # No default: the unpaired access switch rejects this key when it is set. cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( {} @@ -322,8 +329,8 @@ def _final_validate(config: ConfigType) -> ConfigType: ) if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): _LOGGER.warning( - "'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no " - "server can play on this device", + "'%s' is off but there is no pairing method (%s or a dynamic pairing code), so no " + "new server can pair with this device", CONF_UNPAIRED_ACCESS, CONF_STATIC_PAIRING_CODE, ) @@ -400,8 +407,12 @@ async def to_code(config: ConfigType) -> None: if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: cg.add(var.set_static_pairing_code(code)) - if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: - cg.add(var.set_default_unpaired_access(unpaired_access)) + # The client starts once both are set: here, or by the switch that drives the setting. + switch_types = _get_data().switch_types + if CONF_ENABLED not in switch_types: + cg.add(var.set_enabled(True)) + if CONF_UNPAIRED_ACCESS not in switch_types: + cg.add(var.set_unpaired_access_enabled(config.get(CONF_UNPAIRED_ACCESS, True))) if _offers_dynamic_pairing_code(config): cg.add(var.set_pairing_code_display_supported(True)) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 3f8fcd7af44..cfbd91dfcf8 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -154,16 +154,15 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); - -#ifndef USE_SENDSPIN_SWITCH - this->enabled_ = true; -#endif + // Set before setup() by codegen; an unpaired access switch sets it later, from its own setup(). + if (this->unpaired_access_.has_value()) { + this->client_->set_unpaired_access_enabled(*this->unpaired_access_); + } } void SendspinHub::loop() { - if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && - !this->status_has_error()) { + if (this->enabled_.has_value() && this->unpaired_access_.has_value() && + this->enabled_.value() != this->client_->is_started() && !this->status_has_error()) { if (!this->enabled_.value()) { this->client_->stop(); } else if (!this->client_->start()) { @@ -222,7 +221,7 @@ void SendspinHub::dump_config() { #endif } -// THREAD CONTEXT: Main loop (invoked from Sendspin components) +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_enabled(bool enabled) { if (this->status_has_error()) { ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", @@ -232,6 +231,15 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) +void SendspinHub::set_unpaired_access_enabled(bool enabled) { + this->unpaired_access_ = enabled; + // Before setup() there is no client yet; setup() applies the stored value. + if (this->client_ != nullptr) { + this->client_->set_unpaired_access_enabled(enabled); + } +} + #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE // THREAD CONTEXT: Main loop void SendspinHub::update_mdns_service_() { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index d33269048e8..33356f21f67 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -168,12 +168,13 @@ class SendspinHub final : public Component, /// stop. /// /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks - /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has - /// called this once. Must be called from the main loop thread. + /// fire from inside that call. The client stays stopped until this has been called once, by codegen or by the + /// enabled switch. Must be called from the main loop thread. void set_enabled(bool enabled); - /// Turns unpaired (Sentinel) access on or off from setup(); see SendspinClient::set_unpaired_access_enabled(). - void set_default_unpaired_access(bool enabled) { this->default_unpaired_access_ = enabled; } + /// Turns unpaired (Sentinel) access on or off. The client waits for this call, by codegen or by the unpaired + /// access switch, before its first start. May be called before setup(). Main loop only. + void set_unpaired_access_enabled(bool enabled); /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -382,14 +383,14 @@ class SendspinHub final : public Component, const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured bool pairing_code_display_supported_{false}; - bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE bool mdns_advertised_{false}; // Last state requested from mdns #endif - // Requested client state, applied from loop(). Empty until the switch restores its state. + // Requested client state, applied from loop(). The client does not start until both are set. std::optional enabled_; + std::optional unpaired_access_; // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 1f896640bfc..ae640e70a40 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,35 +1,101 @@ +import logging + import esphome.codegen as cg from esphome.components import switch from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_PLATFORM, CONF_SWITCH, CONF_TYPE, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + CONF_STATIC_PAIRING_CODE, + CONF_UNPAIRED_ACCESS, + DOMAIN, + SendspinHub, + _has_pairing_method, + request_switch, + sendspin_ns, +) + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] -SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) +SendspinEnabledSwitch = sendspin_ns.class_( + "SendspinEnabledSwitch", switch.Switch, cg.Component +) +SendspinUnpairedAccessSwitch = sendspin_ns.class_( + "SendspinUnpairedAccessSwitch", switch.Switch, cg.Component +) + + +def _switch_schema(class_: cg.MockObjClass) -> cv.Schema: + return ( + switch.switch_schema( + class_, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +def _request_switch(config: ConfigType) -> ConfigType: + """Tell the hub to wait for this switch before the client's first start.""" + request_switch(config[CONF_TYPE]) + return config + CONFIG_SCHEMA = cv.All( cv.typed_schema( { - CONF_ENABLED: switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + CONF_ENABLED: _switch_schema(SendspinEnabledSwitch), + CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch), }, key=CONF_TYPE, ), cv.only_on_esp32, + _request_switch, ) +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + switch_type = config[CONF_TYPE] + same_type = [ + conf + for conf in full_config.get(CONF_SWITCH, []) + if conf.get(CONF_PLATFORM) == DOMAIN and conf.get(CONF_TYPE) == switch_type + ] + # Two switches of one type would each drive the same hub setting. + if len(same_type) > 1: + raise cv.Invalid(f"Only one sendspin '{switch_type}' switch is allowed") + if switch_type != CONF_UNPAIRED_ACCESS: + return config + hub_config = full_config.get(DOMAIN, {}) + if CONF_UNPAIRED_ACCESS in hub_config: + raise cv.Invalid( + f"'{DOMAIN}: {CONF_UNPAIRED_ACCESS}' cannot be set together with an " + f"{CONF_UNPAIRED_ACCESS} switch; set the switch's restore_mode instead" + ) + if not _has_pairing_method(hub_config): + _LOGGER.warning( + "The %s switch has no pairing method (%s or a dynamic pairing code), so " + "while it is off no new server can pair with this device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index af4d0538543..70703ea3c5c 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,26 +1,49 @@ #include "sendspin_switch.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/core/log.h" +#include "esphome/core/preferences.h" namespace esphome::sendspin_ { static const char *const TAG = "sendspin.switch"; -void SendspinSwitch::setup() { +// --- SendspinEnabledSwitch --- + +void SendspinEnabledSwitch::setup() { // The hub waits for this request, so a restore mode without a state still has to answer. this->control(this->get_initial_state_with_restore_mode().value_or(true)); } -void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } +void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); } // THREAD CONTEXT: Main loop -void SendspinSwitch::write_state(bool state) { +void SendspinEnabledSwitch::write_state(bool state) { this->parent_->set_enabled(state); this->publish_state(state); } +// --- SendspinUnpairedAccessSwitch --- + +void SendspinUnpairedAccessSwitch::setup() { + // The hub holds the client's first start for this, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinUnpairedAccessSwitch::write_state(bool state) { + const bool turning_off = !state && this->has_state() && this->state; + this->parent_->set_unpaired_access_enabled(state); + this->publish_state(state); + // Write the change to flash now: losing it in a power cut would reopen unpaired access. + if (turning_off) { + global_preferences->sync(); + } +} + } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index f9173c362dd..4b04c95857b 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -10,7 +10,19 @@ namespace esphome::sendspin_ { /// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). -class SendspinSwitch final : public switch_::Switch, public SendspinChild { +class SendspinEnabledSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +/// @brief Switch that turns unpaired (Sentinel) access on and off through the hub (see +/// SendspinHub::set_unpaired_access_enabled()). The library does not persist the setting, so the switch's restore +/// mode does. +class SendspinUnpairedAccessSwitch final : public switch_::Switch, public SendspinChild { public: void setup() override; void dump_config() override; @@ -21,4 +33,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py index 80bf5a24178..8660ba9f7cf 100644 --- a/tests/component_tests/sendspin/test_unpaired_access.py +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -1,24 +1,42 @@ -"""Validation tests for the hub's unpaired access policy.""" +"""Validation tests for the unpaired access policy. + +The hub's `unpaired_access` key and an `unpaired_access` switch both set the +policy, so the switch platform rejects a config that has both. +""" import logging import pytest +from esphome import config_validation as cv from esphome.components.sendspin import ( CONF_UNPAIRED_ACCESS, CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + DOMAIN, FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, + _get_data, request_pairing_code_display_support, ) +from esphome.components.sendspin.switch import ( + CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as SWITCH_FINAL_VALIDATE_SCHEMA, +) from esphome.const import PlatformFramework +from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "sendspin_hub_id" -NO_PAIRING_WARNING = "nothing lets a server pair" +NO_PAIRING_WARNING = "no new server can pair with this device" + + +def _switch_config(switch_type: str) -> ConfigType: + return SWITCH_CONFIG_SCHEMA( + {"name": "Sendspin Switch", "type": switch_type, "sendspin_id": HUB_ID} + ) def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None: - """Codegen leaves the hub's built-in default of on in place when the key is not set.""" + """With no default in the schema, the switch can tell whether the key was set.""" set_core_config(PlatformFramework.ESP32_IDF) assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) @@ -36,7 +54,7 @@ def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) - def test_off_without_pairing_method_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture ) -> None: - """With unpaired access off and no way to pair, no server could ever play.""" + """With unpaired access off and no way to pair, no new server could pair.""" set_core_config(PlatformFramework.ESP32_IDF) config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) @@ -76,3 +94,92 @@ def test_on_does_not_warn( HUB_FINAL_VALIDATE_SCHEMA(config) assert NO_PAIRING_WARNING not in caplog.text + + +def test_switch_with_hub_key_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Both would set the policy, and the hub's value would be silently ignored.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with pytest.raises(cv.Invalid, match="set the switch's restore_mode instead"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +def test_switch_without_hub_key_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +def test_switch_without_pairing_method_warns( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """Turning the switch off would leave no way for a new server to pair.""" + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +def test_switch_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, "static_pairing_code": "01234567"}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable) -> None: + """Only the unpaired access switch competes with the hub key.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config("enabled") + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_duplicate_switch_type_rejected( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """Two switches of one type would fight over the same hub setting.""" + entry = {"platform": DOMAIN, "type": switch_type} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID}, "switch": [entry, dict(entry)]}, + ) + config = _switch_config(switch_type) + + with pytest.raises(cv.Invalid, match="Only one sendspin"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_switch_requests_its_type( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """The hub skips the codegen value for a setting a switch drives, so the client + waits for that switch to restore before its first start.""" + set_core_config(PlatformFramework.ESP32_IDF) + + _switch_config(switch_type) + + assert _get_data().switch_types == {switch_type} diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml index cd9cd82bec8..b201a5b850d 100644 --- a/tests/components/sendspin/common-pairing.yaml +++ b/tests/components/sendspin/common-pairing.yaml @@ -10,5 +10,5 @@ wifi: sendspin: id: sendspin_hub_id static_pairing_code: "01234567" - # Covers the hub's unpaired access option. + # Covers the hub's unpaired access option, which is only valid without an unpaired access switch. unpaired_access: false diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml index 01e2f0b02b1..dbf260286b2 100644 --- a/tests/components/sendspin/common-switch.yaml +++ b/tests/components/sendspin/common-switch.yaml @@ -5,3 +5,6 @@ switch: - platform: sendspin name: "Sendspin Enabled" type: enabled + - platform: sendspin + name: "Sendspin Unpaired Access" + type: unpaired_access From d5d11e10774fad5d87b7e1668f89cb15a93a588a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:48:19 +0000 Subject: [PATCH 094/435] Bump aioesphomeapi from 46.8.0 to 46.9.0 (#20358) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6e1dfa1ba51..f429d84d524 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.8.0 +aioesphomeapi==46.9.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 7de2bcb58426fc39cad5f42de4fdce97720bce13 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 15:12:20 -0400 Subject: [PATCH 095/435] [api][sendspin] Add a request for the Sendspin pairing token (#20355) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 39 ++++++++++++++ esphome/components/api/api_connection.cpp | 42 ++++++++++++++- esphome/components/api/api_connection.h | 7 +++ esphome/components/api/api_pb2.cpp | 35 ++++++++++++ esphome/components/api/api_pb2.h | 54 ++++++++++++++++++- esphome/components/api/api_pb2_dump.cpp | 39 ++++++++++++++ esphome/components/api/api_pb2_service.cpp | 9 ++++ esphome/components/api/api_pb2_service.h | 4 ++ esphome/components/sendspin/__init__.py | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 12 +++++ esphome/components/sendspin/sendspin_hub.h | 21 ++++++++ tests/components/sendspin/common-pairing.yaml | 5 ++ .../components/api/test_api_proto.py | 35 ++++++++++++ 13 files changed, 301 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0d2ac36937c..76b82810a4b 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -81,6 +81,8 @@ service APIConnection { rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} + + rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {} } @@ -398,6 +400,11 @@ message WizardCapabilities { bool configured = 1; } +message SendspinCapabilities { + // Bitmask of the Sendspin features this device supports + uint32 feature_flags = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -408,6 +415,7 @@ message DeviceCapabilitiesResponse { repeated SerialProxyInfo serial_proxies = 4 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; + SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"]; } // ==================== DEVICE WIZARD ==================== @@ -3069,3 +3077,34 @@ message BluetoothSetConnectionParamsResponse { uint64 address = 1; int32 error = 2; } + +// ==================== SENDSPIN ==================== +enum SendspinPairingTokenStatus { + // The Sendspin client has not started yet, so the token does not exist yet. Ask again later. + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0; + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1; + // The device has no API encryption key, so it will not send the token. Ask again once a key is set. + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2; + // Sendspin is turned off and has not started since boot, so the token does not exist yet. + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3; + // The Sendspin client failed to start, and will not start until the device reboots. + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4; +} + +// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device +message SendspinPairingTokenRequest { + option (id) = 159; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SENDSPIN"; + // Empty +} + +message SendspinPairingTokenResponse { + option (id) = 160; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SENDSPIN"; + + SendspinPairingTokenStatus status = 1; + // The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK. + string token = 2 [(max_data_length) = 127]; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7d56b1ce583..73ce4bd8569 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -48,6 +48,9 @@ #ifdef USE_ZWAVE_PROXY #include "esphome/components/zwave_proxy/zwave_proxy.h" #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +#include "esphome/components/sendspin/sendspin_hub.h" +#endif #ifdef USE_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif @@ -1402,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { } #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +bool APIConnection::send_sendspin_pairing_token_response_() { + SendspinPairingTokenResponse resp; +#ifdef USE_API_NOISE + // has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions + // never reach hello, and activating a key closes every session opened before it. + if (!this->parent_->get_noise_ctx().has_psk()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; + return this->send_message(resp); + } + auto token = sendspin_::global_sendspin_hub->get_pairing_token(); + if (token.has_value()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK; + resp.token = StringRef(*token); + } else if (sendspin_::global_sendspin_hub->status_has_error()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED; + } else if (sendspin_::global_sendspin_hub->is_disabled()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED; + } else { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY; + } +#else + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; +#endif + return this->send_message(resp); +} + +void APIConnection::on_sendspin_pairing_token_request() { + if (!this->send_sendspin_pairing_token_response_()) { + this->on_fatal_error(); + } +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE, @@ -1872,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 18; + resp.api_version_minor = 19; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -2088,6 +2125,9 @@ bool APIConnection::send_device_capabilities_response_() { #endif #ifdef USE_API_WIZARD resp.wizard.configured = true; +#endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index b7f0bda0ed7..9f784185b83 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + void on_sendspin_pairing_token_request(); +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg); @@ -432,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_VOICE_ASSISTANT bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + bool send_sendspin_pairing_token_response_(); +#endif #ifdef USE_CAMERA void try_send_camera_image_(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 646a8f4211e..b59663aeb51 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -306,6 +306,19 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_SENDSPIN +uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + return pos; +} +uint32_t SendspinCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.feature_flags); + return size; +} +#endif uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); #ifdef USE_BLUETOOTH_PROXY @@ -324,6 +337,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res #endif #ifdef USE_API_WIZARD pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard); +#endif +#ifdef USE_SENDSPIN + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin); #endif return pos; } @@ -346,6 +362,9 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #endif #ifdef USE_API_WIZARD size += ProtoSize::calc_message(1, msg.wizard.calculate_size()); +#endif +#ifdef USE_SENDSPIN + size += ProtoSize::calc_message(1, msg.sendspin.calculate_size()); #endif return size; } @@ -4229,6 +4248,22 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_SENDSPIN +uint8_t *SendspinPairingTokenResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.token); + return pos; +} +uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += msg.status ? 2 : 0; + size += !msg.token.empty() ? 2 + msg.token.size() : 0; + return size; +} +#endif #ifndef HAS_PROTO_MESSAGE_DUMP static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 9ad114b2ca1..421c3fccb38 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -383,6 +383,15 @@ enum SerialProxyIdentityFlag : uint32_t { SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1, SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2, }; +#ifdef USE_SENDSPIN +enum SendspinPairingTokenStatus : uint32_t { + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0, + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1, + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2, + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3, + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4, +}; +#endif } // namespace enums @@ -737,10 +746,27 @@ class WizardCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_SENDSPIN +class SendspinCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 119; + static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -758,6 +784,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_API_WIZARD WizardCapabilities wizard{}; +#endif +#ifdef USE_SENDSPIN + SendspinCapabilities sendspin{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -4138,5 +4167,28 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { protected: }; #endif +#ifdef USE_SENDSPIN +class SendspinPairingTokenResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 160; + static constexpr uint8_t ESTIMATED_SIZE = 11; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("sendspin_pairing_token_response"); } +#endif + enums::SendspinPairingTokenStatus status{}; + StringRef token{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 196a4249cc6..3c6f5302b55 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -928,6 +928,25 @@ template<> const char *proto_enum_to_string(enum return ESPHOME_PSTR("UNKNOWN"); } } +#ifdef USE_SENDSPIN +template<> +const char *proto_enum_to_string(enums::SendspinPairingTokenStatus value) { + switch (value) { + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} +#endif const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest")); @@ -1096,6 +1115,13 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_SENDSPIN +const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1124,6 +1150,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": "); this->wizard.dump_to(out); out.append("\n"); +#endif +#ifdef USE_SENDSPIN + out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": "); + this->sendspin.dump_to(out); + out.append("\n"); #endif return out.c_str(); } @@ -2944,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const return out.c_str(); } #endif +#ifdef USE_SENDSPIN +const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse")); + dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); + dump_field(out, ESPHOME_PSTR("token"), this->token); + return out.c_str(); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 1ef70f1fd4e..35248a89441 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -751,6 +751,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_wizard_input_set_request(msg); break; } +#endif +#ifdef USE_SENDSPIN + case 159 /* SendspinPairingTokenRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request")); +#endif + this->on_sendspin_pairing_token_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 13fc03e03e7..7f33e5668c7 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -252,6 +252,10 @@ class APIServerConnectionBase { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif + +#ifdef USE_SENDSPIN + void on_sendspin_pairing_token_request(){}; +#endif }; } // namespace esphome::api diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index ad9b22e3670..b587a3e0928 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -424,7 +424,7 @@ async def to_code(config: ConfigType) -> None: # esp_websocket_client links esp_tls even for ws:// connections. esp32.request_tls() - cg.add_define("USE_SENDSPIN", True) # for MDNS + cg.add_define("USE_SENDSPIN", True) # for MDNS and the native API # Service starts disabled and the hub enables it; always advertised where unsupported if mdns.request_service_enable_disable(): diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index cfbd91dfcf8..df680202061 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -113,6 +113,10 @@ static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"}; #endif +SendspinHub *global_sendspin_hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +SendspinHub::SendspinHub() { global_sendspin_hub = this; } + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -231,6 +235,14 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop +std::optional SendspinHub::get_pairing_token() const { + if (this->client_ == nullptr) { + return std::nullopt; + } + return this->client_->pairing_token(); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 33356f21f67..cd8d0d0afaf 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -62,6 +62,11 @@ inline constexpr float CHILD = HUB - 1.0f; /// Pairing-record slots, handed to the client as max_pairing_records. inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; +/// Feature flags reported to API clients in DeviceCapabilitiesResponse. +enum SendspinFeature : uint32_t { + SENDSPIN_FEATURE_PAIRING_TOKEN = 1 << 0, +}; + /// @brief Thin adapter over sendspin::SendspinClient. /// /// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for @@ -91,6 +96,8 @@ class SendspinHub final : public Component, public sendspin::SendspinNetworkProvider, public sendspin::SendspinPersistenceProvider { public: + SendspinHub(); + float get_setup_priority() const override { return sendspin_priority::HUB; } void setup() override; void loop() override; @@ -179,6 +186,18 @@ class SendspinHub final : public Component, /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + /// @brief Returns whether the client has been turned off with set_enabled(). + bool is_disabled() const { return this->enabled_.has_value() && !this->enabled_.value(); } + + /// @brief Returns the SendspinFeature flags reported to API clients. + uint32_t get_feature_flags() const { return SENDSPIN_FEATURE_PAIRING_TOKEN; } + + /// @brief Returns the pairing token a Sendspin server uses to pair with this device. + /// + /// The token is a long-lived secret, so this component never logs it. Returns std::nullopt until the client has + /// started once; it stays available after the client stops. Main loop only. + std::optional get_pairing_token() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the @@ -424,6 +443,8 @@ class SendspinPollingChild : public PollingComponent, public Parented None: assert "needs_authentication" not in body +def test_sendspin_pairing_token_messages_keep_their_wire_ids() -> None: + """Message ids are part of the wire protocol and must not change.""" + for message, expected in ( + ("SendspinPairingTokenRequest", 159), + ("SendspinPairingTokenResponse", 160), + ): + body = _extract_proto_message(PROTO_TEXT, message) + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, f"{message} is missing `option (id)`" + assert int(match.group(1)) == expected, ( + f"{message} has id {match.group(1)}, expected {expected}" + ) + + +def test_sendspin_pairing_token_response_fields_keep_their_wire_numbers() -> None: + """Clients decode the response purely by field number.""" + body = _extract_proto_message(PROTO_TEXT, "SendspinPairingTokenResponse") + for field_name, number in (("status", 1), ("token", 2)): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), ( + f"{field_name} in SendspinPairingTokenResponse is no longer field " + f"number {number}" + ) + + +def test_sendspin_pairing_token_rpc_requires_authentication() -> None: + """The pairing token is a long-lived secret, so the rpc must not set + `needs_authentication` and inherits the default of true. + """ + body = _extract_rpc_body(PROTO_TEXT, "sendspin_pairing_token") + assert "needs_authentication" not in body + + # ==================== Group C: advertised API version ==================== From b82dfd7e64bfdd27ddaa7de3195ee405703195ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 11:18:21 -1000 Subject: [PATCH 096/435] [mdns] Bump espressif/mdns to 1.14.0 (#20383) --- esphome/components/mdns/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 0fb24fdf1df..760952e269d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -235,7 +235,7 @@ async def to_code(config: ConfigType) -> None: ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.12.0") + add_idf_component(name="espressif/mdns", ref="1.14.0") # ESPHome only advertises; the browse APIs are unused add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) # The mdns console CLI is never used by ESPHome diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index ed1202fdb96..3d9f1431f67 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -24,7 +24,7 @@ dependencies: espressif/esp32-camera: version: 2.1.7 espressif/mdns: - version: 1.12.0 + version: 1.14.0 espressif/esp_wifi_remote: version: 1.6.5 rules: From ba1af627fd28631c0661827f245213977ed8b537 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 13:14:00 -1000 Subject: [PATCH 097/435] [ci] Pin the shared I2C bus in test configs that left it implicit (#20412) --- tests/components/ags10/common.yaml | 1 + tests/components/bmi270/common.yaml | 1 + tests/components/ds248x/common.yaml | 4 ++++ tests/components/lsm6ds/common.yaml | 1 + tests/components/ltr501/common.yaml | 1 + tests/components/ltr_als_ps/common.yaml | 1 + 6 files changed, 9 insertions(+) diff --git a/tests/components/ags10/common.yaml b/tests/components/ags10/common.yaml index 8009c562955..8441729a8fa 100644 --- a/tests/components/ags10/common.yaml +++ b/tests/components/ags10/common.yaml @@ -1,6 +1,7 @@ sensor: - platform: ags10 id: ags10_1 + i2c_id: i2c_bus tvoc: name: AGS10 TVOC update_interval: 60s diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 0f9b70631ce..41f30fd5c61 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -61,6 +61,7 @@ sensor: motion: - platform: bmi270 id: bmi270_motion + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/ds248x/common.yaml b/tests/components/ds248x/common.yaml index 53ae56f20a1..4429f5b7729 100644 --- a/tests/components/ds248x/common.yaml +++ b/tests/components/ds248x/common.yaml @@ -5,10 +5,12 @@ # - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection ds248x: - id: ds2482_100 + i2c_id: i2c_bus address: 0x18 type: ds2482-100 active_pullup: true - id: ds2482_101 + i2c_id: i2c_bus address: 0x19 type: ds2482-101 active_pullup: true @@ -18,10 +20,12 @@ ds248x: bus_sleep: true hub_sleep: true - id: ds2482_800 + i2c_id: i2c_bus address: 0x1a type: ds2482-800 active_pullup: true - id: ds2484_hub + i2c_id: i2c_bus address: 0x1b type: ds2484 active_pullup: true diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml index aeacd314481..40bfbfc2a11 100644 --- a/tests/components/lsm6ds/common.yaml +++ b/tests/components/lsm6ds/common.yaml @@ -61,6 +61,7 @@ sensor: motion: - platform: lsm6ds id: lsm6ds_motion + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/ltr501/common.yaml b/tests/components/ltr501/common.yaml index 77c6f137397..406450f8c53 100644 --- a/tests/components/ltr501/common.yaml +++ b/tests/components/ltr501/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: ltr501 + i2c_id: i2c_bus address: 0x23 type: ALS_PS gain: 1X diff --git a/tests/components/ltr_als_ps/common.yaml b/tests/components/ltr_als_ps/common.yaml index edad4b2e4f7..6c555eeb740 100644 --- a/tests/components/ltr_als_ps/common.yaml +++ b/tests/components/ltr_als_ps/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: ltr_als_ps + i2c_id: i2c_bus address: 0x23 gain: 1x integration_time: 100ms From f1f1a7715143742fe9e04d0811653de9d809d928 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 13:48:03 -1000 Subject: [PATCH 098/435] [ci] Deal clang-tidy files across five shards (#20411) --- .github/workflows/ci.yml | 18 ++++++++++++------ script/clang-tidy | 38 +++++++++++++++++++++++++++++++++----- script/helpers.py | 2 +- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eb96af59b6..6ed14524149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -893,14 +893,20 @@ jobs: matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 1/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 2/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 3/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 3 + name: Run script/clang-tidy for ESP32 IDF 3/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 3 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 4/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 4 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 5/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 5 steps: - name: Check out code from GitHub diff --git a/script/clang-tidy b/script/clang-tidy index ad6c99d637b..f97e9a482eb 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import heapq import os from pathlib import Path import queue @@ -11,6 +12,7 @@ import subprocess import sys import tempfile import threading +import time from typing import Any import click @@ -278,9 +280,12 @@ def clang_options(idedata, environment): pids = set() -def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): +def run_tidy( + executable, args, options, tmpdir, path_queue, lock, failed_files, timings +): while True: path = path_queue.get() + started = time.monotonic() invocation = [executable] if tmpdir is not None: @@ -319,8 +324,9 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): check=False, close_fds=False, ) - if proc.returncode != 0: - with lock: + with lock: + timings.append((time.monotonic() - started, path)) + if proc.returncode != 0: print_error_for_file(path, proc.stdout) failed_files.append(path) path_queue.task_done() @@ -333,8 +339,27 @@ def progress_bar_show(value): def split_list(a, n): - k, m = divmod(len(a), n) - return [a[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] + """Deal the sorted files out to n splits in turn. + + Contiguous slices put whole directories on one split; expensive + neighbours such as esphome/core then land together and that split takes + several times longer than the others. Dealing spreads them out. + """ + return [a[i::n] for i in range(n)] + + +SLOWEST_FILES_SHOWN = 15 + + +def print_slowest_files(timings): + """Print the files clang-tidy spent the longest on, so the splits can be + rebalanced from CI logs.""" + if not timings: + return + total = sum(seconds for seconds, _ in timings) + print(f"\nclang-tidy spent {total:.0f} s on {len(timings)} files; slowest:") + for seconds, path in heapq.nlargest(SLOWEST_FILES_SHOWN, timings): + print(f" {seconds:6.1f} s {path}") def main(): @@ -458,6 +483,7 @@ def main(): tmpdir = tempfile.mkdtemp() failed_files = [] + timings = [] try: executable = get_binary("clang-tidy", 22) task_queue = queue.Queue(args.jobs) @@ -473,6 +499,7 @@ def main(): task_queue, lock, failed_files, + timings, ), ) t.daemon = True @@ -487,6 +514,7 @@ def main(): # Wait for all threads to be done. task_queue.join() + print_slowest_files(timings) except FileNotFoundError: return 1 diff --git a/script/helpers.py b/script/helpers.py index fb2c964bdd9..11605fcef0e 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1616,7 +1616,7 @@ def lpt_partition( Heaviest item first into the lightest group. Ties keep input order, so pass pre-sorted items for deterministic output. script/clang-tidy's - split_list is the unweighted contiguous sibling. + split_list is the unweighted sibling that deals files out in turn. """ groups: list[list[str]] = [[] for _ in range(count)] group_weights = [0.0] * count From f6746b4977a60dcf4ec616bc9b8cc9fa80e91921 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 14:05:18 -1000 Subject: [PATCH 099/435] [api] Fix C++ unit test link failure when api is only a dependency (#20418) --- tests/components/api/__init__.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index 4a66287c33f..bd84c8d9bb4 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -1,6 +1,12 @@ +from pathlib import Path + import esphome.codegen as cg +from esphome.const import CONF_ESPHOME, CONF_INCLUDES +from esphome.core import CORE from tests.testing_helpers import ComponentManifestOverride +TESTS_DIR = Path(__file__).parent + def override_manifest(manifest: ComponentManifestOverride) -> None: # USE_API compiles every api source, so emit what they need. No socket @@ -10,16 +16,18 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_API_PLAINTEXT") # Linked wizard inputs only exist next to homeassistant entities, which need this cg.add_define("USE_API_HOMEASSISTANT_STATES") - # test_wizard.cpp supplies the tables that codegen emits for a real build - for define in ( - "USE_API_WIZARD", - "USE_API_WIZARD_INPUTS", - "USE_API_WIZARD_LINKED_INPUTS", - "USE_API_WIZARD_STANDALONE_INPUTS", - ): - cg.add_define(define) - cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + # test_wizard.cpp supplies the tables that codegen emits for a real build; + # it is only compiled when api's own tests are, not when api is a dependency + if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): + for define in ( + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + ): + cg.add_define(define) + cg.add_define("API_WIZARD_DATA_SIZE", 200) + cg.add_define("API_WIZARD_INPUT_COUNT", 2) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") From c6244fa4b95f0b9ae13784bd25f56de24fe48d0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 14:25:11 -1000 Subject: [PATCH 100/435] [esp32] Record the PC for cache error and interrupt watchdog crashes (#20415) --- esphome/components/esp32/crash_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b72a2777c7a..d5afaf750fb 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -531,6 +531,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; + // IDF leaves addr unset for SoC-level panics (cache error, interrupt WDT) + if (info->pseudo_excause) + s_raw_crash_data.pc = xt_frame->pc; if (!g_panic_abort) { // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots // never wrote them and abort() traps describe only the synthetic trap. From a6488220277202de4a42a9cf37cfa9f072726552 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:45:36 +1300 Subject: [PATCH 101/435] Bump version to 2026.10.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ec43afe83a5..455a83a0218 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.0b1 +PROJECT_NUMBER = 2026.10.0b2 # 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 f0c339416a9..52712669472 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.0b1" +__version__ = "2026.10.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From d9795399ad9edf43674a3b92e0756d188ff52cb2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:50:29 +1300 Subject: [PATCH 102/435] [core] Use component DOMAIN constants instead of name literals (#20419) --- esphome/components/api/__init__.py | 3 +- esphome/components/api/wizard.py | 3 +- esphome/components/captive_portal/__init__.py | 3 +- esphome/components/ch423/__init__.py | 2 +- esphome/components/dlms_meter/__init__.py | 15 +++++--- esphome/components/dsmr/__init__.py | 3 +- esphome/components/emontx/__init__.py | 8 ++-- esphome/components/es8156/audio_dac.py | 3 +- esphome/components/esp32_ble/__init__.py | 12 +++--- .../components/esp32_ble_server/__init__.py | 4 +- esphome/components/espectre/__init__.py | 5 ++- esphome/components/esphome/ota/__init__.py | 15 ++++++-- esphome/components/espnow/__init__.py | 6 ++- esphome/components/ethernet/__init__.py | 5 +-- esphome/components/haier/climate.py | 10 ++--- esphome/components/i2c/__init__.py | 3 +- esphome/components/i2s_audio/__init__.py | 14 +++---- esphome/components/improv_serial/__init__.py | 12 ++---- esphome/components/ir_rf_base/__init__.py | 4 +- esphome/components/ld6002b/button/__init__.py | 3 +- esphome/components/ld6002b/number/__init__.py | 7 ++-- esphome/components/logger/__init__.py | 3 +- esphome/components/logger/select/__init__.py | 5 ++- esphome/components/lvgl/encoders.py | 3 +- esphome/components/lvgl/lv_validation.py | 22 +++++------ esphome/components/lvgl/schemas.py | 4 +- esphome/components/lvgl/touchscreens.py | 10 +++-- esphome/components/lvgl/widgets/meter.py | 4 +- esphome/components/mdns/__init__.py | 3 +- esphome/components/network/__init__.py | 2 +- esphome/components/openthread/__init__.py | 4 +- esphome/components/packages/__init__.py | 5 ++- esphome/components/rp2040_ble/__init__.py | 5 +-- esphome/components/safe_mode/__init__.py | 5 +-- esphome/components/script/__init__.py | 3 +- esphome/components/sendspin/__init__.py | 4 +- .../components/sendspin/switch/__init__.py | 5 ++- esphome/components/sntp/time.py | 11 +++--- esphome/components/spi/__init__.py | 3 +- esphome/components/substitutions/__init__.py | 4 +- esphome/components/tinyusb/__init__.py | 3 +- esphome/components/uart_tcp/__init__.py | 3 +- esphome/components/uptime/sensor/__init__.py | 4 +- esphome/components/usb_uart/__init__.py | 2 +- esphome/components/web_server/__init__.py | 6 +-- esphome/components/web_server/ota/__init__.py | 17 ++++++--- esphome/components/wifi/__init__.py | 3 +- esphome/components/zephyr_pwm/output.py | 4 +- esphome/components/zigbee/__init__.py | 34 ++++++++--------- esphome/components/zwave_proxy/__init__.py | 3 +- .../rp2040_ble/test_connection_slots.py | 2 +- tests/component_tests/sntp/test_init.py | 37 ++++++++++--------- 52 files changed, 198 insertions(+), 165 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5e68e200312..f538c0b5e50 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -8,6 +8,7 @@ from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_DESCRIPTION, CONF_HOST from esphome.components.logger import request_log_listener +from esphome.components.network import DOMAIN as NETWORK_DOMAIN # ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external # components and downstream consumers that import them from api @@ -514,7 +515,7 @@ def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: or host.version != 6 ): return config - network_conf = fv.full_config.get().get("network") or {} + network_conf = fv.full_config.get().get(NETWORK_DOMAIN) or {} if not network_conf.get(CONF_ENABLE_IPV6): raise cv.Invalid( "outgoing_connection host is an IPv6 address but IPv6 is not " diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index befed39972c..3ab80a063ee 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -11,6 +11,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_DESCRIPTION +from esphome.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_CLASS, @@ -265,7 +266,7 @@ def _validate_wizard_input(conf: ConfigType) -> ConfigType: fv.full_config.get(), conf[CONF_ENTITY] ) if ( - declaration.get(CONF_PLATFORM) != "homeassistant" + declaration.get(CONF_PLATFORM) != HOMEASSISTANT_DOMAIN or domain not in WIZARD_INPUT_DOMAINS ): raise cv.Invalid( diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index fd4daab6dfd..3bdea03bf52 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -3,6 +3,7 @@ import logging import esphome.codegen as cg from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -64,7 +65,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() - wifi_conf = full_config.get("wifi") + wifi_conf = full_config.get(WIFI_DOMAIN) if wifi_conf is None: # This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 45d8423beb6..3997f9581f0 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -60,7 +60,7 @@ def pin_mode_check(pin_config: ConfigType, _: ConfigType) -> None: is_open_drain = pin_config[CONF_MODE][CONF_OPEN_DRAIN] # Track pin modes per CH423 instance in CORE.data - ch423_modes = CORE.data.setdefault(CONF_CH423, {}) + ch423_modes = CORE.data.setdefault(DOMAIN, {}) if ch423_id not in ch423_modes: ch423_modes[ch423_id] = {"gpio_output": None, "gpo_open_drain": None} diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 09423c7d8c5..f23b2de6c21 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -4,6 +4,9 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart +from esphome.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from esphome.components.sensor import DOMAIN as SENSOR_DOMAIN +from esphome.components.text_sensor import DOMAIN as TEXT_SENSOR_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -211,9 +214,9 @@ async def to_code(config: ConfigType) -> None: hub_id = config[CONF_ID].id sensor_count = 0 - for sens_conf in CORE.config.get("sensor", []): + for sens_conf in CORE.config.get(SENSOR_DOMAIN, []): if ( - sens_conf.get("platform") == "dlms_meter" + sens_conf.get("platform") == DOMAIN and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id ): if CONF_OBIS_CODE in sens_conf: @@ -224,9 +227,9 @@ async def to_code(config: ConfigType) -> None: sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) text_sensor_count = 0 - for sens_conf in CORE.config.get("text_sensor", []): + for sens_conf in CORE.config.get(TEXT_SENSOR_DOMAIN, []): if ( - sens_conf.get("platform") == "dlms_meter" + sens_conf.get("platform") == DOMAIN and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id ): if CONF_OBIS_CODE in sens_conf: @@ -237,9 +240,9 @@ async def to_code(config: ConfigType) -> None: text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) binary_sensor_count = 0 - for sens_conf in CORE.config.get("binary_sensor", []): + for sens_conf in CORE.config.get(BINARY_SENSOR_DOMAIN, []): if ( - sens_conf.get("platform") == "dlms_meter" + sens_conf.get("platform") == DOMAIN and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id ): binary_sensor_count += 1 diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 176192b241c..07dbf558310 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -3,6 +3,7 @@ import logging from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.uart import DOMAIN as UART_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -92,7 +93,7 @@ async def to_code(config: ConfigType) -> None: def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() - for uart_conf in full_config["uart"]: + for uart_conf in full_config[UART_DOMAIN]: if uart_conf[CONF_ID] == config[CONF_UART_ID]: rx_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] if rx_buffer_size < 1500: diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 7eae375cd6f..0262f8681f1 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -3,6 +3,8 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.sensor import DOMAIN as SENSOR_DOMAIN +from esphome.components.uart import DOMAIN as UART_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_COMMAND, @@ -63,13 +65,13 @@ def final_validate(config: ConfigType) -> None: hub_id = str(config[CONF_ID]) sensor_count = sum( 1 - for s in full_config.get("sensor", []) - if s.get("platform") == "emontx" and str(s.get(CONF_EMONTX_ID)) == hub_id + for s in full_config.get(SENSOR_DOMAIN, []) + if s.get("platform") == DOMAIN and str(s.get(CONF_EMONTX_ID)) == hub_id ) _get_data().sensor_counts[hub_id] = sensor_count # Ensure UART RX buffer size is large enough to handle data bursts from firmware - for uart_conf in full_config["uart"]: + for uart_conf in full_config[UART_DOMAIN]: if uart_conf[CONF_ID] == config[CONF_UART_ID]: current_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] if current_buffer_size < MINIMUM_RX_BUFFER_SIZE: diff --git a/esphome/components/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index 305aa921255..ebba75e70dd 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac +from esphome.components.speaker import DOMAIN as SPEAKER_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_AUDIO_DAC, CONF_BITS_PER_SAMPLE, CONF_ID import esphome.final_validate as fv @@ -27,7 +28,7 @@ def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() # Check all speaker configurations for ones that reference this es8156 - speaker_configs = full_config.get("speaker", []) + speaker_configs = full_config.get(SPEAKER_DOMAIN, []) for speaker_config in speaker_configs: audio_dac_id = speaker_config.get(CONF_AUDIO_DAC) if ( diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index ef95243bc11..48f2e3c9460 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -22,6 +22,8 @@ from esphome.components.esp32 import ( request_bluetooth, ) from esphome.components.esp32.const import VARIANT_ESP32C2 +from esphome.components.esp32_hosted import DOMAIN as ESP32_HOSTED_DOMAIN +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -277,7 +279,7 @@ DEFAULT_MAX_CONNECTIONS = 3 IDF_MAX_CONNECTIONS = 9 # Connection slot tracking keys -KEY_ESP32_BLE = "esp32_ble" +KEY_ESP32_BLE = DOMAIN KEY_USED_CONNECTION_SLOTS = "used_connection_slots" # Export for use by other components (bluetooth_proxy, etc.) @@ -361,7 +363,7 @@ CONFIG_SCHEMA = cv.Schema( cv.positive_int, cv.Range(min=1, max=IDF_MAX_CONNECTIONS) ), cv.Optional(CONF_USE_PSRAM): cv.All( - cv.only_on_esp32, cv.requires_component("psram"), cv.boolean + cv.only_on_esp32, cv.requires_component(PSRAM_DOMAIN), cv.boolean ), } ).extend(cv.COMPONENT_SCHEMA) @@ -404,7 +406,7 @@ def consume_connection_slots( """ def _consume_connection_slots(config: MutableMapping) -> MutableMapping: - data: dict[str, Any] = CORE.data.setdefault(KEY_ESP32_BLE, {}) + data: dict[str, Any] = CORE.data.setdefault(DOMAIN, {}) slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) slots.extend([consumer] * value) return config @@ -418,7 +420,7 @@ def validate_connection_slots(max_connections: int) -> None: if CORE.testing_mode: return - ble_data = CORE.data.get(KEY_ESP32_BLE, {}) + ble_data = CORE.data.get(DOMAIN, {}) used_slots = ble_data.get(KEY_USED_CONNECTION_SLOTS, []) num_used = len(used_slots) @@ -473,7 +475,7 @@ def final_validation(config: ConfigType) -> None: validate_connection_slots(max_connections) # Check if hosted bluetooth is being used - if "esp32_hosted" in full_config: + if ESP32_HOSTED_DOMAIN in full_config: from esphome.components.esp32_hosted import uses_esp_hosted_3x add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 9355d588596..75a099a3c49 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import esp32_ble from esphome.components.const import CONF_DESCRIPTION, CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth -from esphome.components.esp32_ble import BTLoggers, bt_uuid +from esphome.components.esp32_ble import DOMAIN as ESP32_BLE_DOMAIN, BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED from esphome.const import ( @@ -311,7 +311,7 @@ def final_validate_config(config) -> None: max_clients = config[CONF_MAX_CLIENTS] if max_clients > 1: full_config = fv.full_config.get() - ble_config = full_config.get("esp32_ble", {}) + ble_config = full_config.get(ESP32_BLE_DOMAIN, {}) max_connections = ble_config.get( "max_connections", esp32_ble.DEFAULT_MAX_CONNECTIONS ) diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py index a46d6507fde..4f10db996c9 100644 --- a/esphome/components/espectre/__init__.py +++ b/esphome/components/espectre/__init__.py @@ -10,8 +10,9 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WIFI +from esphome.const import CONF_ID from esphome.core import CORE from esphome.types import ConfigType @@ -162,7 +163,7 @@ async def to_code(config: ConfigType) -> None: if (off_hits := config.get(CONF_MOTION_OFF_HITS)) is not None: cg.add(var.set_motion_off_hits(off_hits)) if esp32.get_esp32_variant() == VARIANT_ESP32C5: - band = CORE.config[CONF_WIFI].get(wifi.CONF_BAND_MODE, "AUTO") + band = CORE.config[WIFI_DOMAIN].get(wifi.CONF_BAND_MODE, "AUTO") cg.add( var.set_wifi_band_policy( { diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 0cb2657ea1f..dc4fc5b47ed 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -6,7 +6,12 @@ from esphome.components.noise import ( new_psk_progmem, static_encryption_key, ) -from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.components.ota import ( + BASE_OTA_SCHEMA, + DOMAIN as OTA_DOMAIN, + OTAComponent, + ota_to_code, +) from esphome.config_helpers import filter_source_files_from_defines, merge_config import esphome.config_validation as cv from esphome.const import ( @@ -31,6 +36,8 @@ from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv from esphome.types import ConfigType +from .. import DOMAIN + CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" _LOGGER = logging.getLogger(__name__) @@ -56,12 +63,12 @@ ESPHomeOTAComponent = esphome.class_("ESPHomeOTAComponent", OTAComponent) def ota_esphome_final_validate(config: ConfigType) -> None: full_conf = fv.full_config.get() - full_ota_conf = full_conf[CONF_OTA] + full_ota_conf = full_conf[OTA_DOMAIN] new_ota_conf = [] merged_ota_esphome_configs_by_port = {} ports_with_merged_configs = [] for ota_conf in full_ota_conf: - if ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME: + if ota_conf.get(CONF_PLATFORM) == DOMAIN: if ( conf_port := ota_conf.get(CONF_PORT) ) not in merged_ota_esphome_configs_by_port: @@ -182,7 +189,7 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_WEB_SERVER, ) - full_conf[CONF_OTA] = new_ota_conf + full_conf[OTA_DOMAIN] = new_ota_conf fv.full_config.set(full_conf) if len(ports_with_merged_configs) > 0: diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index b69bc4e347f..aef8fd17b56 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -4,7 +4,9 @@ from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant +from esphome.components.esp32_hosted import DOMAIN as ESP32_HOSTED_DOMAIN from esphome.components.udp import CONF_ON_RECEIVE +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -141,7 +143,7 @@ def _validate_variant(config: ConfigType) -> ConfigType: return config if variant != VARIANT_ESP32P4: raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)") - if "esp32_hosted" not in fv.full_config.get(): + if ESP32_HOSTED_DOMAIN not in fv.full_config.get(): raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component") return config @@ -177,7 +179,7 @@ async def to_code(config: ConfigType) -> None: include_builtin_idf_component("esp_wifi") - if CONF_WIFI in CORE.config: + if WIFI_DOMAIN in CORE.config: # Track the Wi-Fi channel via connect events instead of polling every loop wifi.request_wifi_connect_state_listener() if wifi_channel := config.get(CONF_CHANNEL): diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 9e12ca3e6e9..bc26db99565 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -68,7 +68,6 @@ LOGGER = logging.getLogger(__name__) ETHERNET_IP_STATE_LISTENERS_KEY = "ethernet_ip_state_listeners" # Key for tracking configured ethernet type ETHERNET_TYPE_KEY = "ethernet_type" -KEY_ETHERNET = "ethernet" def request_ethernet_ip_state_listener() -> None: @@ -673,7 +672,7 @@ async def to_code(config: ConfigType) -> None: # enable_on_boot defaults to true in C++ - only set if false if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) - CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] + CORE.data.setdefault(DOMAIN, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: cg.add_define("USE_ETHERNET_MANUAL_IP") @@ -914,7 +913,7 @@ _define_filter = filter_source_files_from_defines( def _filter_source_files() -> list[str]: excluded = _platform_filter() + _define_filter() - eth_data = CORE.data.get(KEY_ETHERNET, {}) + eth_data = CORE.data.get(DOMAIN, {}) eth_type = eth_data.get(ETHERNET_TYPE_KEY) # Only compile the custom JL1101 driver when JL1101 is configured # and pioarduino doesn't have it builtin (IDF 5.4.2 to 5.x) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 5c4d498912d..ba760536865 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -4,13 +4,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components import climate, logger, uart from esphome.components.climate import ClimateMode, ClimatePreset, ClimateSwingMode +from esphome.components.logger import DOMAIN as LOGGER_DOMAIN +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BEEPER, CONF_CURRENT_TEMPERATURE, CONF_DISPLAY, CONF_LEVEL, - CONF_LOGGER, CONF_LOGS, CONF_MAX_TEMPERATURE, CONF_MIN_TEMPERATURE, @@ -22,7 +23,6 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE, CONF_TEMPERATURE_STEP, CONF_VISUAL, - CONF_WIFI, ) from esphome.cpp_generator import MockObjClass import esphome.final_validate as fv @@ -326,9 +326,9 @@ automation.register_apply_action( def _final_validate(config) -> None: full_config = fv.full_config.get() - if CONF_LOGGER in full_config: + if LOGGER_DOMAIN in full_config: _level = "NONE" - logger_config = full_config[CONF_LOGGER] + logger_config = full_config[LOGGER_DOMAIN] if CONF_LOGS in logger_config: if "haier.protocol" in logger_config[CONF_LOGS]: _level = logger_config[CONF_LOGS]["haier.protocol"] @@ -344,7 +344,7 @@ def _final_validate(config) -> None: "No logger component found, logging for Haier protocol is disabled" ) cg.add_build_flag("-DHAIER_LOG_LEVEL=0") - if config.get(CONF_WIFI_SIGNAL) and CONF_WIFI not in full_config: + if config.get(CONF_WIFI_SIGNAL) and WIFI_DOMAIN not in full_config: raise cv.Invalid( f"No WiFi configured, if you want to use haier climate without WiFi add {CONF_WIFI_SIGNAL}: false to climate configuration" ) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 3981f44fe10..453c2513fa7 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -38,7 +38,6 @@ from esphome.const import ( CONF_ADDRESS, CONF_DEVICE, CONF_FREQUENCY, - CONF_I2C, CONF_I2C_ID, CONF_ID, CONF_LOW_POWER_MODE, @@ -233,7 +232,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: ConfigType) -> None: - full_config = fv.full_config.get()[CONF_I2C] + full_config = fv.full_config.get()[DOMAIN] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") if CORE.is_rp2: diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index dc1d2b95934..6e4e35590c1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -18,6 +18,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE from esphome.core import CORE @@ -40,7 +41,6 @@ CONF_I2S_MCLK_PIN = "i2s_mclk_pin" CONF_I2S_BCLK_PIN = "i2s_bclk_pin" CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin" -CONF_I2S_AUDIO = "i2s_audio" CONF_I2S_AUDIO_ID = "i2s_audio_id" CONF_I2S_MODE = "i2s_mode" @@ -219,9 +219,9 @@ class I2SAudioData: def _get_data() -> I2SAudioData: - if CONF_I2S_AUDIO not in CORE.data: - CORE.data[CONF_I2S_AUDIO] = I2SAudioData() - return CORE.data[CONF_I2S_AUDIO] + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = I2SAudioData() + return CORE.data[DOMAIN] def _assign_ports() -> None: @@ -235,12 +235,12 @@ def _assign_ports() -> None: return full_config = fv.full_config.get() - i2s_configs = full_config[CONF_I2S_AUDIO] + i2s_configs = full_config[DOMAIN] # Find i2s_audio instances with microphones that require port 0 # (PDM and internal ADC only work on I2S port 0) port0_parent_id = None - for mic_config in full_config.get("microphone", []): + for mic_config in full_config.get(MICROPHONE_DOMAIN, []): if CONF_I2S_AUDIO_ID not in mic_config: continue if mic_config.get(CONF_PDM) or mic_config.get(CONF_ADC_TYPE) == "internal": @@ -263,7 +263,7 @@ def _assign_ports() -> None: def _final_validate(_: ConfigType) -> None: - i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] + i2s_audio_configs = fv.full_config.get()[DOMAIN] variant = get_esp32_variant() if variant not in I2S_PORTS: raise cv.Invalid(f"Unsupported variant {variant}") diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index a944a135408..3e1aef21bb6 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -1,15 +1,9 @@ import esphome.codegen as cg from esphome.components import improv_base, uart from esphome.components.esp32 import VARIANT_ESP32S3, get_esp32_variant -from esphome.components.logger import USB_CDC +from esphome.components.logger import DOMAIN as LOGGER_DOMAIN, USB_CDC import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_HARDWARE_UART, - CONF_ID, - CONF_LOGGER, - CONF_UART_ID, -) +from esphome.const import CONF_BAUD_RATE, CONF_HARDWARE_UART, CONF_ID, CONF_UART_ID from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -53,7 +47,7 @@ def validate_transport(config: ConfigType) -> None: # The host logger has no serial port for Improv to share if CORE.is_host: raise cv.Invalid("improv_serial on the host platform requires uart_id") - logger_conf = fv.full_config.get()[CONF_LOGGER] + logger_conf = fv.full_config.get()[LOGGER_DOMAIN] if logger_conf[CONF_BAUD_RATE] == 0: raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0") if CORE.is_esp32 and ( diff --git a/esphome/components/ir_rf_base/__init__.py b/esphome/components/ir_rf_base/__init__.py index 92d853b2a09..7a4cde3677a 100644 --- a/esphome/components/ir_rf_base/__init__.py +++ b/esphome/components/ir_rf_base/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import remote_base -from esphome.const import CONF_API +from esphome.components.api import DOMAIN as API_DOMAIN from esphome.core import CORE from esphome.core.entity_helpers import queue_entity_register from esphome.types import ConfigType @@ -25,7 +25,7 @@ async def attach_transmitter(var: cg.MockObj, config: ConfigType, key: str) -> N soon as the frame is handed over. """ await remote_base.register_transmittable(var, config, key) - if CONF_API in CORE.config: + if API_DOMAIN in CORE.config: cg.add_define("USE_IR_RF_TRANSMIT_COMPLETE") diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index a664890a86a..a33e706eb6e 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import button +from esphome.components.select import DOMAIN as SELECT_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AREA_ID, @@ -91,7 +92,7 @@ def final_validate(config: ConfigType) -> None: if config.get(CONF_APPLY_AREA): has_area_id_select = any( entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None - for entry in full_config.get("select", []) + for entry in full_config.get(SELECT_DOMAIN, []) ) if not has_area_id_select: raise cv.Invalid( diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 236b049f53b..de0ff57a644 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -1,9 +1,10 @@ import esphome.codegen as cg from esphome.components import number +from esphome.components.button import DOMAIN as BUTTON_DOMAIN +from esphome.components.select import DOMAIN as SELECT_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AREA_ID, - CONF_BUTTON, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, ENTITY_CATEGORY_CONFIG, @@ -114,7 +115,7 @@ def final_validate(config: ConfigType) -> None: has_apply_area = any( entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None - for entry in full_config.get(CONF_BUTTON, []) + for entry in full_config.get(BUTTON_DOMAIN, []) ) if not has_apply_area: raise cv.Invalid( @@ -124,7 +125,7 @@ def final_validate(config: ConfigType) -> None: has_area_id_select = any( entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None - for entry in full_config.get("select", []) + for entry in full_config.get(SELECT_DOMAIN, []) ) if not has_area_id_select: raise cv.Invalid( diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index a966ba67ad8..67137214c1e 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_HARDWARE_UART, CONF_ID, CONF_LEVEL, - CONF_LOGGER, CONF_LOGS, CONF_ON_MESSAGE, CONF_TAG, @@ -365,7 +364,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: baud_rate: int = config[CONF_BAUD_RATE] level = config[CONF_LEVEL] - CORE.data.setdefault(CONF_LOGGER, {})[CONF_LEVEL] = level + CORE.data.setdefault(DOMAIN, {})[CONF_LEVEL] = level tx_buffer_size = config[CONF_TX_BUFFER_SIZE] cg.add_define("ESPHOME_LOGGER_TX_BUFFER_SIZE", tx_buffer_size) # Determine task log buffer size. The buffer is a direct member of Logger diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index 00f67422f35..4bd979398ce 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -1,13 +1,14 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_BUG +from esphome.const import CONF_LEVEL, ENTITY_CATEGORY_CONFIG, ICON_BUG from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented from esphome.types import ConfigType from .. import ( CONF_LOGGER_ID, + DOMAIN, LOG_LEVELS, Logger, logger_ns, @@ -31,7 +32,7 @@ async def to_code(config: ConfigType) -> None: request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) - index = levels.index(CORE.data[CONF_LOGGER][CONF_LEVEL]) + index = levels.index(CORE.data[DOMAIN][CONF_LEVEL]) levels = levels[: index + 1] var = await select.new_select(config, options=levels) await register_parented(var, parent) diff --git a/esphome/components/lvgl/encoders.py b/esphome/components/lvgl/encoders.py index e6527bbc9b5..30480eacc7e 100644 --- a/esphome/components/lvgl/encoders.py +++ b/esphome/components/lvgl/encoders.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor +from esphome.components.rotary_encoder import DOMAIN as ROTARY_ENCODER_DOMAIN from esphome.components.rotary_encoder.sensor import RotaryEncoderSensor import esphome.config_validation as cv from esphome.const import CONF_GROUP, CONF_ID, CONF_SENSOR @@ -27,7 +28,7 @@ ENCODERS_CONFIG = cv.ensure_list( cv.Required(CONF_SENSOR): cv.Any( cv.All( cv.use_id(RotaryEncoderSensor), - cv.requires_component("rotary_encoder"), + cv.requires_component(ROTARY_ENCODER_DOMAIN), ), cv.Schema( { diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 961fa4079d7..d8a96de639a 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -3,19 +3,17 @@ from typing import Any import esphome.codegen as cg from esphome.components import image -from esphome.components.color import CONF_HEX, ColorStruct, from_rgbw +from esphome.components.color import ( + CONF_HEX, + DOMAIN as COLOR_DOMAIN, + ColorStruct, + from_rgbw, +) from esphome.components.const.css_colors import CSS_COLORS -from esphome.components.font import Font +from esphome.components.font import DOMAIN as FONT_DOMAIN, Font from esphome.components.image import Image_ import esphome.config_validation as cv -from esphome.const import ( - CONF_ARGS, - CONF_COLOR, - CONF_FORMAT, - CONF_ID, - CONF_TIME, - CONF_VALUE, -) +from esphome.const import CONF_ARGS, CONF_FORMAT, CONF_ID, CONF_TIME, CONF_VALUE from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 @@ -81,7 +79,7 @@ def get_component_colors(value): if isinstance(value, int): return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): - cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0] + cval = [x for x in CORE.config[COLOR_DOMAIN] if x[CONF_ID] == value][0] if CONF_HEX in cval: r, g, b = cval[CONF_HEX] else: @@ -437,7 +435,7 @@ class LvFont(LValidator): add_lv_use("font") fontval = cv.use_id(Font)(value) get_esphome_fonts_used().add(fontval) - return cv.requires_component("font")(fontval) + return cv.requires_component(FONT_DOMAIN)(fontval) # Use font::Font* as return type for lambdas returning ESPHome fonts # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index cf766b96c86..924c17fb547 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -4,6 +4,7 @@ from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation +from esphome.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from esphome.components.mapping import mapping_class from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path @@ -148,7 +149,8 @@ PRESS_TIME = cv.All( ENCODER_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All( - cv.declare_id(LVEncoderListener), cv.requires_component("binary_sensor") + cv.declare_id(LVEncoderListener), + cv.requires_component(BINARY_SENSOR_DOMAIN), ), cv.Optional(CONF_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_INITIAL_FOCUS): cv.All( diff --git a/esphome/components/lvgl/touchscreens.py b/esphome/components/lvgl/touchscreens.py index 0bb5715439d..6b645da3aaa 100644 --- a/esphome/components/lvgl/touchscreens.py +++ b/esphome/components/lvgl/touchscreens.py @@ -1,5 +1,9 @@ import esphome.codegen as cg -from esphome.components.touchscreen import CONF_TOUCHSCREEN_ID, Touchscreen +from esphome.components.touchscreen import ( + CONF_TOUCHSCREEN_ID, + DOMAIN as TOUCHSCREEN_DOMAIN, + Touchscreen, +) import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE @@ -17,7 +21,7 @@ CONF_TOUCHSCREEN = "touchscreen" TOUCHSCREENS_CONFIG = cv.maybe_simple_value( { cv.Required(CONF_TOUCHSCREEN_ID): cv.All( - cv.use_id(Touchscreen), cv.requires_component(CONF_TOUCHSCREEN) + cv.use_id(Touchscreen), cv.requires_component(TOUCHSCREEN_DOMAIN) ), cv.Optional(CONF_LONG_PRESS_TIME, default="400ms"): PRESS_TIME, cv.Optional(CONF_LONG_PRESS_REPEAT_TIME, default="100ms"): PRESS_TIME, @@ -29,7 +33,7 @@ TOUCHSCREENS_CONFIG = cv.maybe_simple_value( def touchscreen_schema(config): value = cv.ensure_list(TOUCHSCREENS_CONFIG)(config) - if value or CONF_TOUCHSCREEN not in CORE.loaded_integrations: + if value or TOUCHSCREEN_DOMAIN not in CORE.loaded_integrations: return value return [TOUCHSCREENS_CONFIG(config)] diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index ba322881289..fedc79383dc 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components.image import get_image_metadata +from esphome.components.image import DOMAIN as IMAGE_DOMAIN, get_image_metadata import esphome.config_validation as cv from esphome.const import ( CONF_COLOR, @@ -214,7 +214,7 @@ INDICATOR_SCHEMA = cv.Schema( cv.GenerateID(CONF_IMAGE_ID): cv.declare_id(lv_image_t), } ), - cv.requires_component("image"), + cv.requires_component(IMAGE_DOMAIN), ), cv.Exclusive(CONF_ARC, CONF_INDICATORS): INDICATOR_ARC_SCHEMA.extend( { diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 703cdc9ab09..e49a3897fee 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, - CONF_MDNS, CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, @@ -195,7 +194,7 @@ def request_service_enable_disable() -> bool: Public API for external components. Do not remove. """ - mdns_config = CORE.config.get(CONF_MDNS) + mdns_config = CORE.config.get(DOMAIN) if ( mdns_config is None or mdns_config[CONF_DISABLED] diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1ee6d6cffaa..1c08202d411 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -278,7 +278,7 @@ def get_priority_interfaces_from_full_config(full_config: ConfigType) -> set[str """ return { entry["interface"] - for entry in full_config.get("network", {}).get(CONF_PRIORITY, []) + for entry in full_config.get(DOMAIN, {}).get(CONF_PRIORITY, []) } diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 84b525271f1..25c7a78bebe 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -19,7 +19,7 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage -from esphome.components.network import add_use_address +from esphome.components.network import DOMAIN as NETWORK_DOMAIN, add_use_address from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform @@ -261,7 +261,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(_: ConfigType) -> None: full_config = fv.full_config.get() - network_config = full_config.get("network", {}) + network_config = full_config.get(NETWORK_DOMAIN, {}) if not network_config.get(CONF_ENABLE_IPV6, False): raise cv.Invalid( "OpenThread requires IPv6 to be enabled in the network component. " diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index a03b1f1c301..9498d4b4da7 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -7,6 +7,7 @@ from typing import Any from esphome import git, yaml_util from esphome.components.substitutions import ( + DOMAIN as SUBSTITUTIONS_DOMAIN, ContextVars, ErrList, push_context, @@ -353,7 +354,7 @@ def _walk_packages( packages = config[CONF_PACKAGES] packages_path = (path or []) + [CONF_PACKAGES] - with cv.prepend_path(CONF_PACKAGES): + with cv.prepend_path(DOMAIN): if isinstance(packages, yaml_util.IncludeFile): # If the packages key is an IncludeFile, resolve it first before processing. packages = resolve_include( @@ -599,7 +600,7 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - with cv.prepend_path(CONF_SUBSTITUTIONS): + with cv.prepend_path(SUBSTITUTIONS_DOMAIN): substitutions = UserDict( resolve_substitutions_block( config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index bd0157d60ec..ad79551792b 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -12,7 +12,6 @@ DOMAIN = "rp2040_ble" CONF_RP2040_BLE_ID = "rp2040_ble_id" -KEY_RP2040_BLE = "rp2040_ble" KEY_USED_CONNECTION_SLOTS = "used_connection_slots" # Hard platform cap on concurrent GATT connections: the BTstack pool overrides @@ -50,7 +49,7 @@ def consume_connection_slots( the total is checked against MAX_CONNECTIONS in final validation.""" def _consume_connection_slots(config: MutableMapping) -> MutableMapping: - data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {}) + data: dict = CORE.data.setdefault(DOMAIN, {}) slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) slots.extend([consumer] * value) return config @@ -63,7 +62,7 @@ def validate_connection_slots() -> None: # Skip in testing mode to allow component grouping (esp32_ble parity). if CORE.testing_mode: return - used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, []) + used = CORE.data.get(DOMAIN, {}).get(KEY_USED_CONNECTION_SLOTS, []) if len(used) > MAX_CONNECTIONS: raise cv.Invalid( f"BLE components require {len(used)} connection slots but the " diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 8cb2653547a..8d77ff363fc 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_ID, CONF_NUM_ATTEMPTS, CONF_REBOOT_TIMEOUT, - CONF_SAFE_MODE, CONF_STORAGE, KEY_PAST_SAFE_MODE, ) @@ -93,5 +92,5 @@ async def to_code(config: ConfigType) -> None: ) cg.add(RawExpression(f"if ({condition}) return")) - CORE.data[CONF_SAFE_MODE] = {} - CORE.data[CONF_SAFE_MODE][KEY_PAST_SAFE_MODE] = True + CORE.data[DOMAIN] = {} + CORE.data[DOMAIN][KEY_PAST_SAFE_MODE] = True diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index e7ffcb140ee..48a208b7f7f 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -16,7 +16,6 @@ RestartScript = script_ns.class_("RestartScript", Script) QueueingScript = script_ns.class_("QueueingScript", Script, cg.Component) ParallelScript = script_ns.class_("ParallelScript", Script) -CONF_SCRIPT = "script" CONF_SINGLE = "single" CONF_QUEUED = "queued" CONF_PARALLEL = "parallel" @@ -36,7 +35,7 @@ PARAMETER_TYPE_TRANSLATIONS = { def get_script(script_id): - scripts = CORE.config.get(CONF_SCRIPT, {}) + scripts = CORE.config.get(DOMAIN, {}) for script in scripts: if script.get(CONF_ID, None) == script_id: return script diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index b587a3e0928..c557a7960d2 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -6,6 +6,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import esp32, mdns, network, psram, socket, wifi from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER +from esphome.components.mdns import DOMAIN as MDNS_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -13,7 +14,6 @@ from esphome.const import ( CONF_FORMAT, CONF_HEIGHT, CONF_ID, - CONF_MDNS, CONF_MODEL, CONF_NAME, CONF_PROJECT, @@ -428,7 +428,7 @@ async def to_code(config: ConfigType) -> None: # Service starts disabled and the hub enables it; always advertised where unsupported if mdns.request_service_enable_disable(): - mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID]) + mdns_var = await cg.get_variable(CORE.config[MDNS_DOMAIN][CONF_ID]) cg.add(var.set_mdns(mdns_var)) data = _get_data() diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index ae640e70a40..63d30d0c033 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -3,8 +3,9 @@ import logging import esphome.codegen as cg from esphome.components import switch from esphome.components.const import CONF_ENABLED +from esphome.components.switch import DOMAIN as SWITCH_DOMAIN import esphome.config_validation as cv -from esphome.const import CONF_PLATFORM, CONF_SWITCH, CONF_TYPE, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_PLATFORM, CONF_TYPE, ENTITY_CATEGORY_CONFIG import esphome.final_validate as fv from esphome.types import ConfigType @@ -69,7 +70,7 @@ def _final_validate(config: ConfigType) -> ConfigType: switch_type = config[CONF_TYPE] same_type = [ conf - for conf in full_config.get(CONF_SWITCH, []) + for conf in full_config.get(SWITCH_DOMAIN, []) if conf.get(CONF_PLATFORM) == DOMAIN and conf.get(CONF_TYPE) == switch_type ] # Two switches of one type would each drive the same hub setting. diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 9d849f9fe9b..9365339473e 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -2,13 +2,13 @@ import logging import esphome.codegen as cg from esphome.components import time as time_ +from esphome.components.time import DOMAIN as TIME_DOMAIN from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_PLATFORM, CONF_SERVERS, - CONF_TIME, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -20,11 +20,12 @@ from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["network"] -CONF_SNTP = "sntp" sntp_ns = cg.esphome_ns.namespace("sntp") SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) @@ -35,13 +36,13 @@ DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] def _sntp_final_validate(config: ConfigType) -> None: """Merge multiple SNTP instances into one, similar to OTA merging behavior.""" full_conf = fv.full_config.get() - time_confs = full_conf.get(CONF_TIME, []) + time_confs = full_conf.get(TIME_DOMAIN, []) sntp_configs: list[ConfigType] = [] other_time_configs: list[ConfigType] = [] for time_conf in time_confs: - if time_conf.get(CONF_PLATFORM) == CONF_SNTP: + if time_conf.get(CONF_PLATFORM) == DOMAIN: sntp_configs.append(time_conf) else: other_time_configs.append(time_conf) @@ -81,7 +82,7 @@ def _sntp_final_validate(config: ConfigType) -> None: # Replace time configs with merged SNTP + other time platforms other_time_configs.append(merged) - full_conf[CONF_TIME] = other_time_configs + full_conf[TIME_DOMAIN] = other_time_configs fv.full_config.set(full_conf) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 75408d2fd84..d6949cc5909 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -18,6 +18,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, only_on_variant, ) +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -158,7 +159,7 @@ def _validate_psram_dma(value: Any) -> bool: msg_prefix="PSRAM DMA", ), cv.require_framework_version(esp_idf=cv.Version(5, 5, 3)), - cv.requires_component("psram"), + cv.requires_component(PSRAM_DOMAIN), )(value) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 845240dcff1..93cf588e02a 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -518,7 +518,7 @@ def resolve_substitutions_block( """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. The caller is responsible for wrapping the call in - ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``cv.prepend_path(DOMAIN)`` for error reporting. ``command_line_substitutions`` seeds the filename context so ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. """ @@ -553,7 +553,7 @@ def do_substitution_pass( # Extract substitutions from config, overriding with substitutions coming from command line: # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) - with cv.prepend_path(CONF_SUBSTITUTIONS): + with cv.prepend_path(DOMAIN): substitutions = resolve_substitutions_block( substitutions, command_line_substitutions ) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 66c43aef26f..586b69d603b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, ) +from esphome.components.logger import DOMAIN as LOGGER_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID from esphome.types import ConfigType @@ -96,7 +97,7 @@ def _final_validate(config: ConfigType) -> None: # tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes # the ROM console through that same peripheral, so the two cannot coexist. # (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.) - logger_config = full_config.get("logger") + logger_config = full_config.get(LOGGER_DOMAIN) if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC": raise cv.Invalid( "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 6e7a6fef325..601fdeb327e 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -7,6 +7,7 @@ from esphome.components.const import ( CONF_RECONNECT_INTERVAL, CONF_ROLE, ) +from esphome.components.tcp_uart import DOMAIN as TCP_UART_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_DEBUG, @@ -125,7 +126,7 @@ def _final_validate(config: ConfigType) -> ConfigType: ports = data.setdefault(CONF_PORT, set()) if port in ports or any( conf[CONF_ROLE] == "server" and conf[CONF_PORT] == port - for conf in full_config.get("tcp_uart", []) + for conf in full_config.get(TCP_UART_DOMAIN, []) ): raise cv.Invalid( f"Port {port} is already the listen port of another uart_tcp " diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index a13d6cd7fd5..04140ba431d 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor, time +from esphome.components.time import DOMAIN as TIME_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -44,7 +45,8 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Schema( { cv.GenerateID(CONF_TIME_ID): cv.All( - cv.requires_component("time"), cv.use_id(time.RealTimeClock) + cv.requires_component(TIME_DOMAIN), + cv.use_id(time.RealTimeClock), ), } ) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index f0cb11244f5..2898576b323 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -34,7 +34,7 @@ USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: return any( channel[CONF_ID] == uart_id - for device in full_config.get("usb_uart") or [] + for device in full_config.get(DOMAIN) or [] for channel in device[CONF_CHANNELS] ) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 808ff6ed179..4f233e99655 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -240,11 +240,11 @@ WEBSERVER_SORTING_SCHEMA = cv.Schema( { cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.Optional(CONF_SORTING_WEIGHT): cv.All( - cv.requires_component("web_server"), + cv.requires_component(DOMAIN), cv.float_, ), cv.Optional(CONF_SORTING_GROUP_ID): cv.All( - cv.requires_component("web_server"), + cv.requires_component(DOMAIN), cv.use_id(cg.int_), ), } @@ -474,7 +474,7 @@ def FILTER_SOURCE_FILES() -> list[str]: files_to_filter: list[str] = [] # web_server_v1.cpp is only needed when version is 1 - config = CORE.config.get("web_server", {}) + config = CORE.config.get(DOMAIN, {}) if config.get(CONF_VERSION, 2) != 1: files_to_filter.append("web_server_v1.cpp") diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 03a5c2ca9bd..937d99ede88 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -2,15 +2,22 @@ import logging import esphome.codegen as cg from esphome.components.esp32 import add_idf_component -from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.components.ota import ( + BASE_OTA_SCHEMA, + DOMAIN as OTA_DOMAIN, + OTAComponent, + ota_to_code, +) from esphome.config_helpers import merge_config import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER +from esphome.const import CONF_ID, CONF_PLATFORM from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType +from .. import DOMAIN + _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] @@ -27,13 +34,13 @@ def _web_server_ota_final_validate(config: ConfigType) -> None: causing undefined behavior. Merge them into a single instance. """ full_conf = fv.full_config.get() - ota_confs = full_conf.get(CONF_OTA, []) + ota_confs = full_conf.get(OTA_DOMAIN, []) web_server_ota_configs: list[ConfigType] = [] other_ota_configs: list[ConfigType] = [] for ota_conf in ota_confs: - if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER: + if ota_conf.get(CONF_PLATFORM) == DOMAIN: web_server_ota_configs.append(ota_conf) else: other_ota_configs.append(ota_conf) @@ -62,7 +69,7 @@ def _web_server_ota_final_validate(config: ConfigType) -> None: # Replace OTA configs with merged web_server + other OTA platforms other_ota_configs.append(merged) - full_conf[CONF_OTA] = other_ota_configs + full_conf[OTA_DOMAIN] = other_ota_configs fv.full_config.set(full_conf) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 22ce14d27ec..aa6d113e168 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -61,7 +61,6 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, - CONF_WIFI, PLACEHOLDER_WIFI_SSID, Platform, PlatformFramework, @@ -1089,7 +1088,7 @@ def _placeholder_wifi_credentials(config: ConfigType) -> list[str]: values still appear. Empty list means no placeholders were found. """ placeholders: list[str] = [] - wifi_conf = config.get(CONF_WIFI) + wifi_conf = config.get(DOMAIN) if not wifi_conf: return placeholders diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py index 2cb62b85b5e..604cffb0e03 100644 --- a/esphome/components/zephyr_pwm/output.py +++ b/esphome/components/zephyr_pwm/output.py @@ -4,6 +4,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg from esphome.components import output +from esphome.components.output import DOMAIN as OUTPUT_DOMAIN from esphome.components.zephyr import zephyr_add_overlay_builder, zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( @@ -12,7 +13,6 @@ from esphome.const import ( CONF_ID, CONF_INVERTED, CONF_NUMBER, - CONF_OUTPUT, CONF_PIN, CONF_PLATFORM, ) @@ -74,7 +74,7 @@ def _allocate_blocks() -> None: full_config = fv.full_config.get() zephyr_pwm_conf = [ cfg - for cfg in full_config.get(CONF_OUTPUT, []) + for cfg in full_config.get(OUTPUT_DOMAIN, []) if cfg.get(CONF_PLATFORM) == DOMAIN ] diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index c5dc50ee5fe..0dc3b4b6f71 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,7 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg -from esphome.components.esp32 import only_on_variant +from esphome.components.esp32 import DOMAIN as ESP32_DOMAIN, only_on_variant from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32C6, @@ -74,19 +74,19 @@ def _get_base_schema(cluster_options: list[str] | None = None) -> cv.Schema: schema = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( - cv.requires_component("zigbee"), - cv.requires_component("esp32"), + cv.requires_component(DOMAIN), + cv.requires_component(ESP32_DOMAIN), _check_report_deprecation, cv.enum(REPORT, lower=True), ), cv.Optional(CONF_ENDPOINT): cv.All( - cv.requires_component("zigbee"), - cv.requires_component("esp32"), + cv.requires_component(DOMAIN), + cv.requires_component(ESP32_DOMAIN), cv.int_range(1, CONF_MAX_EP_NUMBER), ), cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( - cv.requires_component("zigbee"), - cv.requires_component("esp32"), + cv.requires_component(DOMAIN), + cv.requires_component(ESP32_DOMAIN), cv.boolean, ), } @@ -97,8 +97,8 @@ def _get_base_schema(cluster_options: list[str] | None = None) -> cv.Schema: cv.OnlyWith( CONF_CLUSTER, ["esp32", "zigbee"], default=cluster_options[0] ): cv.All( - cv.requires_component("zigbee"), - cv.requires_component("esp32"), + cv.requires_component(DOMAIN), + cv.requires_component(ESP32_DOMAIN), cv.one_of(*cluster_options, lower=True), ), } @@ -231,7 +231,7 @@ async def to_code(config: ConfigType) -> None: async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: if not config.get(CONF_ZIGBEE_ID): @@ -243,7 +243,7 @@ async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: async def setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: if not config.get(CONF_ZIGBEE_ID): @@ -255,7 +255,7 @@ async def setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: async def setup_switch(entity: cg.MockObj, config: ConfigType) -> None: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: if not config.get(CONF_ZIGBEE_ID): @@ -273,7 +273,7 @@ async def setup_number( max_value: float, step: float, ) -> None: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: if not config.get(CONF_ZIGBEE_ID): @@ -303,7 +303,7 @@ def consume_endpoint(config: ConfigType) -> ConfigType: def validate_binary_sensor(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: return validate_binary_sensor_esp32(config) @@ -311,7 +311,7 @@ def validate_binary_sensor(config: ConfigType) -> ConfigType: def validate_sensor(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: return validate_sensor_esp32(config) @@ -319,7 +319,7 @@ def validate_sensor(config: ConfigType) -> ConfigType: def validate_switch(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: return validate_switch_esp32(config) @@ -327,7 +327,7 @@ def validate_switch(config: ConfigType) -> ConfigType: def validate_number(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: return validate_number_esp32(config) diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index 4817a854a86..e451fe79f4d 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import uart +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI import esphome.final_validate as fv @@ -15,7 +16,7 @@ ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() - if (wifi_conf := full_config.get(CONF_WIFI)) and ( + if (wifi_conf := full_config.get(WIFI_DOMAIN)) and ( wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none" ): raise cv.Invalid( diff --git a/tests/component_tests/rp2040_ble/test_connection_slots.py b/tests/component_tests/rp2040_ble/test_connection_slots.py index f33180e2d0a..84f849760f0 100644 --- a/tests/component_tests/rp2040_ble/test_connection_slots.py +++ b/tests/component_tests/rp2040_ble/test_connection_slots.py @@ -20,7 +20,7 @@ def test_proxy_claims_its_slots_through_the_shared_accounting( # A default (3-slot) proxy build records one claim per slot, attributed # to the consumer, and passes final validation. generate_main(component_config_path("rp2_proxy_default.yaml")) - used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS] + used = CORE.data[rp2040_ble.DOMAIN][rp2040_ble.KEY_USED_CONNECTION_SLOTS] assert used == ["bluetooth_proxy"] * 3 diff --git a/tests/component_tests/sntp/test_init.py b/tests/component_tests/sntp/test_init.py index 9197ff55d0a..db0d3c9d9c4 100644 --- a/tests/component_tests/sntp/test_init.py +++ b/tests/component_tests/sntp/test_init.py @@ -8,7 +8,8 @@ from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate +from esphome.components.sntp import DOMAIN as SNTP_DOMAIN +from esphome.components.sntp.time import _sntp_final_validate from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME from esphome.core import ID import esphome.final_validate as fv @@ -20,7 +21,7 @@ import esphome.final_validate as fv pytest.param( [ { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time", is_manual=False), CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], } @@ -33,12 +34,12 @@ import esphome.final_validate as fv pytest.param( [ { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_1", is_manual=False), CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], }, { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_2", is_manual=False), CONF_SERVERS: ["192.168.1.2"], }, @@ -51,12 +52,12 @@ import esphome.final_validate as fv pytest.param( [ { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_1", is_manual=False), CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], }, { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_2", is_manual=False), CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], }, @@ -69,17 +70,17 @@ import esphome.final_validate as fv pytest.param( [ { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_1", is_manual=False), CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], }, { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_2", is_manual=False), CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"], }, { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_3", is_manual=False), CONF_SERVERS: ["pool3.ntp.org"], }, @@ -95,7 +96,7 @@ import esphome.final_validate as fv pytest.param( [ { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_1", is_manual=False), CONF_SERVERS: [ "192.168.1.1", @@ -105,7 +106,7 @@ import esphome.final_validate as fv ], }, { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_2", is_manual=False), CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], }, @@ -143,7 +144,7 @@ def test_sntp_instance_merging( sntp_instances = [ tc for tc in updated_conf[CONF_TIME] - if tc.get(CONF_PLATFORM) == CONF_SNTP + if tc.get(CONF_PLATFORM) == SNTP_DOMAIN ] assert len(sntp_instances) == expected_count @@ -169,12 +170,12 @@ def test_sntp_inconsistent_manual_ids() -> None: # Create configs with manual IDs that are inconsistent time_configs = [ { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_1", is_manual=True), CONF_SERVERS: ["192.168.1.1"], }, { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_2", is_manual=True), CONF_SERVERS: ["192.168.1.2"], }, @@ -197,7 +198,7 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non """Test that SNTP merging doesn't affect other time platforms.""" time_configs = [ { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_1", is_manual=False), CONF_SERVERS: ["192.168.1.1"], }, @@ -206,7 +207,7 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non CONF_ID: ID("homeassistant_time", is_manual=False), }, { - CONF_PLATFORM: CONF_SNTP, + CONF_PLATFORM: SNTP_DOMAIN, CONF_ID: ID("sntp_time_2", is_manual=False), CONF_SERVERS: ["192.168.1.2"], }, @@ -226,11 +227,11 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non # Find the platforms platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]} - assert platforms == {CONF_SNTP, "homeassistant"} + assert platforms == {SNTP_DOMAIN, "homeassistant"} # Verify SNTP was merged sntp_instances = [ - tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP + tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == SNTP_DOMAIN ] assert len(sntp_instances) == 1 assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"] From 6a463c801edb64d7a534664fea533ee957d09805 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 15:42:45 -1000 Subject: [PATCH 103/435] [ci] Stop the toolchain tests from installing ESP-IDF (#20413) --- tests/unit_tests/conftest.py | 16 ++++++++++++++++ tests/unit_tests/test_espidf_toolchain.py | 2 ++ 2 files changed, 18 insertions(+) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index ff5564e8150..7835156b01f 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -18,6 +18,7 @@ from unittest.mock import Mock, patch import pytest from esphome.core import CORE +from esphome.espidf import toolchain here = Path(__file__).parent @@ -26,6 +27,21 @@ package_root = here.parent.parent sys.path.insert(0, package_root.as_posix()) +@pytest.fixture(autouse=True) +def _no_idf_install(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail any test that would install ESP-IDF for real. + + An unmocked ``_get_idf_env`` reaches ``check_esp_idf_install``, which + downloads the framework on a bare CI runner (minutes on Windows). The + install path's own tests call it through ``esphome.espidf.framework``. + """ + + def refuse(*args: object, **kwargs: object) -> None: + raise AssertionError("test would install ESP-IDF; mock _get_idf_env") + + monkeypatch.setattr(toolchain, "check_esp_idf_install", refuse) + + @pytest.fixture(autouse=True) def reset_core(): """Reset CORE after each test.""" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 620d08fe3b4..854c39a9ce1 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -1519,6 +1519,7 @@ def test_run_reconfigure_flip_into_skip_mode_cleans_up(setup_core: Path) -> None with ( patch.object(toolchain, "_skip_bootloader", return_value=True), patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "_get_idf_env", return_value={}), patch.object(toolchain, "_tool_env", return_value={}), patch.object(toolchain, "run_build_tool", return_value=0), patch.object(toolchain, "_idf_py") as mock_idf_py, @@ -1542,6 +1543,7 @@ def test_run_reconfigure_skip_steady_state_cleans_nothing(setup_core: Path) -> N with ( patch.object(toolchain, "_skip_bootloader", return_value=True), patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "_get_idf_env", return_value={}), patch.object(toolchain, "_tool_env", return_value={}), patch.object(toolchain, "run_build_tool", return_value=0), patch.object(toolchain, "_idf_py") as mock_idf_py, From 646f386f12f520bdeed5e01924939af4a9d4de63 Mon Sep 17 00:00:00 2001 From: Guillaume Rischard Date: Thu, 8 Oct 2026 21:50:09 -0400 Subject: [PATCH 104/435] [spd2010] Add touchscreen platform (#19057) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/spd2010/__init__.py | 2 + .../spd2010/spd2010_touchscreen.cpp | 211 ++++++++++++++++++ .../components/spd2010/spd2010_touchscreen.h | 30 +++ esphome/components/spd2010/touchscreen.py | 33 +++ tests/components/spd2010/common.yaml | 25 +++ tests/components/spd2010/test.esp32-idf.yaml | 7 + 7 files changed, 309 insertions(+) create mode 100644 esphome/components/spd2010/__init__.py create mode 100644 esphome/components/spd2010/spd2010_touchscreen.cpp create mode 100644 esphome/components/spd2010/spd2010_touchscreen.h create mode 100644 esphome/components/spd2010/touchscreen.py create mode 100644 tests/components/spd2010/common.yaml create mode 100644 tests/components/spd2010/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index e0f9d2d2ef9..a851e62a3e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -519,6 +519,7 @@ esphome/components/sound_level/* @kahrendt esphome/components/spa06_base/* @danielkent-net esphome/components/spa06_i2c/* @danielkent-net esphome/components/spa06_spi/* @danielkent-net +esphome/components/spd2010/* @grischard esphome/components/speaker/* @jesserockz @kahrendt esphome/components/speaker/media_player/* @kahrendt @synesthesiam esphome/components/speaker_source/* @kahrendt diff --git a/esphome/components/spd2010/__init__.py b/esphome/components/spd2010/__init__.py new file mode 100644 index 00000000000..3fc6c6e9406 --- /dev/null +++ b/esphome/components/spd2010/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@grischard"] +DEPENDENCIES = ["i2c"] diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp new file mode 100644 index 00000000000..94fad87d7d4 --- /dev/null +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2023 Espressif Systems (Shanghai) CO LTD +// SPDX-License-Identifier: Apache-2.0 +// Protocol adapted from esp_lcd_touch_spd2010 2.0.1 for ESPHome. +// https://github.com/espressif/esp-bsp/tree/master/components/lcd_touch/esp_lcd_touch_spd2010 + +#include "spd2010_touchscreen.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::spd2010 { + +ESPHOME_LOG_TAG(TAG, "spd2010.touchscreen"); + +static constexpr uint16_t REG_CLEAR_INTERRUPT = 0x0002; +static constexpr uint16_t REG_CPU_START = 0x0004; +static constexpr uint16_t REG_STATUS = 0x0020; +static constexpr uint16_t REG_VERSION = 0x0026; +static constexpr uint16_t REG_TOUCH_START = 0x0046; +static constexpr uint16_t REG_POINT_MODE = 0x0050; +static constexpr uint16_t REG_HDP_STATUS = 0x02FC; +static constexpr uint16_t REG_HDP = 0x0300; + +// Low status byte +static constexpr uint8_t STATUS_POINT = 0x01; +static constexpr uint8_t STATUS_GESTURE = 0x02; +static constexpr uint8_t STATUS_AUX = 0x08; +// High status byte +static constexpr uint8_t STATUS_BIOS = 0x40; +static constexpr uint8_t STATUS_CPU = 0x20; +static constexpr uint8_t STATUS_RUNNING = 0x08; + +static constexpr uint8_t HDP_STATUS_DONE = 0x82; +static constexpr uint8_t HDP_STATUS_MORE = 0x00; + +static constexpr uint8_t MAX_CONTACT_ID = 0x0A; +static constexpr uint8_t GESTURE_REPORT_ID = 0xF6; +static constexpr size_t REPORT_HEADER_LENGTH = 4; +static constexpr size_t CONTACT_LENGTH = 6; +static constexpr size_t MAX_REPORT_LENGTH = REPORT_HEADER_LENGTH + 10 * CONTACT_LENGTH; +static constexpr uint8_t MAX_DRAIN_READS = 8; + +static constexpr uint32_t RESET_PULSE_WIDTH = + 20; // longer than the datasheet says, but the controller seems to need it +static constexpr uint32_t RESET_DELAY = 20; + +bool SPD2010Touchscreen::read_register_(uint16_t reg, uint8_t *data, size_t length) const { + const uint8_t command[] = {static_cast(reg), static_cast(reg >> 8u)}; + if (this->write(command, sizeof(command)) != i2c::ERROR_OK) + return false; + // The controller needs a STOP and a delay before a commandless receive. + delay_microseconds_safe(200); + return this->read(data, length) == i2c::ERROR_OK; +} + +bool SPD2010Touchscreen::write_command_(uint16_t reg, uint16_t value) const { + const uint8_t command[]{ + static_cast(reg), + static_cast(reg >> 8u), + static_cast(value), + static_cast(value >> 8u), + }; + return this->write(command, sizeof(command)) == i2c::ERROR_OK; +} + +bool SPD2010Touchscreen::clear_interrupt_() const { return this->write_command_(REG_CLEAR_INTERRUPT, 1); } + +void SPD2010Touchscreen::setup() { + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = this->display_->get_native_width() - 1; + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = this->display_->get_native_height() - 1; + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + this->set_timeout(RESET_PULSE_WIDTH, [this] { + this->reset_pin_->digital_write(true); + this->set_timeout(RESET_DELAY, [this] { this->initialize_(); }); + }); + } else { + this->initialize_(); + } +} + +void SPD2010Touchscreen::initialize_() { + uint8_t version[18]; + if (!this->read_register_(REG_VERSION, version, sizeof(version))) { + this->mark_failed(LOG_STR("Failed to read firmware version")); + return; + } + ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_uint16(version[5], version[4])); + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + this->ready_ = true; +} + +bool SPD2010Touchscreen::finish_report_() { + // A corrupt status must not cause an unbounded drain loop or buffer overflow. + for (uint8_t attempt = 0; attempt != MAX_DRAIN_READS; attempt++) { + uint8_t status[8]; + if (!this->read_register_(REG_HDP_STATUS, status, sizeof(status))) + return false; + if (status[5] == HDP_STATUS_DONE) + return this->clear_interrupt_(); + const uint16_t length = encode_uint16(status[3], status[2]); + if (status[5] != HDP_STATUS_MORE || length == 0 || length > MAX_REPORT_LENGTH) + break; + uint8_t remaining[MAX_REPORT_LENGTH]; + if (!this->read_register_(REG_HDP, remaining, length)) + return false; + } + ESP_LOGW(TAG, "Invalid or unfinished report"); + (void) this->clear_interrupt_(); + return false; +} + +// Returns true only when touch data was read correctly and should be published. +bool SPD2010Touchscreen::read_data_() { + uint8_t status[4]; + if (!this->read_register_(REG_STATUS, status, sizeof(status))) { + this->status_set_warning(LOG_STR("Failed to read status")); + return false; + } + const uint8_t status_low = status[0]; + const uint8_t status_high = status[1]; + const uint16_t length = encode_uint16(status[3], status[2]); + + if (status_high & STATUS_BIOS) { + this->status_set_warning(LOG_STR("BIOS status; start CPU")); + (void) (this->clear_interrupt_() && this->write_command_(REG_CPU_START, 1)); + return false; + } + if (status_high & STATUS_CPU) { + this->status_set_warning(LOG_STR("CPU status; stop point mode, start touch")); + (void) (this->write_command_(REG_POINT_MODE, 0) && this->write_command_(REG_TOUCH_START, 0) && + this->clear_interrupt_()); + return false; + } + if ((status_high & STATUS_RUNNING) && length == 0) { + if (!this->clear_interrupt_()) { + this->status_set_warning(LOG_STR("Failed to clear interrupt")); + return false; + } + this->status_clear_warning(); + return true; + } + if (!(status_low & (STATUS_POINT | STATUS_GESTURE))) { + if ((status_high & STATUS_RUNNING) && (status_low & STATUS_AUX) && !this->clear_interrupt_()) { + this->status_set_warning(LOG_STR("Failed to clear interrupt")); + return false; + } + this->status_clear_warning(); + return false; + } + if (length < REPORT_HEADER_LENGTH || length > MAX_REPORT_LENGTH || + (length - REPORT_HEADER_LENGTH) % CONTACT_LENGTH != 0) { + ESP_LOGW(TAG, "Invalid report length: %u", length); + this->status_set_warning(LOG_STR("Invalid report length")); + (void) this->clear_interrupt_(); + return false; + } + uint8_t report[MAX_REPORT_LENGTH]; + if (!this->read_register_(REG_HDP, report, length) || !this->finish_report_()) { + this->status_set_warning(LOG_STR("Failed to read report")); + return false; + } + // Gesture reports are not used. + if (!(status_low & STATUS_POINT) || + (length > REPORT_HEADER_LENGTH && report[REPORT_HEADER_LENGTH] == GESTURE_REPORT_ID)) { + this->status_clear_warning(); + return false; + } + // Check every ID before adding any point, so a bad report leaves the stored touches unchanged. + for (size_t offset = REPORT_HEADER_LENGTH; offset < length; offset += CONTACT_LENGTH) { + if (report[offset] > MAX_CONTACT_ID) { + this->status_set_warning(LOG_STR("Invalid contact ID")); + return false; + } + } + for (size_t offset = REPORT_HEADER_LENGTH; offset < length; offset += CONTACT_LENGTH) { + const uint8_t *contact = report + offset; + // Zero strength marks a released contact. + if (contact[4] == 0) + continue; + const uint16_t x = ((contact[3] & 0xF0u) << 4u) | contact[1]; + const uint16_t y = ((contact[3] & 0x0Fu) << 8u) | contact[2]; + this->add_raw_touch_position_(contact[0], x, y, contact[4]); + } + this->status_clear_warning(); + return true; +} + +void SPD2010Touchscreen::update_touches() { + if (!this->ready_ || !this->read_data_()) + this->skip_update_ = true; +} + +void SPD2010Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "SPD2010 Touchscreen:\n" + " Raw X range: %d-%d\n" + " Raw Y range: %d-%d", + this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::spd2010 diff --git a/esphome/components/spd2010/spd2010_touchscreen.h b/esphome/components/spd2010/spd2010_touchscreen.h new file mode 100644 index 00000000000..88173968721 --- /dev/null +++ b/esphome/components/spd2010/spd2010_touchscreen.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/hal.h" + +namespace esphome::spd2010 { + +class SPD2010Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void initialize_(); + void update_touches() override; + bool read_data_(); + bool read_register_(uint16_t reg, uint8_t *data, size_t length) const; + bool write_command_(uint16_t reg, uint16_t value) const; + bool clear_interrupt_() const; + bool finish_report_(); + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + bool ready_{false}; +}; + +} // namespace esphome::spd2010 diff --git a/esphome/components/spd2010/touchscreen.py b/esphome/components/spd2010/touchscreen.py new file mode 100644 index 00000000000..7ead6a34184 --- /dev/null +++ b/esphome/components/spd2010/touchscreen.py @@ -0,0 +1,33 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN +from esphome.types import ConfigType + +spd2010_ns = cg.esphome_ns.namespace("spd2010") +SPD2010Touchscreen = spd2010_ns.class_( + "SPD2010Touchscreen", touchscreen.Touchscreen, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("50ms") + .extend( + { + cv.GenerateID(): cv.declare_id(SPD2010Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x53)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/tests/components/spd2010/common.yaml b/tests/components/spd2010/common.yaml new file mode 100644 index 00000000000..0692137638d --- /dev/null +++ b/tests/components/spd2010/common.yaml @@ -0,0 +1,25 @@ +display: + - platform: ssd1306_i2c + id: spd2010_display + model: SSD1306_128X64 + +touchscreen: + - platform: spd2010 + id: spd2010_touchscreen + display: spd2010_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} + - platform: spd2010 + id: spd2010_polling_touchscreen + display: spd2010_display + address: 0x54 + update_interval: 100ms + calibration: + x_min: 0 + x_max: 479 + y_min: 0 + y_max: 479 + transform: + swap_xy: true + mirror_x: true + mirror_y: false diff --git a/tests/components/spd2010/test.esp32-idf.yaml b/tests/components/spd2010/test.esp32-idf.yaml new file mode 100644 index 00000000000..ca2be87d29d --- /dev/null +++ b/tests/components/spd2010/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + interrupt_pin: "20" + reset_pin: "21" + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spd2010: !include common.yaml From 6513b176f4423e3019f5466bcc4ab4d827853fba Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:23:56 +1100 Subject: [PATCH 105/435] [spd2010] Fix DOMAIN (#20422) --- esphome/components/spd2010/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/spd2010/__init__.py b/esphome/components/spd2010/__init__.py index 3fc6c6e9406..67a78e5f287 100644 --- a/esphome/components/spd2010/__init__.py +++ b/esphome/components/spd2010/__init__.py @@ -1,2 +1,3 @@ CODEOWNERS = ["@grischard"] DEPENDENCIES = ["i2c"] +DOMAIN = "spd2010" From c7d6a2e89827faa363f2685b3c8c0e7054bc1caf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 22:13:45 -1000 Subject: [PATCH 106/435] [sendspin] Fix duplicate definitions after the beta merge (#20424) --- esphome/components/sendspin/sendspin_hub.cpp | 83 -------------------- 1 file changed, 83 deletions(-) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2de9582bf14..8b5eaeeed23 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -107,89 +107,6 @@ std::optional parse_record_slot(const std::string &key) { } // namespace -namespace { - -// The reason passed to on_pairing_failed automations, in the protocol's own spelling. -StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) { - using sendspin::SendspinPairAbortReason; - switch (reason) { - case SendspinPairAbortReason::ATTEMPT_TIMEOUT: - return StringRef::from_lit("attempt_timeout"); - case SendspinPairAbortReason::CONCURRENT_ATTEMPT: - return StringRef::from_lit("concurrent_attempt"); - case SendspinPairAbortReason::METHOD_NOT_SUPPORTED: - return StringRef::from_lit("method_not_supported"); - case SendspinPairAbortReason::PAIRING_CODE_MISMATCH: - return StringRef::from_lit("pairing_code_mismatch"); - case SendspinPairAbortReason::USER_CANCELLED: - return StringRef::from_lit("user_cancelled"); - case SendspinPairAbortReason::UNKNOWN: - break; - } - return StringRef::from_lit("unknown"); -} - -// Zeroes through a volatile pointer so dead-store elimination cannot drop it. -void secure_wipe(void *data, size_t len) { - volatile auto *p = static_cast(data); - for (size_t i = 0; i < len; ++i) { - p[i] = 0; - } -} - -// Call once per key, from setup(). -ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) { - return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name)); -} - -// Loads straight into the returned buffer, which the library wipes after use. A failed load can -// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers -// this buffer: the preference backend keeps its own copy of a pending write until it syncs. -std::optional> read_blob(ESPPreferenceObject &pref, size_t size) { - std::vector out(size); - if (!pref.load(out.data(), out.size())) { - secure_wipe(out.data(), out.size()); - return std::nullopt; - } - return out; -} - -// The library always writes a key's fixed size; any other length is misuse and is not stored. -bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) { - if (len != size) { - ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size); - return false; - } - if (!pref.save(data, len)) { - ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len); - return false; - } - return true; -} - -// "rec_" to n; nullopt for any other key or a slot with no storage here. -std::optional parse_record_slot(const std::string &key) { - const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX; - const size_t prefix_len = std::strlen(prefix); - if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) { - return std::nullopt; - } - size_t slot = 0; - for (size_t i = prefix_len; i < key.size(); i++) { - const char c = key[i]; - if (c < '0' || c > '9') { - return std::nullopt; - } - slot = slot * 10 + static_cast(c - '0'); - if (slot >= SENDSPIN_RECORD_SLOTS) { - return std::nullopt; - } - } - return slot; -} - -} // namespace - #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; From 9adb7e92e1fa84c1f6215bdb842ec860ebe15d93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:26 -1000 Subject: [PATCH 107/435] [ethernet] Collapse duplicate CI test variants (#20406) --- .../ethernet/common-w5500-spi-id.yaml | 17 -------- .../ethernet/test-ip101.esp32-idf.yaml | 7 +++- .../ethernet/test-ipv6.esp32-idf.yaml | 12 ------ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ------------------- .../ethernet/test-w5500-spi-id.esp32-idf.yaml | 3 -- .../test-w5500-wifi-psram.esp32-s3-idf.yaml | 15 ------- .../ethernet/test-w5500.esp32-idf.yaml | 37 +++++++++++++++--- .../ethernet/test.esp32-s3-idf.yaml | 16 +++++++- ...aml => validate-ksz8081rna.esp32-idf.yaml} | 0 ...date-lan8720-with-expander.esp32-idf.yaml} | 0 .../validate-w5500-interface.esp32-idf.yaml | 12 ++++++ 11 files changed, 65 insertions(+), 93 deletions(-) delete mode 100644 tests/components/ethernet/common-w5500-spi-id.yaml delete mode 100644 tests/components/ethernet/test-ipv6.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml rename tests/components/ethernet/{test-ksz8081rna.esp32-idf.yaml => validate-ksz8081rna.esp32-idf.yaml} (100%) rename tests/components/ethernet/{test-lan8720-with-expander.esp32-idf.yaml => validate-lan8720-with-expander.esp32-idf.yaml} (100%) create mode 100644 tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml diff --git a/tests/components/ethernet/common-w5500-spi-id.yaml b/tests/components/ethernet/common-w5500-spi-id.yaml deleted file mode 100644 index 14c01842f0c..00000000000 --- a/tests/components/ethernet/common-w5500-spi-id.yaml +++ /dev/null @@ -1,17 +0,0 @@ -ethernet: - type: W5500 - spi_id: spi_bus - cs_pin: 5 - interrupt_pin: 36 - reset_pin: 22 - clock_speed: 10Mhz - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - domain: .local - mac_address: "02:AA:BB:CC:DD:01" - on_connect: - - logger.log: "Ethernet connected!" - on_disconnect: - - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ip101.esp32-idf.yaml b/tests/components/ethernet/test-ip101.esp32-idf.yaml index e52329d7ea2..628b63d4e97 100644 --- a/tests/components/ethernet/test-ip101.esp32-idf.yaml +++ b/tests/components/ethernet/test-ip101.esp32-idf.yaml @@ -1 +1,6 @@ -<<: !include common-ip101.yaml +packages: + ethernet: !include common-ip101.yaml + +# IPv6 on the internal EMAC: compiles the IPv6 event handler and multicast filter +network: + enable_ipv6: true diff --git a/tests/components/ethernet/test-ipv6.esp32-idf.yaml b/tests/components/ethernet/test-ipv6.esp32-idf.yaml deleted file mode 100644 index 607e82dfa94..00000000000 --- a/tests/components/ethernet/test-ipv6.esp32-idf.yaml +++ /dev/null @@ -1,12 +0,0 @@ -network: - enable_ipv6: true - -ethernet: - type: IP101 - mdc_pin: 23 - mdio_pin: 18 - clk: - pin: 0 - mode: CLK_EXT_IN - phy_addr: 1 - power_pin: 5 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml deleted file mode 100644 index 904a9167896..00000000000 --- a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml +++ /dev/null @@ -1,39 +0,0 @@ -ethernet: - id: eth - type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 - interrupt_pin: 36 - reset_pin: 22 - enable_on_boot: false - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 - -esphome: - on_boot: - priority: 200 - then: - - if: - condition: - not: - ethernet.enabled: - then: - - ethernet.enable: - -button: - - platform: template - name: "Disable Ethernet" - on_press: - - ethernet.disable: - -binary_sensor: - - platform: template - name: "Ethernet Connected" - lambda: |- - return id(eth).is_connected(); diff --git a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml deleted file mode 100644 index 16a25ced2a9..00000000000 --- a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - spi: !include ../../test_build_components/common/spi/esp32-idf.yaml - ethernet: !include common-w5500-spi-id.yaml diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml deleted file mode 100644 index d454dbadc39..00000000000 --- a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM -packages: - ethernet: !include common-w5500.yaml - -psram: - mode: quad - -wifi: - ssid: MySSID - password: password1 - -network: - priority: - - ethernet - - wifi diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index f1551fef603..c99dfaf5281 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -1,20 +1,47 @@ +# W5500 on a shared SPI bus, started from an automation instead of on boot. +# Covers the spi_id attach path and the enable/disable lifecycle in one build. +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + ethernet: + id: eth type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 + spi_id: spi_bus + cs_pin: 5 interrupt_pin: 36 reset_pin: 22 clock_speed: 10Mhz + enable_on_boot: false manual_ip: static_ip: 192.168.178.56 gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 on_connect: - logger.log: "Ethernet connected!" on_disconnect: - logger.log: "Ethernet disconnected!" + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected(); diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml index 36f1b5365f1..d454dbadc39 100644 --- a/tests/components/ethernet/test.esp32-s3-idf.yaml +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -1 +1,15 @@ -<<: !include common-w5500.yaml +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml b/tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml rename to tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml rename to tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml diff --git a/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml new file mode 100644 index 00000000000..a2c62e4a526 --- /dev/null +++ b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml @@ -0,0 +1,12 @@ +# Config only: a user supplied SPI interface on dedicated pins exercises the +# interface option in validation. +ethernet: + type: W5500 + interface: spi3 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 17 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 12 + clock_speed: 10Mhz From de5c2cda09fad57513974c783d7d59c84a66681d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:42 -1000 Subject: [PATCH 108/435] [mcp23xxx_base] Keep the interrupt handler in IRAM on ESP8266 (#20409) --- esphome/components/mcp23xxx_base/mcp23xxx_base.cpp | 2 ++ esphome/components/mcp23xxx_base/mcp23xxx_base.h | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index b8032fcec8c..8ad2f7521e5 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -4,6 +4,8 @@ namespace esphome::mcp23xxx_base { +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); } + template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 1c45b0f4afa..7414677175e 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -8,6 +8,10 @@ namespace esphome::mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; +// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the +// ISR in flash. +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg); + template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { public: virtual void pin_mode(uint8_t pin, gpio::Flags flags); @@ -35,7 +39,8 @@ template class MCP23XXXBase : public Component, public gpio_expander: void setup_interrupt_pin_() { if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast(this), + gpio::INTERRUPT_FALLING_EDGE); this->set_invalidate_on_read_(false); } // Disable loop until an input pin is configured via pin_mode() @@ -43,7 +48,6 @@ template class MCP23XXXBase : public Component, public gpio_expander: // For polling mode, loop is re-enabled when pin_mode() registers an input pin this->disable_loop(); } - static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); } // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; From 65e8345ab8d50cb5ab37ec46d31adb6b3815b693 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:32:07 -1000 Subject: [PATCH 109/435] [ota] Collapse duplicate CI test variants (#20407) --- tests/components/ota/common.yaml | 5 ----- tests/components/ota/test-api_key_offer.esp8266-ard.yaml | 2 -- .../components/ota/test-api_runtime_key.esp8266-ard.yaml | 2 -- .../components/ota/test-partition_access.esp32-idf.yaml | 5 ----- tests/components/ota/test.bk72xx-ard.yaml | 4 +++- tests/components/ota/test.esp32-ard.yaml | 4 +++- tests/components/ota/test.esp32-idf.yaml | 9 ++++++++- tests/components/ota/test.esp8266-ard.yaml | 4 +++- tests/components/ota/test.host.yaml | 6 ++---- tests/components/ota/test.rp2040-ard.yaml | 4 +++- ...ard.yaml => validate-empty_password.esp8266-ard.yaml} | 0 ...yaml => validate-encryption_inherit.esp8266-ard.yaml} | 0 tests/components/ota/wifi.yaml | 3 +++ 13 files changed, 25 insertions(+), 23 deletions(-) delete mode 100644 tests/components/ota/test-api_key_offer.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-api_runtime_key.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml rename tests/components/ota/{test-empty_password.esp8266-ard.yaml => validate-empty_password.esp8266-ard.yaml} (100%) rename tests/components/ota/{test-encryption_inherit.esp8266-ard.yaml => validate-encryption_inherit.esp8266-ard.yaml} (100%) create mode 100644 tests/components/ota/wifi.yaml diff --git a/tests/components/ota/common.yaml b/tests/components/ota/common.yaml index 1433dada1ff..af8cf66708d 100644 --- a/tests/components/ota/common.yaml +++ b/tests/components/ota/common.yaml @@ -1,11 +1,6 @@ -wifi: - ssid: MySSID - password: password1 - ota: - platform: esphome password: "superlongpasswordthatnoonewillknow" - port: 3286 on_begin: then: - logger.log: "OTA start" diff --git a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml b/tests/components/ota/test-api_key_offer.esp8266-ard.yaml deleted file mode 100644 index ecda6255219..00000000000 --- a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_key_offer.yaml diff --git a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml b/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml deleted file mode 100644 index 4709a9e45ce..00000000000 --- a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_runtime_key.yaml diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml deleted file mode 100644 index 0cbf8549520..00000000000 --- a/tests/components/ota/test-partition_access.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -ota: - - platform: esphome - allow_partition_access: true - -<<: !include common.yaml diff --git a/tests/components/ota/test.bk72xx-ard.yaml b/tests/components/ota/test.bk72xx-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.bk72xx-ard.yaml +++ b/tests/components/ota/test.bk72xx-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-ard.yaml b/tests/components/ota/test.esp32-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.esp32-ard.yaml +++ b/tests/components/ota/test.esp32-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index dade44d145b..7f25fafadbb 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1 +1,8 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml + +# Final validation merges this into the common block +ota: + - platform: esphome + allow_partition_access: true diff --git a/tests/components/ota/test.esp8266-ard.yaml b/tests/components/ota/test.esp8266-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.esp8266-ard.yaml +++ b/tests/components/ota/test.esp8266-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.host.yaml b/tests/components/ota/test.host.yaml index ae7c4d0add7..fdfbf47139a 100644 --- a/tests/components/ota/test.host.yaml +++ b/tests/components/ota/test.host.yaml @@ -1,4 +1,2 @@ -<<: !include common.yaml - -#host platform does not support wifi / network is automatically included -wifi: !remove +packages: + ota: !include common.yaml diff --git a/tests/components/ota/test.rp2040-ard.yaml b/tests/components/ota/test.rp2040-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.rp2040-ard.yaml +++ b/tests/components/ota/test.rp2040-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test-empty_password.esp8266-ard.yaml b/tests/components/ota/validate-empty_password.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-empty_password.esp8266-ard.yaml rename to tests/components/ota/validate-empty_password.esp8266-ard.yaml diff --git a/tests/components/ota/test-encryption_inherit.esp8266-ard.yaml b/tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-encryption_inherit.esp8266-ard.yaml rename to tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml diff --git a/tests/components/ota/wifi.yaml b/tests/components/ota/wifi.yaml new file mode 100644 index 00000000000..ac662302fc7 --- /dev/null +++ b/tests/components/ota/wifi.yaml @@ -0,0 +1,3 @@ +wifi: + ssid: MySSID + password: password1 From 6f4d0e948870e4505807392393b7136793c57d88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:32:33 -1000 Subject: [PATCH 110/435] [debug] Collapse duplicate CI test variants (#20404) --- tests/components/debug/test.bk72xx-ard.yaml | 3 ++- tests/components/debug/test.esp32-ard.yaml | 3 ++- tests/components/debug/test.esp32-idf.yaml | 5 ++--- tests/components/debug/test.esp32-s2-idf.yaml | 8 -------- tests/components/debug/test.esp8266-ard.yaml | 3 ++- tests/components/debug/test.host.yaml | 3 ++- tests/components/debug/test.ln882x-ard.yaml | 6 ------ tests/components/debug/test.nrf52-adafruit.yaml | 3 ++- tests/components/debug/test.nrf52-mcumgr.yaml | 3 ++- tests/components/debug/test.nrf52-xiao-ble.yaml | 1 - tests/components/debug/test.rp2040-ard.yaml | 3 ++- tests/components/debug/test.rtl87xx-ard.yaml | 6 ------ 12 files changed, 16 insertions(+), 31 deletions(-) delete mode 100644 tests/components/debug/test.esp32-s2-idf.yaml delete mode 100644 tests/components/debug/test.ln882x-ard.yaml delete mode 100644 tests/components/debug/test.nrf52-xiao-ble.yaml delete mode 100644 tests/components/debug/test.rtl87xx-ard.yaml diff --git a/tests/components/debug/test.bk72xx-ard.yaml b/tests/components/debug/test.bk72xx-ard.yaml index fdae374788f..65320a47d63 100644 --- a/tests/components/debug/test.bk72xx-ard.yaml +++ b/tests/components/debug/test.bk72xx-ard.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml sensor: - platform: debug diff --git a/tests/components/debug/test.esp32-ard.yaml b/tests/components/debug/test.esp32-ard.yaml index 8f93b0925eb..cdb1615be14 100644 --- a/tests/components/debug/test.esp32-ard.yaml +++ b/tests/components/debug/test.esp32-ard.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml esp32: cpu_frequency: 240MHz diff --git a/tests/components/debug/test.esp32-idf.yaml b/tests/components/debug/test.esp32-idf.yaml index 6a9996ad065..73bc97b2a96 100644 --- a/tests/components/debug/test.esp32-idf.yaml +++ b/tests/components/debug/test.esp32-idf.yaml @@ -1,12 +1,11 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml esp32: cpu_frequency: 240MHz sensor: - platform: debug - free: - name: "Heap Free" psram: name: "Free PSRAM" fragmentation: diff --git a/tests/components/debug/test.esp32-s2-idf.yaml b/tests/components/debug/test.esp32-s2-idf.yaml deleted file mode 100644 index 80919b0bab8..00000000000 --- a/tests/components/debug/test.esp32-s2-idf.yaml +++ /dev/null @@ -1,8 +0,0 @@ -<<: !include common.yaml - -sensor: - - platform: debug - fragmentation: - name: "Heap Fragmentation" - min_free: - name: "Heap Min Free" diff --git a/tests/components/debug/test.esp8266-ard.yaml b/tests/components/debug/test.esp8266-ard.yaml index 1398087bf06..da54acfc0dd 100644 --- a/tests/components/debug/test.esp8266-ard.yaml +++ b/tests/components/debug/test.esp8266-ard.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml sensor: - platform: debug diff --git a/tests/components/debug/test.host.yaml b/tests/components/debug/test.host.yaml index dade44d145b..25c05c6f819 100644 --- a/tests/components/debug/test.host.yaml +++ b/tests/components/debug/test.host.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml diff --git a/tests/components/debug/test.ln882x-ard.yaml b/tests/components/debug/test.ln882x-ard.yaml deleted file mode 100644 index fdae374788f..00000000000 --- a/tests/components/debug/test.ln882x-ard.yaml +++ /dev/null @@ -1,6 +0,0 @@ -<<: !include common.yaml - -sensor: - - platform: debug - min_free: - name: "Heap Min Free" diff --git a/tests/components/debug/test.nrf52-adafruit.yaml b/tests/components/debug/test.nrf52-adafruit.yaml index 6a446634afa..53eea184f58 100644 --- a/tests/components/debug/test.nrf52-adafruit.yaml +++ b/tests/components/debug/test.nrf52-adafruit.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml nrf52: reg0: diff --git a/tests/components/debug/test.nrf52-mcumgr.yaml b/tests/components/debug/test.nrf52-mcumgr.yaml index dade44d145b..25c05c6f819 100644 --- a/tests/components/debug/test.nrf52-mcumgr.yaml +++ b/tests/components/debug/test.nrf52-mcumgr.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml diff --git a/tests/components/debug/test.nrf52-xiao-ble.yaml b/tests/components/debug/test.nrf52-xiao-ble.yaml deleted file mode 100644 index dade44d145b..00000000000 --- a/tests/components/debug/test.nrf52-xiao-ble.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common.yaml diff --git a/tests/components/debug/test.rp2040-ard.yaml b/tests/components/debug/test.rp2040-ard.yaml index dade44d145b..25c05c6f819 100644 --- a/tests/components/debug/test.rp2040-ard.yaml +++ b/tests/components/debug/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml diff --git a/tests/components/debug/test.rtl87xx-ard.yaml b/tests/components/debug/test.rtl87xx-ard.yaml deleted file mode 100644 index fdae374788f..00000000000 --- a/tests/components/debug/test.rtl87xx-ard.yaml +++ /dev/null @@ -1,6 +0,0 @@ -<<: !include common.yaml - -sensor: - - platform: debug - min_free: - name: "Heap Min Free" From a0f0949078aac9906ee18de9171ccd561f9ce2fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:42:00 -1000 Subject: [PATCH 111/435] [mdns] Collapse duplicate CI test variants (#20403) --- .../{common-enabled-ethernet.yaml => common-ethernet.yaml} | 1 - tests/components/mdns/{common-enabled.yaml => common.yaml} | 1 - tests/components/mdns/test-disabled.esp32-idf.yaml | 6 ------ .../components/mdns/test-enabled-ethernet.rp2040-ard.yaml | 1 - tests/components/mdns/test-enabled.esp32-idf.yaml | 1 - tests/components/mdns/test-enabled.esp8266-ard.yaml | 1 - tests/components/mdns/test-enabled.rp2040-ard.yaml | 1 - tests/components/mdns/test-ethernet.rp2040-ard.yaml | 2 ++ tests/components/mdns/test.esp32-idf.yaml | 2 ++ tests/components/mdns/test.esp8266-ard.yaml | 2 ++ tests/components/mdns/test.rp2040-ard.yaml | 2 ++ tests/components/mdns/validate-disabled.esp32-idf.yaml | 7 +++++++ 12 files changed, 15 insertions(+), 12 deletions(-) rename tests/components/mdns/{common-enabled-ethernet.yaml => common-ethernet.yaml} (95%) rename tests/components/mdns/{common-enabled.yaml => common.yaml} (92%) delete mode 100644 tests/components/mdns/test-disabled.esp32-idf.yaml delete mode 100644 tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml delete mode 100644 tests/components/mdns/test-enabled.esp32-idf.yaml delete mode 100644 tests/components/mdns/test-enabled.esp8266-ard.yaml delete mode 100644 tests/components/mdns/test-enabled.rp2040-ard.yaml create mode 100644 tests/components/mdns/test-ethernet.rp2040-ard.yaml create mode 100644 tests/components/mdns/test.esp32-idf.yaml create mode 100644 tests/components/mdns/test.esp8266-ard.yaml create mode 100644 tests/components/mdns/test.rp2040-ard.yaml create mode 100644 tests/components/mdns/validate-disabled.esp32-idf.yaml diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-ethernet.yaml similarity index 95% rename from tests/components/mdns/common-enabled-ethernet.yaml rename to tests/components/mdns/common-ethernet.yaml index bfa9321d436..95a5d0edc8c 100644 --- a/tests/components/mdns/common-enabled-ethernet.yaml +++ b/tests/components/mdns/common-ethernet.yaml @@ -14,7 +14,6 @@ ethernet: mac_address: "02:AA:BB:CC:DD:01" mdns: - disabled: false services: - service: _test_service protocol: _tcp diff --git a/tests/components/mdns/common-enabled.yaml b/tests/components/mdns/common.yaml similarity index 92% rename from tests/components/mdns/common-enabled.yaml rename to tests/components/mdns/common.yaml index 8b3d81cf69d..8d0154fe9d2 100644 --- a/tests/components/mdns/common-enabled.yaml +++ b/tests/components/mdns/common.yaml @@ -3,7 +3,6 @@ wifi: password: password1 mdns: - disabled: false services: - service: _test_service protocol: _tcp diff --git a/tests/components/mdns/test-disabled.esp32-idf.yaml b/tests/components/mdns/test-disabled.esp32-idf.yaml deleted file mode 100644 index 07c70bf2486..00000000000 --- a/tests/components/mdns/test-disabled.esp32-idf.yaml +++ /dev/null @@ -1,6 +0,0 @@ -wifi: - ssid: MySSID - password: password1 - -mdns: - disabled: true diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml deleted file mode 100644 index f84a0bc276b..00000000000 --- a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled-ethernet.yaml diff --git a/tests/components/mdns/test-enabled.esp32-idf.yaml b/tests/components/mdns/test-enabled.esp32-idf.yaml deleted file mode 100644 index 97fd63d70e3..00000000000 --- a/tests/components/mdns/test-enabled.esp32-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled.yaml diff --git a/tests/components/mdns/test-enabled.esp8266-ard.yaml b/tests/components/mdns/test-enabled.esp8266-ard.yaml deleted file mode 100644 index 97fd63d70e3..00000000000 --- a/tests/components/mdns/test-enabled.esp8266-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled.yaml diff --git a/tests/components/mdns/test-enabled.rp2040-ard.yaml b/tests/components/mdns/test-enabled.rp2040-ard.yaml deleted file mode 100644 index 97fd63d70e3..00000000000 --- a/tests/components/mdns/test-enabled.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled.yaml diff --git a/tests/components/mdns/test-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-ethernet.rp2040-ard.yaml new file mode 100644 index 00000000000..4c2dfce1e3b --- /dev/null +++ b/tests/components/mdns/test-ethernet.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common-ethernet.yaml diff --git a/tests/components/mdns/test.esp32-idf.yaml b/tests/components/mdns/test.esp32-idf.yaml new file mode 100644 index 00000000000..c5e8cf7aeaf --- /dev/null +++ b/tests/components/mdns/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common.yaml diff --git a/tests/components/mdns/test.esp8266-ard.yaml b/tests/components/mdns/test.esp8266-ard.yaml new file mode 100644 index 00000000000..c5e8cf7aeaf --- /dev/null +++ b/tests/components/mdns/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common.yaml diff --git a/tests/components/mdns/test.rp2040-ard.yaml b/tests/components/mdns/test.rp2040-ard.yaml new file mode 100644 index 00000000000..c5e8cf7aeaf --- /dev/null +++ b/tests/components/mdns/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common.yaml diff --git a/tests/components/mdns/validate-disabled.esp32-idf.yaml b/tests/components/mdns/validate-disabled.esp32-idf.yaml new file mode 100644 index 00000000000..7d76bf4de97 --- /dev/null +++ b/tests/components/mdns/validate-disabled.esp32-idf.yaml @@ -0,0 +1,7 @@ +# Disabling mdns compiles no mdns code, so config validation is enough. +wifi: + ssid: MySSID + password: password1 + +mdns: + disabled: true From d9b9ce2909354efe1c60093d536e0378f7d5180c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:07:56 -1000 Subject: [PATCH 112/435] [core] Use numeric scheduler ids instead of string names (ESP32 1/2) (#20380) --- .../airthings_wave_base/airthings_wave_base.cpp | 7 +++++-- esphome/components/demo/demo_cover.h | 6 ++++-- esphome/components/improv_ble/improv_ble_component.cpp | 10 +++++++--- .../pvvx_mithermometer/display/pvvx_display.cpp | 8 +++++--- esphome/components/sendspin/image/sendspin_image.cpp | 4 +++- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp index d67a2e4a0fa..f4dfaaee326 100644 --- a/esphome/components/airthings_wave_base/airthings_wave_base.cpp +++ b/esphome/components/airthings_wave_base/airthings_wave_base.cpp @@ -10,6 +10,9 @@ namespace esphome::airthings_wave_base { ESPHOME_LOG_TAG(TAG, "airthings_wave_base"); +static constexpr uint32_t BATTERY_READ_TIMEOUT_ID = 0; +static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1; + void AirthingsWaveBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { switch (event) { @@ -181,7 +184,7 @@ void AirthingsWaveBase::read_battery_(uint8_t *raw_value, uint16_t value_len) { // read the battery again at the configured update interval if (this->battery_update_interval_ != this->update_interval_) { this->read_battery_next_update_ = false; - this->set_timeout("battery", this->battery_update_interval_, + this->set_timeout(BATTERY_READ_TIMEOUT_ID, this->battery_update_interval_, [this]() { this->read_battery_next_update_ = true; }); } } @@ -204,7 +207,7 @@ void AirthingsWaveBase::response_received_() { } void AirthingsWaveBase::set_response_timeout_() { - this->set_timeout("response_timeout", 30 * 1000, [this]() { + this->set_timeout(RESPONSE_TIMEOUT_ID, 30 * 1000, [this]() { this->responses_pending_ = 1; this->response_received_(); }); diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index aa12c885f43..eeb188a4631 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -36,6 +36,8 @@ class DemoCover final : public cover::Cover, public Component { } protected: + static constexpr uint32_t MOVE_TIMEOUT_ID = 0; + void control(const cover::CoverCall &call) override { auto pos = call.get_position(); if (pos.has_value()) { @@ -43,7 +45,7 @@ class DemoCover final : public cover::Cover, public Component { this->current_operation = target > this->position ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING; - this->set_timeout("move", 2000, [this, target]() { + this->set_timeout(MOVE_TIMEOUT_ID, 2000, [this, target]() { this->current_operation = cover::COVER_OPERATION_IDLE; this->position = target; this->publish_state(); @@ -54,7 +56,7 @@ class DemoCover final : public cover::Cover, public Component { this->tilt = *tilt; } if (call.get_stop()) { - this->cancel_timeout("move"); + this->cancel_timeout(MOVE_TIMEOUT_ID); } this->publish_state(); diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 15adc497764..2029058899f 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -21,6 +21,10 @@ namespace esphome::improv_ble { using namespace bytebuffer; ESPHOME_LOG_TAG(TAG, "improv_ble.component"); + +static constexpr uint32_t STOP_SERVICE_TIMEOUT_ID = 0; +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 1; + static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte @@ -322,7 +326,7 @@ void ImprovBLEComponent::stop() { // Wait before stopping the service to ensure all BLE clients see the state change. // This prevents clients from repeatedly reconnecting and wasting resources by allowing // them to observe that the device is provisioned before the service disappears. - this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { + this->set_timeout(STOP_SERVICE_TIMEOUT_ID, STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; // Release first so removing the service UUID does not restart advertising on the way out @@ -398,7 +402,7 @@ void ImprovBLEComponent::process_incoming_data_() { ESP_LOGD(TAG, "Received Improv Wi-Fi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", 30000, [this]() { this->on_wifi_connect_timeout_(); }); + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, 30000, [this]() { this->on_wifi_connect_timeout_(); }); this->incoming_data_.clear(); break; } @@ -438,7 +442,7 @@ void ImprovBLEComponent::check_wifi_connection_() { if (this->state_ == improv::STATE_PROVISIONING) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); // Build the URL list directly into a stack buffer with no heap allocation std::array buf; diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 57876f8c074..995c4aff50d 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -9,6 +9,8 @@ namespace esphome::pvvx_mithermometer { ESPHOME_LOG_TAG(TAG, "display.pvvx_mithermometer"); +static constexpr uint32_t DISCONNECT_TIMEOUT_ID = 0; + void PVVXDisplay::dump_config() { char service_buf[ble_device_base::UUID_STR_LEN]; char char_buf[ble_device_base::UUID_STR_LEN]; @@ -39,7 +41,7 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t case ESP_GATTC_DISCONNECT_EVT: ESP_LOGV(TAG, "[%s] Disconnected", this->parent_->address_str()); this->connection_established_ = false; - this->cancel_timeout("disconnect"); + this->cancel_timeout(DISCONNECT_TIMEOUT_ID); this->char_handle_ = 0; break; case ESP_GATTC_SEARCH_CMPL_EVT: { @@ -147,8 +149,8 @@ void PVVXDisplay::send_to_setup_char_(uint8_t *blk, size_t size) { void PVVXDisplay::delayed_disconnect_() { if (this->disconnect_delay_ms_ == 0) return; - this->cancel_timeout("disconnect"); - this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); + this->cancel_timeout(DISCONNECT_TIMEOUT_ID); + this->set_timeout(DISCONNECT_TIMEOUT_ID, this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } void PVVXDisplay::sync_time_and_display_() { diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 4787abc7601..a93c2eb4430 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -10,6 +10,8 @@ namespace esphome::sendspin_ { ESPHOME_LOG_TAG(TAG, "sendspin.image"); +static constexpr uint32_t TRANSITION_ACK_TIMEOUT_ID = 0; + // How long a displayed frame may wait for sendspin.image.transition_finished before a warning // names the missing ack. Generous next to a typical fade of a second or two. static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000; @@ -163,7 +165,7 @@ void SendspinImageSlot::on_display_(uint32_t lateness_ms) { // that never reaches the action stalls the slot with nothing in the log. Name the cause after // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame // that was already acked and superseded. - this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + this->set_timeout(TRANSITION_ACK_TIMEOUT_ID, TRANSITION_ACK_WARNING_MS, [this]() { if (this->transition_pending_) { ESP_LOGW(TAG, "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " From 466081acc16c6885327b1547a7ea1adcf082e016 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:09:04 -1000 Subject: [PATCH 113/435] [core] Use numeric scheduler ids instead of string names (ESP32 2/2) (#20381) --- .../media_player/speaker_media_player.cpp | 30 +++++++++++-------- .../voice_assistant/voice_assistant.cpp | 27 ++++++++++------- .../xiaomi_mue4094rt/xiaomi_mue4094rt.cpp | 4 ++- .../xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp | 7 +++-- .../zigbee/time/zigbee_time_esp32.cpp | 7 +++-- esphome/components/zigbee/zigbee_esp32.cpp | 11 ++++--- 6 files changed, 54 insertions(+), 32 deletions(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index e4aa78d10db..8ab4128e927 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -52,6 +52,11 @@ static const UBaseType_t ANNOUNCEMENT_PIPELINE_TASK_PRIORITY = 1; ESPHOME_LOG_TAG(TAG, "speaker_media_player"); +static constexpr uint32_t UNPAUSE_MEDIA_INTERVAL_ID = 0; +static constexpr uint32_t NEXT_ANNOUNCEMENT_TIMEOUT_ID = 1; +static constexpr uint32_t NEXT_MEDIA_TIMEOUT_ID = 2; +static constexpr uint32_t UNPAUSE_ANNOUNCEMENT_INTERVAL_ID = 3; + void SpeakerMediaPlayer::setup() { #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF state = media_player::MEDIA_PLAYER_STATE_OFF; @@ -112,13 +117,13 @@ void SpeakerMediaPlayer::set_playlist_delay_ms(AudioPipelineType pipeline_type, void SpeakerMediaPlayer::stop_and_unpause_media_() { this->media_pipeline_->stop(); this->unpause_media_remaining_ = 3; - this->set_interval("unpause_med", 50, [this]() { + this->set_interval(UNPAUSE_MEDIA_INTERVAL_ID, 50, [this]() { if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { - this->cancel_interval("unpause_med"); + this->cancel_interval(UNPAUSE_MEDIA_INTERVAL_ID); this->media_pipeline_->set_pause_state(false); this->is_paused_ = false; } else if (--this->unpause_media_remaining_ == 0) { - this->cancel_interval("unpause_med"); + this->cancel_interval(UNPAUSE_MEDIA_INTERVAL_ID); } }); } @@ -152,7 +157,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { if (!enqueue) { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause - this->cancel_timeout("next_ann"); + this->cancel_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID); this->announcement_playlist_.clear(); this->announcement_item_failed_ = false; if (media_command.file.has_value()) { @@ -166,7 +171,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { } else { if (!enqueue) { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause - this->cancel_timeout("next_media"); + this->cancel_timeout(NEXT_MEDIA_TIMEOUT_ID); this->media_playlist_.clear(); this->media_item_failed_ = false; if (this->is_paused_) { @@ -236,23 +241,23 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { #endif if (this->announcement_pipeline_ != nullptr) { - this->cancel_timeout("next_ann"); + this->cancel_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID); this->announcement_playlist_.clear(); this->announcement_item_failed_ = false; this->announcement_pipeline_->stop(); this->unpause_announcement_remaining_ = 3; - this->set_interval("unpause_ann", 50, [this]() { + this->set_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID, 50, [this]() { if (this->announcement_pipeline_state_ == AudioPipelineState::STOPPED) { - this->cancel_interval("unpause_ann"); + this->cancel_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID); this->announcement_pipeline_->set_pause_state(false); } else if (--this->unpause_announcement_remaining_ == 0) { - this->cancel_interval("unpause_ann"); + this->cancel_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID); } }); } } else { if (this->media_pipeline_ != nullptr) { - this->cancel_timeout("next_media"); + this->cancel_timeout(NEXT_MEDIA_TIMEOUT_ID); this->media_playlist_.clear(); this->media_item_failed_ = false; this->stop_and_unpause_media_(); @@ -408,7 +413,8 @@ void SpeakerMediaPlayer::loop() { this->announcement_pipeline_->set_pause_state(true); // Internally unpause the pipeline after the delay between playlist items. Announcements do not follow the // media player's pause state. - this->set_timeout("next_ann", timeout_ms, [this]() { this->announcement_pipeline_->set_pause_state(false); }); + this->set_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID, timeout_ms, + [this]() { this->announcement_pipeline_->set_pause_state(false); }); } } } else { @@ -454,7 +460,7 @@ void SpeakerMediaPlayer::loop() { this->media_pipeline_->set_pause_state(true); // Internally unpause the pipeline after the delay between playlist items, if the media player state is // not paused. - this->set_timeout("next_media", timeout_ms, + this->set_timeout(NEXT_MEDIA_TIMEOUT_ID, timeout_ms, [this]() { this->media_pipeline_->set_pause_state(this->is_paused_); }); } } diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index e9523084370..039410abdbf 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -14,6 +14,11 @@ namespace esphome::voice_assistant { ESPHOME_LOG_TAG(TAG, "voice_assistant"); +static constexpr uint32_t RESET_CONVERSATION_TIMEOUT_ID = 0; +static constexpr uint32_t PLAYBACK_TIMEOUT_ID = 1; +static constexpr uint32_t SPEAKER_IDLE_TIMEOUT_ID = 2; +static constexpr uint32_t TIMER_TICK_INTERVAL_ID = 3; + #ifdef SAMPLE_RATE_HZ #undef SAMPLE_RATE_HZ #endif @@ -370,7 +375,7 @@ void VoiceAssistant::loop() { break; } this->set_state_(State::STARTING_PIPELINE); - this->set_timeout("reset-conversation_id", this->conversation_timeout_, + this->set_timeout(RESET_CONVERSATION_TIMEOUT_ID, this->conversation_timeout_, [this]() { this->reset_conversation_id(); }); break; } @@ -460,10 +465,10 @@ void VoiceAssistant::loop() { if (this->speaker_bytes_received_ > RECEIVE_SIZE * 4 || end_of_stream) this->write_speaker_(); if (this->wait_for_stream_end_) { - this->cancel_timeout("playing"); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); if (end_of_stream) { ESP_LOGD(TAG, "End of audio stream received"); - this->cancel_timeout("speaker-timeout"); + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); } break; // We dont want to timeout here as the STREAM_END event will take care of that. @@ -477,7 +482,7 @@ void VoiceAssistant::loop() { if (this->media_player_response_state_ == MediaPlayerResponseState::FINISHED) { this->media_player_response_state_ = MediaPlayerResponseState::IDLE; - this->cancel_timeout("playing"); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); ESP_LOGD(TAG, "Announcement finished playing"); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); @@ -507,8 +512,8 @@ void VoiceAssistant::loop() { } ESP_LOGD(TAG, "Speaker has finished outputting all audio"); this->speaker_->stop(); - this->cancel_timeout("speaker-timeout"); - this->cancel_timeout("playing"); + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); this->clear_buffers_(); @@ -540,7 +545,7 @@ void VoiceAssistant::write_speaker_() { memmove(this->speaker_buffer_, this->speaker_buffer_ + written, this->speaker_buffer_size_ - written); this->speaker_buffer_size_ -= written; this->speaker_buffer_index_ -= written; - this->set_timeout("speaker-timeout", 5000, [this]() { this->speaker_->stop(); }); + this->set_timeout(SPEAKER_IDLE_TIMEOUT_ID, 5000, [this]() { this->speaker_->stop(); }); } else { ESP_LOGV(TAG, "Speaker buffer full, trying again next loop"); } @@ -755,8 +760,8 @@ void VoiceAssistant::signal_stop_() { } void VoiceAssistant::start_playback_timeout_() { - this->set_timeout("playing", 2000, [this]() { - this->cancel_timeout("speaker-timeout"); + this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() { + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); if (this->api_client_ == nullptr) @@ -1046,10 +1051,10 @@ void VoiceAssistant::on_timer_event(const api::VoiceAssistantTimerEventResponse } if (this->timers_.empty()) { - this->cancel_interval("timer-event"); + this->cancel_interval(TIMER_TICK_INTERVAL_ID); this->timer_tick_running_ = false; } else if (!this->timer_tick_running_) { - this->set_interval("timer-event", 1000, [this]() { this->timer_tick_(); }); + this->set_interval(TIMER_TICK_INTERVAL_ID, 1000, [this]() { this->timer_tick_(); }); this->timer_tick_running_ = true; } } diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index afd8715ec5a..683e8a2a927 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -5,6 +5,8 @@ namespace esphome::xiaomi_mue4094rt { ESPHOME_LOG_TAG(TAG, "xiaomi_mue4094rt"); +static constexpr uint32_t MOTION_TIMEOUT_ID = 0; + void XiaomiMUE4094RT::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MUE4094RT"); LOG_BINARY_SENSOR(" ", "Motion", this); @@ -40,7 +42,7 @@ bool XiaomiMUE4094RT::parse_device(const ble_device_base::ESPBTDevice &device) { } if (res->has_motion.has_value()) { this->publish_state(*res->has_motion); - this->set_timeout("motion_timeout", timeout_, [this]() { this->publish_state(false); }); + this->set_timeout(MOTION_TIMEOUT_ID, timeout_, [this]() { this->publish_state(false); }); } success = true; } diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index 8bf098a676f..f7ecf15c3bb 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -6,6 +6,9 @@ namespace esphome::xiaomi_rtcgq02lm { ESPHOME_LOG_TAG(TAG, "xiaomi_rtcgq02lm"); +static constexpr uint32_t MOTION_TIMEOUT_ID = 0; +static constexpr uint32_t BUTTON_TIMEOUT_ID = 1; + static constexpr size_t RTCGQ02LM_BINDKEY_SIZE = 16; void XiaomiRTCGQ02LM::dump_config() { @@ -55,13 +58,13 @@ bool XiaomiRTCGQ02LM::parse_device(const ble_device_base::ESPBTDevice &device) { #ifdef USE_BINARY_SENSOR if (res->has_motion.has_value() && this->motion_ != nullptr) { this->motion_->publish_state(*res->has_motion); - this->set_timeout("motion_timeout", this->motion_timeout_, [this]() { this->motion_->publish_state(false); }); + this->set_timeout(MOTION_TIMEOUT_ID, this->motion_timeout_, [this]() { this->motion_->publish_state(false); }); } if (res->is_light.has_value() && this->light_ != nullptr) this->light_->publish_state(*res->is_light); if (res->button_press.has_value() && this->button_ != nullptr) { this->button_->publish_state(*res->button_press); - this->set_timeout("button_timeout", this->button_timeout_, [this]() { this->button_->publish_state(false); }); + this->set_timeout(BUTTON_TIMEOUT_ID, this->button_timeout_, [this]() { this->button_->publish_state(false); }); } #endif #ifdef USE_SENSOR diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp index a6a25ef2f3e..1bc0923a1f7 100644 --- a/esphome/components/zigbee/time/zigbee_time_esp32.cpp +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -7,6 +7,9 @@ namespace esphome::zigbee { ESPHOME_LOG_TAG(TAG, "zigbee.time"); +static constexpr uint32_t REGISTER_RETRY_TIMEOUT_ID = 0; +static constexpr uint32_t SYNC_RETRY_TIMEOUT_ID = 1; + // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; @@ -29,7 +32,7 @@ void ZigbeeTime::register_zb_time_() { }; ezb_err_t ret; if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - this->set_timeout("zb_time_register", 100, [this]() { this->register_zb_time_(); }); + this->set_timeout(REGISTER_RETRY_TIMEOUT_ID, 100, [this]() { this->register_zb_time_(); }); return; } ret = ezb_zcl_time_server_interface_register(this->endpoint_, time_interface); @@ -69,7 +72,7 @@ void ZigbeeTime::update() { ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time, will retry maximum 3 times"); } if (this->retry_count_ < 3) { - this->set_timeout("zb_time_sync", 100, [this]() { this->update(); }); + this->set_timeout(SYNC_RETRY_TIMEOUT_ID, 100, [this]() { this->update(); }); this->retry_count_++; } else { ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time"); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 676407d751c..8b7b59acb0b 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -19,6 +19,8 @@ namespace esphome::zigbee { ESPHOME_LOG_TAG(TAG, "zigbee"); +static constexpr uint32_t COMMISSIONING_RETRY_TIMEOUT_ID = 0; + static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { @@ -49,7 +51,8 @@ void ZigbeeComponent::factory_reset() { void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, + [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); App.wake_loop_threadsafe(); return; } @@ -86,7 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { } } else { ESP_LOGW(TAG, "The %s failed with status(0x%02x), please retry", ezb_app_signal_to_string(signal_type), status); - global_zigbee->set_timeout("zb_init", 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); App.wake_loop_threadsafe(); @@ -107,11 +110,11 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); if (steering_retry_count < 10) { steering_retry_count++; - global_zigbee->set_timeout("zb_init", 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } else { - global_zigbee->set_timeout("zb_init", 600 * 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 600 * 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } From a2434f9aa3321feaf0e217375495efae31076a84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:09:33 -1000 Subject: [PATCH 114/435] [core] Document numeric scheduler ids in AGENTS.md (#20382) --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dc364e4e5c5..d5d33b32af6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,13 @@ file does, and it is the authority when they disagree. The most useful starting Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a + `static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other + components, so number them from 0 and keep all of a component's ids together in one place. + ```cpp + static constexpr uint32_t READ_TIMEOUT_ID = 0; + this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); }); + ``` * **Don't override a default with the same value:** if a base class method already returns what you want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a component that wants `DATA` should simply leave it alone. From bc6fecfadb6531183b32f446098940db2482ccd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:10:04 -1000 Subject: [PATCH 115/435] [core] Use numeric scheduler ids instead of string names (1/5) (#20374) --- esphome/components/aqi/aqi_sensor.cpp | 6 ++++-- esphome/components/bme280_base/bme280_base.cpp | 4 +++- esphome/components/bme680/bme680.cpp | 4 +++- esphome/components/bme680_bsec/bme680_bsec.cpp | 4 +++- esphome/components/bme68x_bsec2/bme68x_bsec2.cpp | 6 ++++-- esphome/components/bmp085/bmp085.cpp | 7 +++++-- esphome/components/bmp280_base/bmp280_base.cpp | 4 +++- esphome/components/bmp3xx_base/bmp3xx_base.cpp | 4 +++- esphome/components/bmp581_base/bmp581_base.cpp | 4 +++- esphome/components/combination/combination.cpp | 12 ++++++++---- 10 files changed, 39 insertions(+), 16 deletions(-) diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index d7fb120741b..c242ee39136 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -5,18 +5,20 @@ namespace esphome::aqi { ESPHOME_LOG_TAG(TAG, "aqi"); +static constexpr uint32_t CALCULATE_DEFER_ID = 0; + void AQISensor::setup() { if (this->pm_2_5_sensor_ != nullptr) { this->pm_2_5_sensor_->add_on_state_callback([this](float value) { this->pm_2_5_value_ = value; // Defer calculation to avoid double-publishing if both sensors update in the same loop - this->defer("update", [this]() { this->calculate_aqi_(); }); + this->defer(CALCULATE_DEFER_ID, [this]() { this->calculate_aqi_(); }); }); } if (this->pm_10_0_sensor_ != nullptr) { this->pm_10_0_sensor_->add_on_state_callback([this](float value) { this->pm_10_0_value_ = value; - this->defer("update", [this]() { this->calculate_aqi_(); }); + this->defer(CALCULATE_DEFER_ID, [this]() { this->calculate_aqi_(); }); }); } } diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 6cc011d76b5..96bb56f345e 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -13,6 +13,8 @@ namespace esphome::bme280_base { ESPHOME_LOG_TAG(TAG, "bme280.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const uint8_t BME280_REGISTER_DIG_T1 = 0x88; static const uint8_t BME280_REGISTER_DIG_T2 = 0x8A; static const uint8_t BME280_REGISTER_DIG_T3 = 0x8C; @@ -221,7 +223,7 @@ void BME280Component::update() { meas_time += 2.3f * oversampling_to_time(this->pressure_oversampling_) + 0.575f; meas_time += 2.3f * oversampling_to_time(this->humidity_oversampling_) + 0.575f; - this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ceilf(meas_time)), [this]() { uint8_t data[8]; if (!this->read_bytes(BME280_REGISTER_MEASUREMENTS, data, 8)) { ESP_LOGW(TAG, "Error reading registers"); diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 2ff7ca2b8cc..6bacb71ff20 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -6,6 +6,8 @@ namespace esphome::bme680 { ESPHOME_LOG_TAG(TAG, "bme680.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const uint8_t BME680_REGISTER_COEFF1 = 0x89; static const uint8_t BME680_REGISTER_COEFF2 = 0xE1; @@ -242,7 +244,7 @@ void BME680Component::update() { return; } - this->set_timeout("data", this->calc_meas_duration_(), [this]() { this->read_data_(); }); + this->set_timeout(DATA_TIMEOUT_ID, this->calc_meas_duration_(), [this]() { this->read_data_(); }); } uint8_t BME680Component::calc_heater_resistance_(uint16_t temperature) { diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 219f72d3dfc..3f0082e1af7 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -7,6 +7,8 @@ namespace esphome::bme680_bsec { #ifdef USE_BSEC ESPHOME_LOG_TAG(TAG, "bme680_bsec.sensor"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; std::vector @@ -259,7 +261,7 @@ void BME680BSECComponent::run_() { this->snapshot_state_(); ESP_LOGV(TAG, "Queueing read in %ums", meas_dur); - this->set_timeout("read", meas_dur, [this]() { this->read_(); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur, [this]() { this->read_(); }); } else { ESP_LOGV(TAG, "Measurement not required"); this->read_(); diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index 2b113c179fd..f5a674d6b92 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -15,6 +15,8 @@ namespace esphome::bme68x_bsec2 { ESPHOME_LOG_TAG(TAG, "bme68x_bsec2.sensor"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; static bool is_no_new_data_warning(int8_t status) { @@ -285,11 +287,11 @@ void BME68xBSEC2Component::run_() { uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout("read", meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); } else { ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout("read", 0, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { this->read_(this->trigger_time_ns_); }); } } diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index 4badfd95f5b..b989ed877b6 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -5,6 +5,9 @@ namespace esphome::bmp085 { ESPHOME_LOG_TAG(TAG, "bmp085.sensor"); +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 0; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 1; + static const uint8_t BMP085_ADDRESS = 0x77; static const uint8_t BMP085_REGISTER_AC1_H = 0xAA; static const uint8_t BMP085_REGISTER_CONTROL = 0xF4; @@ -16,7 +19,7 @@ void BMP085Component::update() { if (!this->set_mode_(BMP085_CONTROL_MODE_TEMPERATURE)) return; - this->set_timeout("temperature", 5, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 5, [this]() { this->read_temperature_(); }); } void BMP085Component::setup() { uint8_t data[22]; @@ -84,7 +87,7 @@ void BMP085Component::read_temperature_() { return; } - this->set_timeout("pressure", 26, [this]() { this->read_pressure_(); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 26, [this]() { this->read_pressure_(); }); } void BMP085Component::read_pressure_() { uint8_t buffer[3]; diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 88ff0985812..9cb023c6884 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -8,6 +8,8 @@ namespace esphome::bmp280_base { ESPHOME_LOG_TAG(TAG, "bmp280.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const uint8_t BMP280_REGISTER_STATUS = 0xF3; static const uint8_t BMP280_REGISTER_CONTROL = 0xF4; static const uint8_t BMP280_REGISTER_CONFIG = 0xF5; @@ -166,7 +168,7 @@ void BMP280Component::update() { meas_time += 2.3f * oversampling_to_time(this->temperature_oversampling_); meas_time += 2.3f * oversampling_to_time(this->pressure_oversampling_) + 0.575f; - this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ceilf(meas_time)), [this]() { int32_t t_fine = 0; float temperature = this->read_temperature_(&t_fine); if (std::isnan(temperature)) { diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index ba85a20e3d8..b10a7a7a951 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -15,6 +15,8 @@ namespace esphome::bmp3xx_base { ESPHOME_LOG_TAG(TAG, "bmp3xx.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const LogString *chip_type_to_str(uint8_t chip_type) { switch (chip_type) { case BMP388_ID: @@ -170,7 +172,7 @@ void BMP3XXComponent::update() { const uint32_t meas_timeout = uint32_t(ceilf(meas_time)); ESP_LOGVV(TAG, "measurement time %" PRIu32, meas_timeout); - this->set_timeout("data", meas_timeout, [this]() { + this->set_timeout(DATA_TIMEOUT_ID, meas_timeout, [this]() { float temperature = 0.0f; float pressure = 0.0f; if (this->pressure_sensor_ != nullptr) { diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 7d4761f5166..29f67fadc51 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -19,6 +19,8 @@ namespace esphome::bmp581_base { ESPHOME_LOG_TAG(TAG, "bmp581"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + // Oversampling strings indexed by Oversampling enum (0-7): NONE, X2, X4, X8, X16, X32, X64, X128 PROGMEM_STRING_TABLE(OversamplingStrings, "None", "2x", "4x", "8x", "16x", "32x", "64x", "128x", ""); @@ -272,7 +274,7 @@ void BMP581Component::update() { ESP_LOGVV(TAG, "Measurement should take %d ms", this->conversion_time_); - this->set_timeout("measurement", this->conversion_time_, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, this->conversion_time_, [this]() { float temperature = 0.0; float pressure = 0.0; diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index d10490a20ce..0488fbb6ab2 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -9,6 +9,8 @@ namespace esphome::combination { ESPHOME_LOG_TAG(TAG, "combination"); +static constexpr uint32_t NEW_VALUE_DEFER_ID = 0; + void CombinationComponent::log_config_(const LogString *combo_type) { LOG_SENSOR("", "Combination Sensor:", this); ESP_LOGCONFIG(TAG, " Combination Type: %s", LOG_STR_ARG(combo_type)); @@ -43,8 +45,9 @@ void CombinationNoParameterComponent::setup() { for (const auto &sensor : this->sensors_) { // All sensor updates are deferred until the next loop. This avoids publishing the combined sensor's result // repeatedly in the same loop if multiple source senors update. - sensor->add_on_state_callback( - [this](float value) -> void { this->defer("update", [this, value]() { this->handle_new_value(value); }); }); + sensor->add_on_state_callback([this](float value) -> void { + this->defer(NEW_VALUE_DEFER_ID, [this, value]() { this->handle_new_value(value); }); + }); } } @@ -119,8 +122,9 @@ void LinearCombinationComponent::setup() { for (auto &source : this->sensor_sources_) { // All sensor updates are deferred until the next loop. This avoids publishing the combined sensor's result // repeatedly in the same loop if multiple source senors update. - source.sensor->add_on_state_callback( - [this](float value) -> void { this->defer("update", [this, value]() { this->handle_new_value(value); }); }); + source.sensor->add_on_state_callback([this](float value) -> void { + this->defer(NEW_VALUE_DEFER_ID, [this, value]() { this->handle_new_value(value); }); + }); } } From 824834f847eb300b3e183b1723ba9947be6f940a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:10:19 -1000 Subject: [PATCH 116/435] [core] Use numeric scheduler ids instead of string names (3/5) (#20376) --- esphome/components/hyt271/hyt271.cpp | 4 +++- esphome/components/ld6002b/ld6002b.cpp | 7 ++++--- esphome/components/ld6002b/ld6002b.h | 8 ++++---- esphome/components/max31855/max31855.cpp | 4 +++- esphome/components/max31856/max31856.cpp | 4 +++- esphome/components/max31865/max31865.cpp | 4 +++- esphome/components/max6675/max6675.cpp | 4 +++- esphome/components/mics_4514/mics_4514.cpp | 4 +++- esphome/components/mlx90393/sensor_mlx90393.cpp | 7 +++++-- esphome/components/ms5611/ms5611.cpp | 7 +++++-- esphome/components/ms8607/ms8607.cpp | 17 ++++++++++++----- 11 files changed, 48 insertions(+), 22 deletions(-) diff --git a/esphome/components/hyt271/hyt271.cpp b/esphome/components/hyt271/hyt271.cpp index 72d3940f30a..a8374c4115e 100644 --- a/esphome/components/hyt271/hyt271.cpp +++ b/esphome/components/hyt271/hyt271.cpp @@ -6,6 +6,8 @@ namespace esphome::hyt271 { ESPHOME_LOG_TAG(TAG, "hyt271"); +static constexpr uint32_t WAIT_CONVERT_TIMEOUT_ID = 0; + static const uint8_t HYT271_ADDRESS = 0x28; void HYT271Component::dump_config() { @@ -23,7 +25,7 @@ void HYT271Component::update() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); return; } - this->set_timeout("wait_convert", 50, [this]() { + this->set_timeout(WAIT_CONVERT_TIMEOUT_ID, 50, [this]() { uint8_t raw_data[4]; if (this->read(raw_data, 4) != i2c::ERROR_OK) { this->status_set_warning(); diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index fdf1b15d06c..93a2ea341f4 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -620,7 +620,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ this->process_command_queue_(); if (refresh_areas) { this->area_write_in_flight_ = false; - this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + this->set_timeout(AREA_REFRESH_TIMEOUT_ID, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); } return; } @@ -1215,7 +1215,7 @@ void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data std::memcpy(this->wake_scratch_.data(), data, len); } // A button pulse must not raise the pin in the middle of this one. - this->cancel_timeout(WAKE_BUTTON_TIMEOUT); + this->cancel_timeout(WAKE_BUTTON_TIMEOUT_ID); this->wake_pulse_pending_ = true; this->wakeup_pin_->digital_write(false); const uint8_t generation = this->send_generation_; @@ -1364,7 +1364,8 @@ void LD6002BComponent::wake_() { if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) return; this->wakeup_pin_->digital_write(false); - this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); }); + this->set_timeout(WAKE_BUTTON_TIMEOUT_ID, this->wakeup_pulse_ms_, + [this]() { this->wakeup_pin_->digital_write(true); }); } void LD6002BComponent::set_number_value(NumberType type, float value) { diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index bea38043128..c52f770d6e9 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -413,12 +413,12 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // How long the module stays awake after any frame, and so still answers the next one. static constexpr uint32_t MODULE_AWAKE_MS = 10000; static constexpr uint8_t CMD_MAX_RETRIES = 3; - // Named so a repeated press replaces its own pending timeout instead of stacking + // Keyed so a repeated press replaces its own pending timeout instead of stacking // another, and so the command path can cancel it when it takes the pin over. - static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; - // Named so a burst of writes collapses to one read once they settle, rather than + static constexpr uint32_t WAKE_BUTTON_TIMEOUT_ID = 0; + // Keyed so a burst of writes collapses to one read once they settle, rather than // one read per write. - static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; + static constexpr uint32_t AREA_REFRESH_TIMEOUT_ID = 1; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; diff --git a/esphome/components/max31855/max31855.cpp b/esphome/components/max31855/max31855.cpp index 3d681e49233..4bef41761c5 100644 --- a/esphome/components/max31855/max31855.cpp +++ b/esphome/components/max31855/max31855.cpp @@ -7,6 +7,8 @@ namespace esphome::max31855 { ESPHOME_LOG_TAG(TAG, "max31855"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void MAX31855Sensor::update() { this->enable(); delay(1); @@ -14,7 +16,7 @@ void MAX31855Sensor::update() { this->disable(); // Conversion time typ: 170ms, max: 220ms - this->set_timeout("value", 220, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, 220, [this]() { this->read_data_(); }); } void MAX31855Sensor::setup() { this->spi_setup(); } diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index 5612a4cbf70..bdb47216daa 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -7,6 +7,8 @@ namespace esphome::max31856 { ESPHOME_LOG_TAG(TAG, "max31856"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + // Based on Adafruit's library: https://github.com/adafruit/Adafruit_MAX31856 void MAX31856Sensor::setup() { @@ -42,7 +44,7 @@ void MAX31856Sensor::update() { this->one_shot_temperature_(); // Datasheet max conversion time for 1 shot is 155ms for 60Hz / 185ms for 50Hz - this->set_timeout("MAX31856Sensor::read_thermocouple_temperature_", filter_ == FILTER_60HZ ? 155 : 185, + this->set_timeout(READ_TIMEOUT_ID, filter_ == FILTER_60HZ ? 155 : 185, [this]() { this->read_thermocouple_temperature_(); }); } diff --git a/esphome/components/max31865/max31865.cpp b/esphome/components/max31865/max31865.cpp index b637d6dbfc8..94899b05ab5 100644 --- a/esphome/components/max31865/max31865.cpp +++ b/esphome/components/max31865/max31865.cpp @@ -8,6 +8,8 @@ namespace esphome::max31865 { ESPHOME_LOG_TAG(TAG, "max31865"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void MAX31865Sensor::update() { // Check new faults since last measurement if (!has_fault_) { @@ -59,7 +61,7 @@ void MAX31865Sensor::update() { this->write_config_(0b11100000, 0b10100000); // Datasheet max conversion time is 55ms for 60Hz / 66ms for 50Hz - this->set_timeout("value", filter_ == FILTER_60HZ ? 55 : 66, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, filter_ == FILTER_60HZ ? 55 : 66, [this]() { this->read_data_(); }); } void MAX31865Sensor::setup() { diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index ad0c470e336..2fc8dbb73d7 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -5,6 +5,8 @@ namespace esphome::max6675 { ESPHOME_LOG_TAG(TAG, "max6675"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void MAX6675Sensor::update() { this->enable(); delay(1); @@ -12,7 +14,7 @@ void MAX6675Sensor::update() { this->disable(); // Conversion time typ: 170ms, max: 220ms - this->set_timeout("value", 250, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, 250, [this]() { this->read_data_(); }); } void MAX6675Sensor::setup() { this->spi_setup(); } diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index d4794b66826..957cdf90ef2 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -7,6 +7,8 @@ namespace esphome::mics_4514 { ESPHOME_LOG_TAG(TAG, "mics_4514"); +static constexpr uint32_t WARMUP_TIMEOUT_ID = 0; + static const uint8_t SENSOR_REGISTER = 0x04; static const uint8_t POWER_MODE_REGISTER = 0x0a; @@ -18,7 +20,7 @@ void MICS4514Component::setup() { power_mode = 0x01; this->write_register(POWER_MODE_REGISTER, &power_mode, 1); delay(100); // NOLINT - this->set_timeout("warmup", 3 * 60 * 1000, [this]() { this->warmed_up_ = true; }); + this->set_timeout(WARMUP_TIMEOUT_ID, 3 * 60 * 1000, [this]() { this->warmed_up_ = true; }); this->status_set_warning(); return; } diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 82d12a57898..e47b7d8c30b 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -7,6 +7,8 @@ namespace esphome::mlx90393 { ESPHOME_LOG_TAG(TAG, "mlx90393"); +static constexpr uint32_t VERIFY_SETTINGS_TIMEOUT_ID = 0; + const LogString *settings_to_string(MLX90393Setting setting) { switch (setting) { case MLX90393_GAIN_SEL: @@ -114,7 +116,7 @@ void MLX90393Cls::setup() { } // start verify settings process - this->set_timeout("verify settings", 3000, [this]() { this->verify_settings_timeout_(MLX90393_GAIN_SEL); }); + this->set_timeout(VERIFY_SETTINGS_TIMEOUT_ID, 3000, [this]() { this->verify_settings_timeout_(MLX90393_GAIN_SEL); }); } void MLX90393Cls::dump_config() { @@ -268,7 +270,8 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { next_stage = static_cast(0); } - this->set_timeout("verify settings", 3000, [this, next_stage]() { this->verify_settings_timeout_(next_stage); }); + this->set_timeout(VERIFY_SETTINGS_TIMEOUT_ID, 3000, + [this, next_stage]() { this->verify_settings_timeout_(next_stage); }); } } // namespace esphome::mlx90393 diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index 8b275e53f2e..8f66d704fe6 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -6,6 +6,9 @@ namespace esphome::ms5611 { ESPHOME_LOG_TAG(TAG, "ms5611"); +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 0; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 1; + static const uint8_t MS5611_ADDRESS = 0x77; static const uint8_t MS5611_CMD_ADC_READ = 0x00; static const uint8_t MS5611_CMD_RESET = 0x1E; @@ -44,7 +47,7 @@ void MS5611Component::update() { return; } - this->set_timeout("temperature", 10, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 10, [this]() { this->read_temperature_(); }); } void MS5611Component::read_temperature_() { uint8_t bytes[3]; @@ -60,7 +63,7 @@ void MS5611Component::read_temperature_() { return; } - this->set_timeout("pressure", 10, [this, raw_temperature]() { this->read_pressure_(raw_temperature); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 10, [this, raw_temperature]() { this->read_pressure_(raw_temperature); }); } void MS5611Component::read_pressure_(uint32_t raw_temperature) { uint8_t bytes[3]; diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index 832e7ab2d94..2e20c4e0ea5 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -9,6 +9,12 @@ namespace esphome::ms8607 { /// TAG used for logging calls ESPHOME_LOG_TAG(TAG, "ms8607"); +static constexpr uint32_t RESET_TIMEOUT_ID = 0; +static constexpr uint32_t PROM_READ_TIMEOUT_ID = 1; +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 2; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 3; +static constexpr uint32_t HUMIDITY_TIMEOUT_ID = 4; + /// Reset the Pressure/Temperature sensor static const uint8_t MS8607_PT_CMD_RESET = 0x1E; @@ -97,7 +103,7 @@ void MS8607Component::try_reset_() { if (--this->reset_attempts_remaining_ > 0) { uint32_t delay = this->reset_interval_; this->reset_interval_ *= 5; - this->set_timeout("reset", delay, [this]() { this->try_reset_(); }); + this->set_timeout(RESET_TIMEOUT_ID, delay, [this]() { this->try_reset_(); }); this->status_set_error(); } else { this->mark_failed(); @@ -110,7 +116,7 @@ void MS8607Component::try_reset_() { this->status_clear_error(); // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library - this->set_timeout("prom-read", 15, [this]() { + this->set_timeout(PROM_READ_TIMEOUT_ID, 15, [this]() { if (this->read_calibration_values_from_prom_()) { this->setup_status_ = SetupStatus::SUCCESSFUL; this->status_clear_error(); @@ -251,7 +257,7 @@ void MS8607Component::request_read_temperature_() { } // datasheet says 17.2ms max conversion time at OSR 8192 - this->set_timeout("temperature", 20, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 20, [this]() { this->read_temperature_(); }); } void MS8607Component::read_temperature_() { @@ -272,7 +278,8 @@ void MS8607Component::request_read_pressure_(uint32_t d2_raw_temperature) { } // datasheet says 17.2ms max conversion time at OSR 8192 - this->set_timeout("pressure", 20, [this, d2_raw_temperature]() { this->read_pressure_(d2_raw_temperature); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 20, + [this, d2_raw_temperature]() { this->read_pressure_(d2_raw_temperature); }); } void MS8607Component::read_pressure_(uint32_t d2_raw_temperature) { @@ -293,7 +300,7 @@ void MS8607Component::request_read_humidity_(float temperature_float) { } // datasheet says 15.89ms max conversion time at OSR 8192 - this->set_timeout("humidity", 20, [this, temperature_float]() { this->read_humidity_(temperature_float); }); + this->set_timeout(HUMIDITY_TIMEOUT_ID, 20, [this, temperature_float]() { this->read_humidity_(temperature_float); }); } void MS8607Component::read_humidity_(float temperature_float) { From 541562451e5494e81fba963fb96919003af4c9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:10:48 -1000 Subject: [PATCH 117/435] [sprinkler] Key timers by index and keep them in a fixed array (#20379) --- esphome/components/sprinkler/sprinkler.cpp | 16 +++++++--------- esphome/components/sprinkler/sprinkler.h | 6 +++--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index a3c264da582..eb9e61958f5 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -322,13 +322,11 @@ optional SprinklerValveRunRequest::valve_as_opt() { SprinklerValveOperator *SprinklerValveRunRequest::valve_operator() { return this->valve_op_; } Sprinkler::Sprinkler() : Sprinkler("") {} -Sprinkler::Sprinkler(const char *name) : name_(name) { - // The `name` is stored for dump_config logging - this->timer_.init(2); - // Timer names only need to be unique within this component instance - this->timer_.push_back({"sm", false, 0, 0, [this]() { this->sm_timer_callback_(); }}); - this->timer_.push_back({"vs", false, 0, 0, [this]() { this->valve_selection_callback_(); }}); -} +// The `name` is stored for dump_config logging; the scheduler keys each timer by its SprinklerTimerIndex +Sprinkler::Sprinkler(const char *name) + : name_(name), + timer_{{{false, 0, 0, [this]() { this->sm_timer_callback_(); }}, + {false, 0, 0, [this]() { this->valve_selection_callback_(); }}}} {} void Sprinkler::setup() { this->all_valves_off_(true); @@ -1510,7 +1508,7 @@ const LogString *Sprinkler::state_as_str_(SprinklerState state) { void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) { if (this->timer_duration_(timer_index) > 0) { - this->set_timeout(this->timer_[timer_index].name, this->timer_duration_(timer_index), + this->set_timeout(static_cast(timer_index), this->timer_duration_(timer_index), this->timer_cbf_(timer_index)); this->timer_[timer_index].start_time = millis(); this->timer_[timer_index].active = true; @@ -1521,7 +1519,7 @@ void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) { bool Sprinkler::cancel_timer_(const SprinklerTimerIndex timer_index) { this->timer_[timer_index].active = false; - return this->cancel_timeout(this->timer_[timer_index].name); + return this->cancel_timeout(static_cast(timer_index)); } bool Sprinkler::timer_active_(const SprinklerTimerIndex timer_index) { return this->timer_[timer_index].active; } diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 2499a0a5915..c51824d8833 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -7,6 +7,7 @@ #include "esphome/components/number/number.h" #include "esphome/components/switch/switch.h" +#include #include namespace esphome::sprinkler { @@ -49,7 +50,6 @@ struct SprinklerQueueItem { }; struct SprinklerTimer { - const char *name; bool active; uint32_t time; uint32_t start_time; @@ -564,8 +564,8 @@ class Sprinkler final : public Component { /// Sprinkler valve operator objects std::vector valve_op_{2}; - /// Valve control timers - FixedVector enforces that this can never grow beyond init() size - FixedVector timer_; + /// Valve control timers, indexed by SprinklerTimerIndex (also used as the scheduler id) + std::array timer_; /// Other Sprinkler instances we should be aware of (used to check if pumps are in use) std::vector other_controllers_; From 3485297e6e586c70406cc53565b1786bd85d20ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:01 -1000 Subject: [PATCH 118/435] [core] Use numeric scheduler ids instead of string names (4/5) (#20377) --- esphome/components/opt3001/opt3001.cpp | 7 +++++-- esphome/components/output/button/output_button.cpp | 6 ++++-- esphome/components/pid/pid_climate.cpp | 4 +++- .../remote_transmitter/remote_transmitter_rmt.cpp | 6 ++++-- esphome/components/scd30/scd30.cpp | 4 +++- esphome/components/spa06_base/spa06_base.cpp | 4 +++- esphome/components/tfluna/tfluna.cpp | 10 ++++++---- esphome/components/tmp102/tmp102.cpp | 4 +++- esphome/components/tsl2561/tsl2561.cpp | 4 +++- esphome/components/tsl2591/tsl2591.cpp | 10 +++++----- 10 files changed, 39 insertions(+), 20 deletions(-) diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index c6692d8dadb..0101368cebd 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -5,6 +5,9 @@ namespace esphome::opt3001 { ESPHOME_LOG_TAG(TAG, "opt3001.sensor"); +static constexpr uint32_t RESULT_RETRY_TIMEOUT_ID = 0; +static constexpr uint32_t READ_TIMEOUT_ID = 1; + static const uint8_t OPT3001_REG_RESULT = 0x00; static const uint8_t OPT3001_REG_CONFIGURATION = 0x01; // See datasheet for full description of each bit. @@ -42,7 +45,7 @@ void OPT3001Sensor::read_result_(const std::function &f) { if ((raw_value & OPT3001_CONFIGURATION_CONVERSION_MODE_MASK) != OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN) { // not ready; wait 10ms and try again ESP_LOGW(TAG, "Data not ready; waiting 10ms"); - this->set_timeout("opt3001_wait", 10, [this, f]() { read_result_(f); }); + this->set_timeout(RESULT_RETRY_TIMEOUT_ID, 10, [this, f]() { read_result_(f); }); return; } @@ -70,7 +73,7 @@ void OPT3001Sensor::read_lx_(const std::function &f) { return; } - this->set_timeout("read", OPT3001_CONVERSION_TIME_800, [this, f]() { + this->set_timeout(READ_TIMEOUT_ID, OPT3001_CONVERSION_TIME_800, [this, f]() { if (this->write(&OPT3001_REG_CONFIGURATION, 1) != i2c::ERROR_OK) { ESP_LOGW(TAG, "Starting configuration register read failed"); f(NAN); diff --git a/esphome/components/output/button/output_button.cpp b/esphome/components/output/button/output_button.cpp index 6ebbae13d68..17ffc39d7f0 100644 --- a/esphome/components/output/button/output_button.cpp +++ b/esphome/components/output/button/output_button.cpp @@ -5,6 +5,8 @@ namespace esphome::output { ESPHOME_LOG_TAG(TAG, "output.button"); +static constexpr uint32_t RESET_TIMEOUT_ID = 0; + void OutputButton::dump_config() { LOG_BUTTON("", "Output Button", this); ESP_LOGCONFIG(TAG, " Duration: %.1fs", this->duration_ / 1e3f); @@ -12,8 +14,8 @@ void OutputButton::dump_config() { void OutputButton::press_action() { this->output_->turn_on(); - // Use a named timeout so that it's automatically cancelled if button is pressed again before it's reset - this->set_timeout("reset", this->duration_, [this]() { this->output_->turn_off(); }); + // One timeout id, so a second press restarts the reset instead of stacking another + this->set_timeout(RESET_TIMEOUT_ID, this->duration_, [this]() { this->output_->turn_off(); }); } } // namespace esphome::output diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 67585f65e8c..d33a7f75441 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -5,6 +5,8 @@ namespace esphome::pid { ESPHOME_LOG_TAG(TAG, "pid.climate"); +static constexpr uint32_t AUTOTUNE_PROGRESS_INTERVAL_ID = 0; + bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) { if (threshold_low > threshold_high) { ESP_LOGW(TAG, "Deadband threshold low %.2f must not be greater than high %.2f", threshold_low, threshold_high); @@ -191,7 +193,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { "Until your sensor provides a reading, the autotuner may display \'nan\'", this->get_name().c_str()); - this->set_interval("autotune-progress", 10000, [this]() { + this->set_interval(AUTOTUNE_PROGRESS_INTERVAL_ID, 10000, [this]() { if (this->autotuner_ != nullptr && !this->autotuner_->is_finished()) this->autotuner_->dump_config(); }); diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index f6550d107c6..22a63ca4fd3 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -11,6 +11,8 @@ namespace esphome::remote_transmitter { ESPHOME_LOG_TAG(TAG, "remote_transmitter"); +static constexpr uint32_t COMPLETE_TIMEOUT_ID = 0; + // Maximum RMT symbol duration (15-bit field) static constexpr uint32_t RMT_SYMBOL_DURATION_MAX = 0x7FFF; @@ -206,7 +208,7 @@ void RemoteTransmitterComponent::wait_for_rmt_() { #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) void RemoteTransmitterComponent::flush_pending_completion() { // a frame still on the wire is waited out, and its completion reported, before the next one - if (this->non_blocking_ && this->cancel_timeout("complete")) { + if (this->non_blocking_ && this->cancel_timeout(COMPLETE_TIMEOUT_ID)) { this->wait_for_rmt_(); } } @@ -292,7 +294,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen this->status_clear_warning(); if (this->non_blocking_) { - this->set_timeout("complete", total_duration / 1000, [this]() { this->wait_for_rmt_(); }); + this->set_timeout(COMPLETE_TIMEOUT_ID, total_duration / 1000, [this]() { this->wait_for_rmt_(); }); } else { this->wait_for_rmt_(); } diff --git a/esphome/components/scd30/scd30.cpp b/esphome/components/scd30/scd30.cpp index 3e8794e65c7..5802503627a 100644 --- a/esphome/components/scd30/scd30.cpp +++ b/esphome/components/scd30/scd30.cpp @@ -10,6 +10,8 @@ namespace esphome::scd30 { ESPHOME_LOG_TAG(TAG, "scd30"); +static constexpr uint32_t STATUS_CHECK_INTERVAL_ID = 0; + static const uint16_t SCD30_CMD_GET_FIRMWARE_VERSION = 0xd100; static const uint16_t SCD30_CMD_START_CONTINUOUS_MEASUREMENTS = 0x0010; static const uint16_t SCD30_CMD_ALTITUDE_COMPENSATION = 0x5102; @@ -107,7 +109,7 @@ void SCD30Component::setup() { } // check each 500ms if data is ready, and read it in that case - this->set_interval("status-check", 500, [this]() { + this->set_interval(STATUS_CHECK_INTERVAL_ID, 500, [this]() { if (this->is_data_ready_()) this->update(); }); diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index 987a14c66a8..02defd07735 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -8,6 +8,8 @@ namespace esphome::spa06_base { ESPHOME_LOG_TAG(TAG, "spa06"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + // Sign extension function for <=16 bit types inline int16_t decode16(uint8_t msb, uint8_t lsb, size_t bits, size_t head = 0) { return static_cast(encode_uint16(msb, lsb) << head) >> (16 - bits); @@ -243,7 +245,7 @@ void SPA06Component::update() { } // Queue a background task for retrieving the measurement - this->set_timeout("measurement", this->conversion_time_, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, this->conversion_time_, [this]() { float raw_temperature; float temperature = 0.0; float pressure = 0.0; diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index 0936a07f49c..e3d7045d8e6 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -17,7 +17,9 @@ static constexpr uint8_t TRIGGER_ONESHOT_REGISTER = 0x24; static constexpr uint8_t MAX_READ_ATTEMPTS = 5; static constexpr uint8_t READ_RETRY_MS = 5; ESPHOME_LOG_TAG(TAG, "tfluna"); -static const char *const READ_RETRY_TIMEOUT = "read_retry"; + +static constexpr uint32_t SETUP_TIMEOUT_ID = 0; +static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 1; void TFLuna::dump_config() { ESP_LOGCONFIG(TAG, "TF-Luna (i2c):"); @@ -121,7 +123,7 @@ void TFLuna::read_data_timeout_() { } else { if (this->attempt_ < MAX_READ_ATTEMPTS) { this->attempt_++; - this->set_timeout(READ_RETRY_TIMEOUT, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); + this->set_timeout(READ_RETRY_TIMEOUT_ID, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); } else { this->status_set_warning("Hung device, restarting..."); this->restart(); @@ -130,7 +132,7 @@ void TFLuna::read_data_timeout_() { } void TFLuna::update() { - this->cancel_timeout(READ_RETRY_TIMEOUT); + this->cancel_timeout(READ_RETRY_TIMEOUT_ID); this->attempt_ = 0; if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); @@ -145,7 +147,7 @@ void TFLuna::factory_reset() { return; } this->status_set_warning("Factory reset issued; waiting for device to become ready"); - this->set_timeout("_setup", 100, [this]() { this->setup(); }); + this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->setup(); }); } void TFLuna::restart() { diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 4f10555324e..d851bd925fa 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -6,6 +6,8 @@ namespace esphome::tmp102 { ESPHOME_LOG_TAG(TAG, "tmp102"); +static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; + static const uint8_t TMP102_ADDRESS = 0x48; static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static const uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; @@ -29,7 +31,7 @@ void TMP102Component::update() { this->status_set_warning(); return; } - this->set_timeout("read_temp", 50, [this]() { + this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { this->status_set_warning(); diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 989a15f054b..d1d1d66a265 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -5,6 +5,8 @@ namespace esphome::tsl2561 { ESPHOME_LOG_TAG(TAG, "tsl2561"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static const uint8_t TSL2561_COMMAND_BIT = 0x80; static const uint8_t TSL2561_WORD_BIT = 0x20; static const uint8_t TSL2561_REGISTER_CONTROL = 0x00; @@ -60,7 +62,7 @@ void TSL2561Sensor::update() { // Make sure the data is there when we will read it. auto timeout = static_cast(this->get_integration_time_ms_() + 20); - this->set_timeout("illuminance", timeout, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, timeout, [this]() { this->read_data_(); }); } float TSL2561Sensor::calculate_lx_(uint16_t ch0, uint16_t ch1) { diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index dfc0dfedf4e..f1f9ae9cfdb 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -6,6 +6,8 @@ namespace esphome::tsl2591 { ESPHOME_LOG_TAG(TAG, "tsl2591.sensor"); +static constexpr uint32_t ADC_WAIT_INTERVAL_ID = 0; + // Various constants used in TSL2591 register manipulation #define TSL2591_COMMAND_BIT (0xA0) // 1010 0000: bits 7 and 5 for 'command, normal' #define TSL2591_ENABLE_POWERON (0x01) // Flag for ENABLE register, to enable @@ -163,19 +165,17 @@ void TSL2591Component::process_update_() { this->status_clear_warning(); } -#define interval_name "tsl2591_interval_for_update" - void TSL2591Component::interval_function_for_update_() { if (!this->is_adc_valid()) { uint64_t now = millis(); ESP_LOGD(TAG, "Elapsed %3llu ms; still waiting for valid ADC", (now - this->interval_start_)); if (now > this->interval_timeout_) { ESP_LOGW(TAG, "Interval timeout for '%s' expired before ADCs became valid", this->name_); - this->cancel_interval(interval_name); + this->cancel_interval(ADC_WAIT_INTERVAL_ID); } return; } - this->cancel_interval(interval_name); + this->cancel_interval(ADC_WAIT_INTERVAL_ID); this->process_update_(); } @@ -191,7 +191,7 @@ void TSL2591Component::update() { } else { this->interval_start_ = millis(); this->interval_timeout_ = this->interval_start_ + 620; - this->set_interval(interval_name, 100, [this] { this->interval_function_for_update_(); }); + this->set_interval(ADC_WAIT_INTERVAL_ID, 100, [this] { this->interval_function_for_update_(); }); } } } From 8a09e49e3158a94bad14ae977e4b2932b4423149 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:18 -1000 Subject: [PATCH 119/435] [ci] Skip the memory impact target build when the base test is missing (#20410) --- .github/workflows/ci.yml | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf59f073fa..586263c6514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1422,31 +1422,27 @@ jobs: echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis" fi - # Check if test files exist on the target branch for the requested - # components and platform. When a PR adds new test files for a platform, - # the target branch won't have them yet, so skip instead of failing. - # This check must be done here (not in determine-jobs.py) because - # determine-jobs runs on the PR branch and cannot see what the target - # branch has. + # The build below uses --base-only, so skip unless the target branch has + # test..yaml for every requested component (test-*.yaml + # variants alone compile nothing, and a partial set would compare a + # smaller target build against the PR build). Checked here, not in + # determine-jobs.py, because determine-jobs runs on the PR branch and + # cannot see the target branch. - name: Check for test files on target branch id: check-tests if: steps.check-script.outputs.skip != 'true' run: | components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}' platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}" - found=false + missing="" for component in $(echo "$components" | jq -r '.[]'); do - # Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml) - for f in tests/components/${component}/test*.${platform}.yaml; do - if [ -f "$f" ]; then - found=true - break 2 - fi - done + if [ ! -f "tests/components/${component}/test.${platform}.yaml" ]; then + missing="${missing} ${component}" + fi done - if [ "$found" = false ]; then + if [ -n "$missing" ]; then echo "skip=true" >> $GITHUB_OUTPUT - echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis" + echo "::warning::No test.${platform}.yaml on target branch for${missing}, skipping memory impact analysis" else echo "skip=false" >> $GITHUB_OUTPUT fi From 7ac679aa046073e739f4e1ec0866943f6f09988d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:30 -1000 Subject: [PATCH 120/435] [esp8266] Keep ArduinoJson's float tables in flash (Arduino core 3.1.2-esphome.3) (#20416) --- esphome/arduino8266/framework.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index c94ea1df9cd..16fd5d62829 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple): # Arduino core version -> its build in esphome-libs/arduino-esp8266 FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { Version(3, 1, 2): FrameworkRelease( - "3.1.2-esphome.2", - "9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7", - 37192128, + "3.1.2-esphome.3", + "3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db", + 37185650, ), } From 56e6e332cbd4233e5438d5ff50d818c2339a6964 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:45 -1000 Subject: [PATCH 121/435] [ci] Skip the static analyzer on the generated protobuf dump file (#20414) --- script/clang-tidy | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/script/clang-tidy b/script/clang-tidy index f97e9a482eb..a2becb59cc8 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -279,6 +279,14 @@ def clang_options(idedata, environment): pids = set() +# Generated files the static analyzer is skipped on. The analyzer inlines the +# small dump helpers into every generated dump function and takes +# over 200 s on this file in CI, 99% of its clang-tidy time; any finding there +# would be fixed in script/api_protobuf anyway. The other checks still run. +ANALYZER_SKIPPED_FILES: frozenset[str] = frozenset( + {"esphome/components/api/api_pb2_dump.cpp"} +) + def run_tidy( executable, args, options, tmpdir, path_queue, lock, failed_files, timings @@ -302,16 +310,22 @@ def run_tidy( if sys.stdout.isatty(): invocation.append("--use-color") + disabled_checks = [] if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): # MMIO peripheral access on these bare-metal platforms is all # fixed-address. # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher # recursion on lvgl_esphome.h under the RP2 defines. - invocation.append( - "--checks=-clang-analyzer-core.FixedAddressDereference," - "-bugprone-pointer-arithmetic-on-polymorphic-object,-cert-ctr56-cpp" - ) + disabled_checks += [ + "-clang-analyzer-core.FixedAddressDereference", + "-bugprone-pointer-arithmetic-on-polymorphic-object", + "-cert-ctr56-cpp", + ] + if os.path.normpath(path) in ANALYZER_SKIPPED_FILES: + disabled_checks.append("-clang-analyzer-*") + if disabled_checks: + invocation.append("--checks=" + ",".join(disabled_checks)) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") invocation.append(str(Path(path).resolve())) invocation.append("--") From d4d5ee87cb343cc0f320890bc926c7671cec377a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:12:14 -1000 Subject: [PATCH 122/435] [ir_rf_proxy] Collapse duplicate CI test variants (#20399) --- .../components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml | 9 --------- tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml | 7 ------- tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml | 7 ------- tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml | 7 ------- tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml | 7 ------- tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml | 7 ------- 6 files changed, 44 deletions(-) delete mode 100644 tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml delete mode 100644 tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml diff --git a/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml deleted file mode 100644 index e25c47ab236..00000000000 --- a/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - cs_pin: GPIO5 - gdo0_pin: GPIO4 - gdo2_pin: GPIO16 - -packages: - common: !include common.yaml - spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml - cc1101: !include common-cc1101.yaml diff --git a/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml deleted file mode 100644 index 8172885b31b..00000000000 --- a/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - rx: !include common-rx.yaml diff --git a/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml b/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml deleted file mode 100644 index 8172885b31b..00000000000 --- a/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - rx: !include common-rx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml b/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml deleted file mode 100644 index 7162f15b2dd..00000000000 --- a/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml deleted file mode 100644 index 7162f15b2dd..00000000000 --- a/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml b/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml deleted file mode 100644 index 7162f15b2dd..00000000000 --- a/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - tx: !include common-tx.yaml From 24237a07c9f13ca3c561ac2cfb858e57163e2e17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:12:35 -1000 Subject: [PATCH 123/435] [ci] Refit component batch estimates to a full run (#20401) --- script/split_components_for_ci.py | 172 ++++++++++++++----- tests/script/test_determine_jobs.py | 14 +- tests/script/test_split_components_for_ci.py | 121 +++++++++++-- 3 files changed, 248 insertions(+), 59 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index cc458d5d664..26907936c17 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -13,15 +13,20 @@ from __future__ import annotations import argparse from collections import defaultdict +from collections.abc import Set as AbstractSet +import contextlib from dataclasses import dataclass, field +import functools import json import math from pathlib import Path import sys +from typing import Any, NamedTuple # Add esphome to path sys.path.insert(0, str(Path(__file__).parent.parent)) +from esphome import yaml_util from script.analyze_component_buses import ( ISOLATED_COMPONENTS, ISOLATED_SIGNATURE_PREFIX, @@ -31,29 +36,52 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, ) from script.helpers import ( + _extract_components_from_yaml, get_component_test_files, parse_test_filename, split_conflicting_groups, ) -# Estimated CI seconds for one build per test platform, read from batch job -# logs. Absolute accuracy does not matter, only the ratios between platforms. -# A full run takes over an hour, so recording real timings is not practical. +# Estimated CI seconds for one build per test platform, fitted to the batch +# job logs of a full run (every component, PR #20389). Absolute accuracy does +# not matter, only the ratios between platforms. A full run takes over an +# hour, so recording real timings is not practical. PLATFORM_BUILD_SECONDS = { "host": 10, "esp8266-ard": 30, - "esp32-idf": 45, - "rtl87xx-ard": 35, - "ln882x-ard": 50, - "rp2040-ard": 50, - "rp2350-ard": 50, - "bk72xx-ard": 60, + "rtl87xx-ard": 40, + "esp32-c3-idf": 40, + "esp32-s2-idf": 40, + "rp2350-ard": 40, + "rp2040-ard": 45, + "esp32-idf": 55, + "esp32-p4-idf": 55, + "ln882x-ard": 55, + "esp32-s3-idf": 60, + "bk72xx-ard": 70, + "esp32-c6-idf": 70, } -NRF52_BUILD_SECONDS = 45 -# Other ESP32 chips and Arduino on ESP32 share less of the ccache -DEFAULT_BUILD_SECONDS = 90 +NRF52_BUILD_SECONDS = 50 +# Arduino on any ESP32 chip builds the IDF plus the Arduino core on top +ESP32_ARDUINO_BUILD_SECONDS = 90 +# The remaining ESP32 chips on ESP-IDF +DEFAULT_BUILD_SECONDS = 50 # Each extra component merged into a grouped build makes it larger -GROUPED_COMPONENT_SECONDS = 5 +GROUPED_COMPONENT_SECONDS = 3 +# Components that pull in a large library the build compiles from source +# (TensorFlow Lite Micro, FastLED, LVGL, the HTTP server), added to every +# build whose test config enables them. Fitted the same way as the platforms. +HEAVY_COMPONENT_SECONDS: dict[str, int] = { + "micro_wake_word": 200, + "fastled_clockless": 90, + "fastled_spi": 90, + "wled": 60, + "lvgl": 50, + "web_server": 50, + "online_image": 45, + "prometheus": 35, + "bluetooth_proxy": 30, +} # Estimated build seconds per CI runner; sets how many runners are used. # Approximate: the runner count charges each grouped build once, but a large # group spreads over several runners, which each pay for that build. @@ -90,9 +118,62 @@ def build_seconds(platform: str) -> int: return seconds if platform.startswith("nrf52"): return NRF52_BUILD_SECONDS + if platform.startswith("esp32") and platform.endswith("-ard"): + return ESP32_ARDUINO_BUILD_SECONDS return DEFAULT_BUILD_SECONDS +def _config_components(data: Any) -> set[str]: + """Return the components a loaded test config and its ``packages:`` use.""" + if not isinstance(data, dict): + return set() + found = _extract_components_from_yaml(data) + if isinstance(packages := data.get("packages"), dict): + for package in packages.values(): + if isinstance(package, yaml_util.IncludeFile): + found |= _file_components( + (package.parent_file.parent / package.file).resolve() + ) + else: + found |= _config_components(package) + return found + + +@functools.cache +def _file_components(path: Path) -> frozenset[str]: + """Return the components a config file uses; shared packages load once.""" + return frozenset( + _config_components(yaml_util.load_yaml(path, track_document_range=False)) + ) + + +def heavy_components(component: str, test_file: Path) -> frozenset[str]: + """Return the heavy components a test compiles. + + A test counts as heavy when it belongs to a heavy component or when its + config enables one, such as a display test that draws with lvgl. + """ + heavy = {component} & HEAVY_COMPONENT_SECONDS.keys() + # A config that does not load is left to the build to report + with contextlib.suppress(Exception): + heavy |= _file_components(test_file.resolve()) & HEAVY_COMPONENT_SECONDS.keys() + return frozenset(heavy) + + +def heavy_seconds(heavy: AbstractSet[str]) -> int: + """Return the library seconds a set of heavy components adds to a build.""" + return sum(HEAVY_COMPONENT_SECONDS[name] for name in heavy) + + +class _GroupedShare(NamedTuple): + """A component's part in one (signature, platform) shared build.""" + + # Seconds of its files on that platform when no other member shares it + solo: int + # Heavy components its base test brings into the shared build + heavy: frozenset[str] + + @dataclass class _BatchItem: """A component and the builds it adds to the batch it lands in.""" @@ -101,40 +182,52 @@ class _BatchItem: # Builds that always run on their own (isolated tests, variants on a # platform without a base test) own_seconds: int - # Per (signature, platform) shared build: the seconds of this component's - # files on that platform when no other batch member shares the build - grouped_builds: dict[tuple[str, str], int] = field(default_factory=dict) + grouped_builds: dict[tuple[str, str], _GroupedShare] = field(default_factory=dict) def standalone_seconds(self) -> int: - return self.own_seconds + sum(self.grouped_builds.values()) + return self.own_seconds + sum( + share.solo for share in self.grouped_builds.values() + ) -def _grouped_build_seconds(platform: str, solo_seconds: list[int]) -> int: +def _grouped_build_seconds(platform: str, members: list[_GroupedShare]) -> int: """Return the seconds of one (signature, platform) build in a batch. + A library two members both enable compiles once, so the union is charged. test_build_components only groups when two or more members share the build; a grouped member then skips its variants on that platform. """ - if len(solo_seconds) <= 1: - return sum(solo_seconds) - return build_seconds(platform) + GROUPED_COMPONENT_SECONDS * (len(solo_seconds) - 1) + if len(members) <= 1: + return sum(share.solo for share in members) + return ( + build_seconds(platform) + + GROUPED_COMPONENT_SECONDS * (len(members) - 1) + + heavy_seconds(set().union(*(share.heavy for share in members))) + ) def _make_item( tests_dir: Path, component: str, signature: str, is_isolated: bool ) -> _BatchItem: - files_by_platform: dict[str, list[str]] = defaultdict(list) + # Per platform: the seconds of its test files, and the base test's heavy + # components; a platform without a base test cannot join a shared build + seconds_by_platform: dict[str, int] = defaultdict(int) + base_heavy: dict[str, frozenset[str]] = {} for test_file in (tests_dir / component).glob("test[.-]*.yaml"): test_name, platform = parse_test_filename(test_file) - files_by_platform[platform].append(test_name) + heavy = heavy_components(component, test_file) + seconds_by_platform[platform] += build_seconds(platform) + heavy_seconds(heavy) + if test_name == "test": + base_heavy[platform] = heavy own_seconds = 0 - grouped: dict[tuple[str, str], int] = {} - for platform, test_names in files_by_platform.items(): - seconds = build_seconds(platform) * len(test_names) - if is_isolated or "test" not in test_names: + grouped: dict[tuple[str, str], _GroupedShare] = {} + for platform, seconds in seconds_by_platform.items(): + if is_isolated or platform not in base_heavy: own_seconds += seconds else: - grouped[(signature, platform)] = seconds + grouped[(signature, platform)] = _GroupedShare( + seconds, base_heavy[platform] + ) return _BatchItem(component, own_seconds, grouped) @@ -144,22 +237,24 @@ class _Batch: def __init__(self) -> None: self.components: list[str] = [] self.seconds = 0 - self.grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + self.grouped_builds: dict[tuple[str, str], list[_GroupedShare]] = defaultdict( + list + ) def added_seconds(self, item: _BatchItem) -> int: """Return the seconds item would add; joining an existing build is cheap.""" added = item.own_seconds - for (signature, platform), solo in item.grouped_builds.items(): + for (signature, platform), share in item.grouped_builds.items(): members = self.grouped_builds.get((signature, platform), []) added += _grouped_build_seconds( - platform, [*members, solo] + platform, [*members, share] ) - _grouped_build_seconds(platform, members) return added def add(self, item: _BatchItem) -> None: self.seconds += self.added_seconds(item) - for build, solo in item.grouped_builds.items(): - self.grouped_builds[build].append(solo) + for build, share in item.grouped_builds.items(): + self.grouped_builds[build].append(share) self.components.append(item.component) @@ -172,14 +267,11 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """ if not items: return [] - grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + # One batch holding every item counts each grouped build once + whole = _Batch() for item in items: - for build, solo in item.grouped_builds.items(): - grouped_builds[build].append(solo) - total = sum(item.own_seconds for item in items) + sum( - _grouped_build_seconds(platform, solos) - for (_, platform), solos in grouped_builds.items() - ) + whole.add(item) + total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7e9b09b2b93..e63e0849deb 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2613,8 +2613,8 @@ def test_component_batching_beta_branch_groups_evenly( For beta/release branches, all components should be groupable (not isolated), so they share one grouped build per runner and spread evenly. """ - # Create 120 test components with test files - component_names = [f"comp_{i:03d}" for i in range(120)] + # Create 200 test components with test files + component_names = [f"comp_{i:03d}" for i in range(200)] tests_dir = tmp_path / "tests" / "components" for comp in component_names: @@ -2675,15 +2675,15 @@ def test_component_batching_beta_branch_groups_evenly( assert "component_test_batches" in output batches = output["component_test_batches"] - # One grouped esp32-idf build plus 5 s per extra component is 640 s, - # so two runners of 60 components each - assert [len(batch["components"].split()) for batch in batches] == [60, 60] + # One grouped esp32-idf build plus 3 s per extra component is 652 s, + # so two runners of 100 components each + assert [len(batch["components"].split()) for batch in batches] == [100, 100] - # Verify all 120 components are in batches + # Verify all 200 components are in batches all_components = [] for batch in batches: all_components.extend(batch["components"].split()) - assert len(all_components) == 120 + assert len(all_components) == 200 assert set(all_components) == set(component_names) diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 88a1a17b333..4da644d6798 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -35,13 +35,46 @@ def test_build_seconds() -> None: """Known platforms use the table; nrf52 boards share one estimate.""" build_seconds = split_components_for_ci.build_seconds assert build_seconds("host") == 10 - assert build_seconds("esp32-idf") == 45 + assert build_seconds("esp32-idf") == 55 assert ( build_seconds("nrf52-xiao-ble") == split_components_for_ci.NRF52_BUILD_SECONDS ) assert ( - build_seconds("esp32-s3-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS + build_seconds("esp32-c5-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS ) + assert ( + build_seconds("esp32-c3-ard") + == split_components_for_ci.ESP32_ARDUINO_BUILD_SECONDS + ) + + +def test_heavy_components(tests_dir: Path) -> None: + """A heavy component, or a test that enables one, costs its library time.""" + heavy_components = split_components_for_ci.heavy_components + heavy_seconds = split_components_for_ci.heavy_seconds + table = split_components_for_ci.HEAVY_COMPONENT_SECONDS + _add_component(tests_dir, "dht", ["test.esp32-idf.yaml"]) + _add_component(tests_dir, "micro_wake_word", ["test.esp32-idf.yaml"]) + assert heavy_components("dht", tests_dir / "dht/test.esp32-idf.yaml") == set() + assert heavy_components( + "micro_wake_word", tests_dir / "micro_wake_word/test.esp32-idf.yaml" + ) == {"micro_wake_word"} + assert heavy_seconds(frozenset({"micro_wake_word"})) == table["micro_wake_word"] + + display = tests_dir / "mipi_spi" + display.mkdir() + (display / "common.yaml").write_text("web_server:\n port: 80\n") + (display / "test-lvgl.esp32-s3-idf.yaml").write_text( + "packages:\n" + " mipi_spi: !include common.yaml\n" + "lvgl:\n pages: []\n" + "light:\n - platform: fastled_spi\n num_leds: 1\n" + ) + assert heavy_components("mipi_spi", display / "test-lvgl.esp32-s3-idf.yaml") == { + "lvgl", + "web_server", + "fastled_spi", + } def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: @@ -61,29 +94,93 @@ def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: isolated = split_components_for_ci._make_item( tests_dir, "comp", "isolated_comp", True ) - assert isolated.own_seconds == 45 + 10 + 45 + 50 + assert isolated.own_seconds == 55 + 10 + 55 + 45 assert isolated.grouped_builds == {} # The rp2040 variant has no base test to group with, so it always runs grouped = split_components_for_ci._make_item(tests_dir, "comp", "i2c", False) - assert grouped.own_seconds == 50 - assert grouped.grouped_builds == {("i2c", "esp32-idf"): 90, ("i2c", "host"): 10} + assert grouped.own_seconds == 45 + assert grouped.grouped_builds == { + ("i2c", "esp32-idf"): (110, frozenset()), + ("i2c", "host"): (10, frozenset()), + } + + +def test_make_item_heavy_component(tests_dir: Path) -> None: + """A heavy component's library cost follows it into every build.""" + _add_component(tests_dir, "lvgl", ["test.esp32-idf.yaml", "test-a.host.yaml"]) + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + + item = split_components_for_ci._make_item(tests_dir, "lvgl", "spi", False) + assert item.own_seconds == 10 + extra + assert item.grouped_builds == { + ("spi", "esp32-idf"): (55 + extra, frozenset({"lvgl"})) + } + + +def test_heavy_cost_is_per_platform(tests_dir: Path) -> None: + """A base test that enables a heavy component only charges its own platform.""" + comp = tests_dir / "web_server_idf" + comp.mkdir() + (comp / "test.esp32-idf.yaml").write_text("web_server:\n port: 80\n") + (comp / "test.host.yaml").write_text("logger:\n") + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["web_server"] + + item = split_components_for_ci._make_item( + tests_dir, "web_server_idf", "none", False + ) + assert item.grouped_builds == { + ("none", "esp32-idf"): (55 + extra, frozenset({"web_server"})), + ("none", "host"): (10, frozenset()), + } def test_grouped_component_joins_existing_build() -> None: """A second member turns a lone build into a group that skips variants.""" item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare batch = split_components_for_ci._Batch() - batch.add(item("a", 0, {("i2c", "esp32-idf"): 90})) - assert batch.seconds == 90 + batch.add(item("a", 0, {("i2c", "esp32-idf"): share(110, frozenset())})) + assert batch.seconds == 110 - other = item("b", 0, {("i2c", "esp32-idf"): 45, ("i2c", "host"): 10}) - grouped = 45 + split_components_for_ci.GROUPED_COMPONENT_SECONDS - assert batch.added_seconds(other) == grouped - 90 + 10 + other = item( + "b", + 0, + { + ("i2c", "esp32-idf"): share(55, frozenset()), + ("i2c", "host"): share(10, frozenset()), + }, + ) + grouped = 55 + split_components_for_ci.GROUPED_COMPONENT_SECONDS + assert batch.added_seconds(other) == grouped - 110 + 10 batch.add(other) assert batch.seconds == grouped + 10 +def test_heavy_component_charges_the_shared_build() -> None: + """A heavy member makes the whole grouped build slower, once per library.""" + item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare + batch = split_components_for_ci._Batch() + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + per_member = split_components_for_ci.GROUPED_COMPONENT_SECONDS + lvgl = frozenset({"lvgl"}) + batch.add(item("a", 0, {("spi", "esp32-idf"): share(55, frozenset())})) + + heavy = item("lvgl", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + grouped = 55 + per_member + extra + assert batch.added_seconds(heavy) == grouped - 55 + batch.add(heavy) + assert batch.seconds == grouped + + light = item("b", 0, {("spi", "esp32-idf"): share(55, frozenset())}) + assert batch.added_seconds(light) == per_member + + # A second member that also draws with lvgl compiles the library once + also_heavy = item("c", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + assert batch.added_seconds(also_heavy) == per_member + + def test_isolated_components_balance_across_runners(tests_dir: Path) -> None: """Heavy isolated components spread out and light ones fill the gaps.""" platforms = ["esp32-idf", "esp8266-ard", "rp2040-ard", "bk72xx-ard"] @@ -138,7 +235,7 @@ def test_groupable_components_split_evenly(tests_dir: Path) -> None: components=names, tests_dir=tests_dir, target_seconds=100 ) - assert sorted(len(batch) for batch in batches) == [2, 2, 2, 3, 3] + assert sorted(len(batch) for batch in batches) == [3, 3, 3, 3] assert sorted(c for batch in batches for c in batch) == names @@ -149,7 +246,7 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: _add_component(other, "comp", ["test.esp32-idf.yaml"]) item = split_components_for_ci._make_item(other, "comp", "isolated_comp", True) - assert item.own_seconds == 45 + assert item.own_seconds == 55 def test_balance_batches_empty() -> None: From 4c17ee739398a9ad368b0cfddd7c7de587c9cc94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:13:46 -1000 Subject: [PATCH 124/435] [core] Use numeric scheduler ids instead of string names (2/5) (#20375) --- esphome/components/cs5460a/cs5460a.cpp | 6 ++++-- esphome/components/ct_clamp/ct_clamp_sensor.cpp | 4 +++- esphome/components/current_based/current_based_cover.cpp | 6 ++++-- esphome/components/dps310/dps310.cpp | 4 +++- esphome/components/ens210/ens210.cpp | 4 +++- esphome/components/gp2y1010au0f/gp2y1010au0f.cpp | 5 ++++- esphome/components/hdc302x/hdc302x.cpp | 8 +++++--- esphome/components/honeywell_hih_i2c/honeywell_hih.cpp | 6 ++++-- esphome/components/honeywellabp2_i2c/honeywellabp2.cpp | 6 ++++-- esphome/components/hydreon_rgxx/hydreon_rgxx.cpp | 7 +++++-- 10 files changed, 39 insertions(+), 17 deletions(-) diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index 6ef4588b171..efc0cc9fa1f 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -5,6 +5,8 @@ namespace esphome::cs5460a { ESPHOME_LOG_TAG(TAG, "cs5460a"); +static constexpr uint32_t STATUS_CHECK_TIMEOUT_ID = 0; + void CS5460AComponent::write_register_(enum CS5460ARegister addr, uint32_t value) { this->write_byte(CMD_WRITE | (addr << 1)); this->write_byte(value >> 16); @@ -149,13 +151,13 @@ void CS5460AComponent::schedule_next_check_() { this->status_momentary_warning("warning", 1000); } else { ESP_LOGCONFIG(TAG, "Device officially stuck, resetting"); - this->cancel_timeout("status-check"); + this->cancel_timeout(STATUS_CHECK_TIMEOUT_ID); this->hw_init_(); return; } } - this->set_timeout("status-check", time_left, [this]() { + this->set_timeout(STATUS_CHECK_TIMEOUT_ID, time_left, [this]() { if (!this->check_status_()) this->schedule_next_check_(); }); diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.cpp b/esphome/components/ct_clamp/ct_clamp_sensor.cpp index d9e4b2b8fa2..6488c468c34 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.cpp +++ b/esphome/components/ct_clamp/ct_clamp_sensor.cpp @@ -8,6 +8,8 @@ namespace esphome::ct_clamp { ESPHOME_LOG_TAG(TAG, "ct_clamp"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void CTClampSensor::dump_config() { LOG_SENSOR("", "CT Clamp Sensor", this); ESP_LOGCONFIG(TAG, " Sample Duration: %.2fs", this->sample_duration_ / 1e3f); @@ -21,7 +23,7 @@ void CTClampSensor::update() { this->high_freq_.start(); // Set timeout for ending sampling phase - this->set_timeout("read", this->sample_duration_, [this]() { + this->set_timeout(READ_TIMEOUT_ID, this->sample_duration_, [this]() { this->is_sampling_ = false; this->high_freq_.stop(); diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 6311fd3cf3f..04743975715 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -8,6 +8,8 @@ namespace esphome::current_based { ESPHOME_LOG_TAG(TAG, "current_based.cover"); +static constexpr uint32_t ROLLBACK_TIMEOUT_ID = 0; + using namespace esphome::cover; CoverTraits CurrentBasedCover::get_traits() { @@ -73,7 +75,7 @@ void CurrentBasedCover::loop() { ESP_LOGD(TAG, "'%s' - Obstacle detected during opening.", this->name_.c_str()); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { - this->set_timeout("rollback", 300, [this]() { + this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); this->target_position_ = clamp(this->position - this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_CLOSING); @@ -94,7 +96,7 @@ void CurrentBasedCover::loop() { ESP_LOGD(TAG, "'%s' - Obstacle detected during closing.", this->name_.c_str()); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { - this->set_timeout("rollback", 300, [this]() { + this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); this->target_position_ = clamp(this->position + this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_OPENING); diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index abb34d6598a..080f101747f 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -6,6 +6,8 @@ namespace esphome::dps310 { ESPHOME_LOG_TAG(TAG, "dps310"); +static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 0; + void DPS310Component::setup() { uint8_t coef_data_raw[DPS310_NUM_COEF_REGS]; auto timer = DPS310_INIT_TIMEOUT; @@ -126,7 +128,7 @@ void DPS310Component::read_() { this->update_in_progress_ = false; this->status_clear_warning(); } else { - this->set_timeout("dps310", 10, [this]() { this->read_(); }); + this->set_timeout(READ_RETRY_TIMEOUT_ID, 10, [this]() { this->read_(); }); } } diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index fa1d4594070..5815fc90382 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -14,6 +14,8 @@ namespace esphome::ens210 { ESPHOME_LOG_TAG(TAG, "ens210"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + // ENS210 chip constants static const uint8_t ENS210_BOOTING_MS = 2; // Booting time in ms (also after reset, or going to high power) static const uint8_t ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS = @@ -149,7 +151,7 @@ void ENS210Component::update() { return; } // Wait for measurement to complete - this->set_timeout("data", uint32_t(ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS), [this]() { int temperature_data, temperature_status, humidity_data, humidity_status; uint8_t data[6]; uint32_t h_val_data, t_val_data; diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp index 552261155ff..ac98a01fd5d 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp @@ -7,6 +7,9 @@ namespace esphome::gp2y1010au0f { ESPHOME_LOG_TAG(TAG, "gp2y1010au0f"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static const float MIN_VOLTAGE = 0.0f; static const float MAX_VOLTAGE = 4.0f; @@ -22,7 +25,7 @@ void GP2Y1010AU0FSensor::dump_config() { void GP2Y1010AU0FSensor::update() { is_sampling_ = true; - this->set_timeout("read", this->sample_duration_, [this]() { + this->set_timeout(READ_TIMEOUT_ID, this->sample_duration_, [this]() { this->is_sampling_ = false; if (this->num_samples_ == 0) return; diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index b45b3b612f1..d5428486579 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -8,6 +8,8 @@ namespace esphome::hdc302x { ESPHOME_LOG_TAG(TAG, "hdc302x.sensor"); +static constexpr uint32_t HEATER_OFF_TIMEOUT_ID = 0; + // Commands (per datasheet Table 7-4) static const uint8_t HDC302X_CMD_SOFT_RESET[2] = {0x30, 0xa2}; static const uint8_t HDC302X_CMD_CLEAR_STATUS_REGISTER[2] = {0x30, 0x41}; @@ -68,14 +70,14 @@ void HDC302XComponent::start_heater(uint16_t power, uint32_t duration_ms) { return; } this->heater_active_ = true; - this->cancel_timeout("heater_off"); + this->cancel_timeout(HEATER_OFF_TIMEOUT_ID); if (duration_ms > 0) { - this->set_timeout("heater_off", duration_ms, [this]() { this->stop_heater(); }); + this->set_timeout(HEATER_OFF_TIMEOUT_ID, duration_ms, [this]() { this->stop_heater(); }); } } void HDC302XComponent::stop_heater() { - this->cancel_timeout("heater_off"); + this->cancel_timeout(HEATER_OFF_TIMEOUT_ID); if (!this->disable_heater_()) { ESP_LOGW(TAG, "Heater stop failed"); } diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index a237f3bd609..352d3f7f01f 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -9,6 +9,8 @@ namespace esphome::honeywell_hih_i2c { ESPHOME_LOG_TAG(TAG, "honeywell_hih.i2c"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + static const uint8_t REQUEST_CMD[1] = {0x00}; // Measurement Request Format static const uint16_t MAX_COUNT = 0x3FFE; // 2^14 - 2 @@ -68,13 +70,13 @@ void HoneywellHIComponent::update() { this->start_measurement_(); // The measurement cycle duration is typically 36.65 ms for temperature and humidity readings. - this->set_timeout("meas_timeout", 100, [this] { this->measurement_timeout_(); }); + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 100, [this] { this->measurement_timeout_(); }); } void HoneywellHIComponent::loop() { if (this->measurement_running_ && this->is_measurement_ready_()) { this->measurement_running_ = false; - this->cancel_timeout("meas_timeout"); + this->cancel_timeout(MEASUREMENT_TIMEOUT_ID); this->read_sensor_data_(); } } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp index 891eb4447df..2ca3fb821cc 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp @@ -11,6 +11,8 @@ static const uint8_t STATUS_MATH_SAT = 0; ESPHOME_LOG_TAG(TAG, "honeywellabp2"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + void HONEYWELLABP2Sensor::read_sensor_data() { if (this->read(raw_data_, 7) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -62,7 +64,7 @@ float HONEYWELLABP2Sensor::get_temperature() { return this->last_temperature_; } void HONEYWELLABP2Sensor::loop() { if (this->measurement_running_) { if (this->is_measurement_ready()) { - this->cancel_timeout("meas_timeout"); + this->cancel_timeout(MEASUREMENT_TIMEOUT_ID); this->read_sensor_data(); if (pressure_sensor_ != nullptr) { @@ -79,7 +81,7 @@ void HONEYWELLABP2Sensor::update() { ESP_LOGV(TAG, "Update Honeywell ABP2 Sensor"); this->start_measurement(); - this->set_timeout("meas_timeout", 100, [this] { this->measurement_timeout(); }); + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 100, [this] { this->measurement_timeout(); }); } void HONEYWELLABP2Sensor::dump_config() { diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 0afeb94bf3b..36a7b1b3511 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -4,6 +4,9 @@ namespace esphome::hydreon_rgxx { ESPHOME_LOG_TAG(TAG, "hydreon_rgxx.sensor"); + +static constexpr uint32_t REBOOT_INTERVAL_ID = 0; + static const int MAX_DATA_LENGTH_BYTES = 80; static const uint8_t ASCII_LF = 0x0A; #define HYDREON_RGXX_COMMA , @@ -138,7 +141,7 @@ void HydreonRGxxComponent::loop() { */ void HydreonRGxxComponent::schedule_reboot_() { this->boot_count_ = 0; - this->set_interval("reboot", 5000, [this]() { + this->set_interval(REBOOT_INTERVAL_ID, 5000, [this]() { if (this->boot_count_ < 0) { ESP_LOGW(TAG, "hydreon_rgxx failed to boot %d times", -this->boot_count_); } @@ -188,7 +191,7 @@ void HydreonRGxxComponent::process_line_() { } else { this->boot_count_++; } - this->cancel_interval("reboot"); + this->cancel_interval(REBOOT_INTERVAL_ID); this->no_response_count_ = 0; ESP_LOGI(TAG, "Boot detected: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); From aba0c5d7ee4b3f87f444088a3f521157f638a0a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:14:19 -1000 Subject: [PATCH 125/435] [dallas_temp] Use a numeric scheduler id and drop the cached address string (#20361) --- esphome/components/dallas_temp/dallas_temp.cpp | 4 +++- esphome/components/one_wire/one_wire.cpp | 13 ------------- esphome/components/one_wire/one_wire.h | 13 ++++++------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 2721383ae84..45101e4981f 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -5,6 +5,8 @@ namespace esphome::dallas_temp { ESPHOME_LOG_TAG(TAG, "dallas.temp.sensor"); +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; + static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44; @@ -44,7 +46,7 @@ void DallasTemperatureSensor::update() { this->send_command_(DALLAS_COMMAND_START_CONVERSION); - this->set_timeout(this->get_address_name().c_str(), this->millis_to_wait_for_conversion_(), [this] { + this->set_timeout(CONVERSION_TIMEOUT_ID, this->millis_to_wait_for_conversion_(), [this] { if (!this->read_scratch_pad_() || !this->check_scratch_pad_()) { this->publish_state(NAN); return; diff --git a/esphome/components/one_wire/one_wire.cpp b/esphome/components/one_wire/one_wire.cpp index 65a99623490..e9504720729 100644 --- a/esphome/components/one_wire/one_wire.cpp +++ b/esphome/components/one_wire/one_wire.cpp @@ -4,19 +4,6 @@ namespace esphome::one_wire { ESPHOME_LOG_TAG(TAG, "one_wire"); -const std::string &OneWireDevice::get_address_name() { - if (this->address_name_.empty()) { - char hex_buf[19]; // "0x" + 16 hex chars + null - this->address_name_ = format_hex_prefixed_to(hex_buf, this->address_); - } - return this->address_name_; -} - -void OneWireDevice::set_address(uint64_t address) { - this->address_ = address; - this->address_name_.clear(); -} - bool OneWireDevice::send_command_(uint8_t cmd) { if (!this->bus_->select(this->address_)) return false; diff --git a/esphome/components/one_wire/one_wire.h b/esphome/components/one_wire/one_wire.h index 4dbbe117927..e3abb95bb70 100644 --- a/esphome/components/one_wire/one_wire.h +++ b/esphome/components/one_wire/one_wire.h @@ -7,14 +7,17 @@ namespace esphome::one_wire { #define LOG_ONE_WIRE_DEVICE(this) \ - ESP_LOGCONFIG(TAG, " Address: %s (%s)", this->get_address_name().c_str(), \ - LOG_STR_ARG(this->bus_->get_model_str(this->address_ & 0xff))); + do { \ + char address_buf[format_hex_prefixed_size(sizeof(uint64_t))]; \ + ESP_LOGCONFIG(TAG, " Address: %s (%s)", format_hex_prefixed_to(address_buf, (this)->address_), \ + LOG_STR_ARG((this)->bus_->get_model_str((this)->address_ & 0xff))); \ + } while (0) class OneWireDevice { public: /// @brief store the address of the device /// @param address of the device - void set_address(uint64_t address); + void set_address(uint64_t address) { this->address_ = address; } void set_index(uint8_t index) { this->index_ = index; } @@ -22,16 +25,12 @@ class OneWireDevice { /// @param bus pointer to the OneWireBus object void set_one_wire_bus(OneWireBus *bus) { this->bus_ = bus; } - /// Helper to create (and cache) the name for this sensor. For example "0xfe0000031f1eaf29". - const std::string &get_address_name(); - protected: static constexpr uint8_t INDEX_NOT_SET = 255; uint64_t address_{0}; uint8_t index_{INDEX_NOT_SET}; OneWireBus *bus_{nullptr}; ///< pointer to OneWireBus instance - std::string address_name_; /// @brief find an address if necessary /// should be called from setup From 558de5ebee615f70670a5910d9875a708424713d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:14:39 -1000 Subject: [PATCH 126/435] [core] Use numeric scheduler ids instead of string names (5/5) (#20378) --- esphome/components/ufire_ec/ufire_ec.cpp | 4 +++- esphome/components/ufire_ise/ufire_ise.cpp | 4 +++- esphome/components/veml3235/veml3235.cpp | 4 +++- esphome/components/xgzp68xx/xgzp68xx.cpp | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index afa806d2466..1bb12229417 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -6,6 +6,8 @@ namespace esphome::ufire_ec { ESPHOME_LOG_TAG(TAG, "ufire_ec"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + void UFireECComponent::setup() { uint8_t version; if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) { @@ -45,7 +47,7 @@ void UFireECComponent::update() { } if (wait > 0) { - this->set_timeout("data", wait, [this]() { this->update_internal_(); }); + this->set_timeout(DATA_TIMEOUT_ID, wait, [this]() { this->update_internal_(); }); } } diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index c0fa7154859..aa899968e44 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -7,6 +7,8 @@ namespace esphome::ufire_ise { ESPHOME_LOG_TAG(TAG, "ufire_ise"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + void UFireISEComponent::setup() { uint8_t version; if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) { @@ -38,7 +40,7 @@ void UFireISEComponent::update() { } // Wait until measurement are taken - this->set_timeout("data", wait, [this]() { this->update_internal_(); }); + this->set_timeout(DATA_TIMEOUT_ID, wait, [this]() { this->update_internal_(); }); } void UFireISEComponent::update_internal_() { diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 3d555bc469a..3a856696782 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -6,6 +6,8 @@ namespace esphome::veml3235 { ESPHOME_LOG_TAG(TAG, "veml3235.sensor"); +static constexpr uint32_t REREAD_TIMEOUT_ID = 0; + // ADC counts at or above this value (98% of full scale) are treated as clipped: the true light level cannot // be estimated from such a reading, so auto-gain restarts from minimum sensitivity instead static const uint16_t CLIPPED_COUNTS = 64224; @@ -71,7 +73,7 @@ void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { const uint32_t old_integration_time_ms = this->integration_time_ms_(); if (this->adjust_sensitivity_(als_counts)) { const uint32_t wait_ms = old_integration_time_ms + 2 * this->integration_time_ms_(); - this->set_timeout("reread", wait_ms, + this->set_timeout(REREAD_TIMEOUT_ID, wait_ms, [this, adjustments_left]() { this->read_and_publish_(adjustments_left - 1); }); return; } diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 35e1ed950b8..795db448f0b 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -10,6 +10,8 @@ namespace esphome::xgzp68xx { ESPHOME_LOG_TAG(TAG, "xgzp68xx.sensor"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + static const uint8_t CMD_ADDRESS = 0x30; static const uint8_t SYSCONFIG_ADDRESS = 0xA5; static const uint8_t PCONFIG_ADDRESS = 0xA6; @@ -54,7 +56,7 @@ void XGZP68XXComponent::update() { this->write_register(0x30, &READ_COMMAND, 1); // Wait 20mS per datasheet - this->set_timeout("measurement", 20, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 20, [this]() { uint8_t data[5] = {}; uint32_t pressure_raw = 0; uint16_t temperature_raw = 0; From cea872be0baa370e039a6c557e8654eda7a1dfe1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:14:59 -1000 Subject: [PATCH 127/435] [modbus] Use a numeric scheduler id for the deferred server reply (#20368) --- esphome/components/modbus/modbus.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 7c56d2340ff..80065546cf7 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -11,6 +11,8 @@ namespace esphome::modbus { ESPHOME_LOG_TAG(TAG, "modbus"); +static constexpr uint32_t DEFERRED_SEND_TIMEOUT_ID = 0; + static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; static constexpr uint32_t US_PER_SEC = 1000000; @@ -205,7 +207,7 @@ void ModbusServerHub::parse_modbus_frames() { while (!this->rx_buffer_.empty()) { if (this->deferred_payload_len_ != 0) { // Another frame arrived before the deferred reply went out, so the client has moved on. - this->cancel_timeout("deferred_send"); + this->cancel_timeout(DEFERRED_SEND_TIMEOUT_ID); ESP_LOGD(TAG, "Dropped deferred reply to %" PRIu8 ": a new frame arrived first", this->deferred_payload_[0]); this->deferred_payload_len_ = 0; } @@ -1216,7 +1218,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { std::memcpy(this->deferred_payload_.data(), payload, len); this->deferred_payload_len_ = len; // set_timeout() takes milliseconds; round the microsecond delay up so we never fire early. - this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { + this->set_timeout(DEFERRED_SEND_TIMEOUT_ID, (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); this->deferred_payload_len_ = 0; From b6ee8f8035370025c5bafa7737d9d6f3cd446b65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:15:14 -1000 Subject: [PATCH 128/435] [power_supply] Use a numeric scheduler id for the power off timeout (#20367) --- esphome/components/power_supply/power_supply.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 7daee1663a4..3abffc88e15 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -5,6 +5,8 @@ namespace esphome::power_supply { ESPHOME_LOG_TAG(TAG, "power_supply"); +static constexpr uint32_t POWER_OFF_TIMEOUT_ID = 0; + void PowerSupply::setup() { this->pin_->setup(); this->pin_->digital_write(false); @@ -31,7 +33,7 @@ bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } void PowerSupply::request_high_power() { if (this->active_requests_ == 0) { - this->cancel_timeout("power-supply-off"); + this->cancel_timeout(POWER_OFF_TIMEOUT_ID); ESP_LOGV(TAG, "Enabling"); this->pin_->digital_write(true); delay(this->enable_time_); @@ -46,7 +48,7 @@ void PowerSupply::unrequest_high_power() { } this->active_requests_--; if (this->active_requests_ == 0) { - this->set_timeout("power-supply-off", this->keep_on_time_, [this]() { + this->set_timeout(POWER_OFF_TIMEOUT_ID, this->keep_on_time_, [this]() { ESP_LOGV(TAG, "Disabling"); this->pin_->digital_write(false); }); From 279f68c4324233d01e76b6934ac9ae0d08d6de27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:15:33 -1000 Subject: [PATCH 129/435] [tuya] Use numeric scheduler ids instead of string names (#20365) --- esphome/components/tuya/tuya.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 992dde15336..784aea5c50b 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -19,6 +19,10 @@ namespace esphome::tuya { ESPHOME_LOG_TAG(TAG, "tuya"); + +static constexpr uint32_t HEARTBEAT_INTERVAL_ID = 0; +static constexpr uint32_t WIFI_STATUS_INTERVAL_ID = 1; +static constexpr uint32_t DATAPOINT_DUMP_TIMEOUT_ID = 2; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; @@ -34,7 +38,7 @@ static bool network_is_connected() { } void Tuya::setup() { - this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); + this->set_interval(HEARTBEAT_INTERVAL_ID, 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); if (this->status_pin_ != nullptr) { this->status_pin_->digital_write(false); } @@ -223,7 +227,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff this->status_pin_reported_); } ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + this->set_interval(WIFI_STATUS_INTERVAL_ID, 1000, [this] { this->set_status_pin_(); }); } else { ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); @@ -231,7 +235,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); - this->set_interval("wifi", 1000, [this] { this->send_wifi_status_(); }); + this->set_interval(WIFI_STATUS_INTERVAL_ID, 1000, [this] { this->send_wifi_status_(); }); } } break; @@ -279,7 +283,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff case TuyaCommandType::DATAPOINT_REPORT_SYNC: if (this->init_state_ == TuyaInitState::INIT_DATAPOINT) { this->init_state_ = TuyaInitState::INIT_DONE; - this->set_timeout("datapoint_dump", 1000, [this] { this->dump_config(); }); + this->set_timeout(DATAPOINT_DUMP_TIMEOUT_ID, 1000, [this] { this->dump_config(); }); this->initialized_callback_.call(); } this->handle_datapoints_(buffer, len); From f89a60358295e1683c566a1d318599e441991af5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:15:59 -1000 Subject: [PATCH 130/435] [esp8266] Keep switch lookup tables out of RAM (#20398) --- esphome/components/esp8266/__init__.py | 2 ++ tests/component_tests/esp8266/__init__.py | 0 .../component_tests/esp8266/config/minimal.yaml | 5 +++++ .../component_tests/esp8266/test_build_flags.py | 17 +++++++++++++++++ 4 files changed, 24 insertions(+) create mode 100644 tests/component_tests/esp8266/__init__.py create mode 100644 tests/component_tests/esp8266/config/minimal.yaml create mode 100644 tests/component_tests/esp8266/test_build_flags.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d975d08f25e..d95cbd239b5 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -444,6 +444,8 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO") cg.add_build_flag("-Wno-nonnull-compare") + # .rodata is linked into RAM on ESP8266; keep switch lookup tables (CSWTCH) out of it + cg.add_build_flag("-fno-tree-switch-conversion") if use_platformio: cg.add_platformio_option("framework", "arduino") cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) diff --git a/tests/component_tests/esp8266/__init__.py b/tests/component_tests/esp8266/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp8266/config/minimal.yaml b/tests/component_tests/esp8266/config/minimal.yaml new file mode 100644 index 00000000000..79fde0157b5 --- /dev/null +++ b/tests/component_tests/esp8266/config/minimal.yaml @@ -0,0 +1,5 @@ +esphome: + name: test + +esp8266: + board: d1_mini diff --git a/tests/component_tests/esp8266/test_build_flags.py b/tests/component_tests/esp8266/test_build_flags.py new file mode 100644 index 00000000000..938089333bd --- /dev/null +++ b/tests/component_tests/esp8266/test_build_flags.py @@ -0,0 +1,17 @@ +"""Tests for ESP8266 build flags.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_esp8266_keeps_switches_out_of_ram( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("minimal.yaml")) + + assert "-fno-tree-switch-conversion" in CORE.build_flags From f5ce33a8debc727d2ce9c9480f6020771332fec0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:16:17 -1000 Subject: [PATCH 131/435] [esp32_ble_client] Track conn_id per attempt and stop logging an error on a failed open (#20417) --- .../esp32_ble_client/ble_client_base.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 68fccac2880..dde47b0c5e4 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -150,6 +150,8 @@ void BLEClientBase::connect() { // A registration whose event never arrived must not block this connection's release. this->services_released_ = false; this->pending_notify_regs_ = 0; + // A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own. + this->conn_id_ = UNSET_CONN_ID; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_gattc_lifecycle_event_("OPEN"); - // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an @@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } - if (this->state() != espbt::ClientState::CONNECTING) { - // This should not happen but lets log it in case it does - // because it means we have a bad assumption about how the - // ESP BT stack works. + const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN; + // Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT, + // so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption + // about how the ESP BT stack works, so log it. + const bool expected_failure = + open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID; + if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) { ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state()), param->open.status); } - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + if (open_failed) { this->log_gattc_warning_("Connection open", param->open.status); // Connection was never established so CLOSE_EVT may not follow this->set_idle_(); break; } + // An open on a link that is already up sends no CONNECT_EVT, so take the id here. + this->conn_id_ = param->open.conn_id; if (this->want_disconnect_) { // Disconnect was requested after connecting started, // but before the connection was established. Now that we have From 0701329ea73e2ae03a43806966380f3307436731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:20:59 -1000 Subject: [PATCH 132/435] [wifi] Use numeric scheduler ids instead of string names (#20357) --- esphome/components/wifi/automation.h | 11 +++++++---- esphome/components/wifi/wifi_component.cpp | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index c14341330f5..eb3d7471dc1 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -76,13 +76,13 @@ template class WiFiConfigureAction final : public Action, // Enable WiFi global_wifi_component->enable(); // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { + this->set_timeout(CONNECT_TIMEOUT_ID, this->connection_timeout_.value(x...), [this, x...]() { // If the timeout is reached, stop connecting and revert to the old AP global_wifi_component->disable(); global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); global_wifi_component->enable(); // Start a timeout for the fallback if the connection to the old AP fails - this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->set_timeout(FALLBACK_TIMEOUT_ID, this->connection_timeout_.value(x...), [this]() { this->connecting_ = false; this->error_trigger_.trigger(); }); @@ -97,8 +97,8 @@ template class WiFiConfigureAction final : public Action, return; if (global_wifi_component->is_connected()) { // The WiFi is connected, stop the timeout and reset the connecting flag - this->cancel_timeout("wifi-connect-timeout"); - this->cancel_timeout("wifi-fallback-timeout"); + this->cancel_timeout(CONNECT_TIMEOUT_ID); + this->cancel_timeout(FALLBACK_TIMEOUT_ID); this->connecting_ = false; char ssid_buf[SSID_BUFFER_SIZE]; if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), this->new_sta_.get_ssid().c_str()) == 0) { @@ -112,6 +112,9 @@ template class WiFiConfigureAction final : public Action, } protected: + static constexpr uint32_t CONNECT_TIMEOUT_ID = 0; + static constexpr uint32_t FALLBACK_TIMEOUT_ID = 1; + bool connecting_{false}; WiFiAP new_sta_; WiFiAP old_sta_; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ef8b22d2395..001dae98b83 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -398,6 +398,7 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; /// disconnect event cannot keep the networks from being tried for good. static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; #endif /// Cooldown duration when fallback AP is active and captive portal may be running @@ -1222,7 +1223,7 @@ void WiFiComponent::connect_soon_() { // New credentials from the portal: the AP pauses for them once its answer // is out, and the STA state machine picks them up from there. if (this->ap_setup_) { - this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { if (this->ap_setup_) this->pause_exclusive_ap_(); }); From d98ddeeb563aec9f599dda39c20dc8b1759835ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:29 -1000 Subject: [PATCH 133/435] [mqtt] Use a numeric scheduler id for the deferred state publish (#20359) --- esphome/components/mqtt/mqtt_component.h | 2 ++ esphome/components/mqtt/mqtt_fan.cpp | 2 +- esphome/components/mqtt/mqtt_light.cpp | 2 +- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index b4ae6244048..141b5f12b38 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -273,6 +273,8 @@ class MQTTComponent : public Component { void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); protected: + static constexpr uint32_t SEND_DEFER_ID = 0; + /// Helper method to get the discovery topic for this component into a buffer. StringRef get_discovery_topic_to_(std::span buf, const MQTTDiscoveryInfo &discovery_info) const; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 90f51cf6cec..daf2a511dcc 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -121,7 +121,7 @@ void MQTTFanComponent::setup() { }); } - this->state_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->state_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTFanComponent::dump_config() { diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index e9058eb642d..e1affae8a24 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -28,7 +28,7 @@ void MQTTJSONLightComponent::setup() { } void MQTTJSONLightComponent::on_light_remote_values_update() { - this->defer("send", [this]() { this->publish_state_(); }); + this->defer(SEND_DEFER_ID, [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 8d8b4c38e88..4f81045fdc3 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -29,7 +29,7 @@ void MQTTLockComponent::setup() { } }); this->lock_->add_on_state_callback( - [this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); }); + [this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTLockComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index b786b4412e6..17ba86a9187 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -34,7 +34,7 @@ void MQTTSwitchComponent::setup() { } }); this->switch_->add_on_state_callback( - [this](bool enabled) { this->defer("send", [this, enabled]() { this->publish_state(enabled); }); }); + [this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); }); } void MQTTSwitchComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index a4565058ea2..eac45d30b71 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -24,7 +24,7 @@ void MQTTUpdateComponent::setup() { } }); - this->update_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->update_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } bool MQTTUpdateComponent::publish_state() { From cf0e64f84cbd800fe90740e942af767b89d697eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:53 -1000 Subject: [PATCH 134/435] [improv_serial] Use a numeric scheduler id for the Wi-Fi connect timeout (#20360) --- .../components/improv_serial/improv_serial_component.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ecb7bc98fb4..10454678669 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -18,6 +18,8 @@ namespace esphome::improv_serial { ESPHOME_LOG_TAG(TAG, "improv_serial"); +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; + void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_IMPROV_SERIAL_UART @@ -73,7 +75,7 @@ void ImprovSerialComponent::loop() { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); this->set_state_(improv::STATE_PROVISIONED); this->send_settings_response_(improv::WIFI_SETTINGS); @@ -302,7 +304,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, [this]() { this->on_wifi_connect_timeout_(); }); #else // No Wi-Fi support compiled in; there is nothing to provision. From 11368a0b16cd82f4276da41eb613e3655c4b3f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:22:20 -1000 Subject: [PATCH 135/435] [adc] Collapse duplicate CI test variants (#20400) --- tests/components/adc/test.nrf52-mcumgr.yaml | 23 ------------------- ...72xx-ard.yaml => validate.bk72xx-ard.yaml} | 2 ++ ...p4-idf.yaml => validate.esp32-p4-idf.yaml} | 2 ++ ...s2-idf.yaml => validate.esp32-s2-idf.yaml} | 3 +++ 4 files changed, 7 insertions(+), 23 deletions(-) delete mode 100644 tests/components/adc/test.nrf52-mcumgr.yaml rename tests/components/adc/{test.bk72xx-ard.yaml => validate.bk72xx-ard.yaml} (64%) rename tests/components/adc/{test.esp32-p4-idf.yaml => validate.esp32-p4-idf.yaml} (67%) rename tests/components/adc/{test.esp32-s2-idf.yaml => validate.esp32-s2-idf.yaml} (58%) diff --git a/tests/components/adc/test.nrf52-mcumgr.yaml b/tests/components/adc/test.nrf52-mcumgr.yaml deleted file mode 100644 index 4be5b4b5c20..00000000000 --- a/tests/components/adc/test.nrf52-mcumgr.yaml +++ /dev/null @@ -1,23 +0,0 @@ -sensor: - - platform: adc - pin: VDDHDIV5 - name: "VDDH Voltage" - update_interval: 5sec - filters: - - multiply: 5 - - platform: adc - pin: VDD - name: "VDD Voltage" - update_interval: 5sec - - platform: adc - pin: AIN0 - name: "AIN0 Voltage" - update_interval: 5sec - - platform: adc - pin: P0.03 - name: "AIN1 Voltage" - update_interval: 5sec - - platform: adc - name: "AIN2 Voltage" - update_interval: 5sec - pin: 4 diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/validate.bk72xx-ard.yaml similarity index 64% rename from tests/components/adc/test.bk72xx-ard.yaml rename to tests/components/adc/validate.bk72xx-ard.yaml index 09ef0e1fad8..0064276e308 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/validate.bk72xx-ard.yaml @@ -1,3 +1,5 @@ +# Config only: every LibreTiny chip compiles the same ADC code, covered by the +# ln882x-ard build; this checks the BK72xx analog pin names. sensor: - id: adc_my_sensor platform: adc diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/validate.esp32-p4-idf.yaml similarity index 67% rename from tests/components/adc/test.esp32-p4-idf.yaml rename to tests/components/adc/validate.esp32-p4-idf.yaml index 77cf50d17ce..708ab03f764 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/validate.esp32-p4-idf.yaml @@ -1,3 +1,5 @@ +# Config only: the P4 compiles the same ADC code as the esp32-s3-idf build +# (curve fitting calibration), only the pin table differs. sensor: - id: adc_my_sensor platform: adc diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/validate.esp32-s2-idf.yaml similarity index 58% rename from tests/components/adc/test.esp32-s2-idf.yaml rename to tests/components/adc/validate.esp32-s2-idf.yaml index a3019466b55..b8d6923bc21 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/validate.esp32-s2-idf.yaml @@ -1,3 +1,6 @@ +# Config only: the S2 shares the C2's line fitting path (no default_vref) and +# the original ESP32's ADC init (no clk_src); both are compiled by those +# builds, only the S2 pin table differs. sensor: - id: adc_my_sensor platform: adc From 3eb8155ed233029ffc7b3df488306378cbee7c1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:22:48 -1000 Subject: [PATCH 136/435] [logger] Collapse duplicate CI test variants (#20402) --- .../components/logger/test-custom_buffer_size.esp32-idf.yaml | 5 ----- .../components/logger/test-disable_log_buffer.esp32-idf.yaml | 5 ----- tests/components/logger/test-uart0.ln882x-ard.yaml | 2 -- tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml | 1 - tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml | 1 - .../components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml | 1 - .../components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml | 1 - tests/components/logger/test.esp32-c3-idf.yaml | 4 ++++ .../logger/{test-on_message.host.yaml => test.host.yaml} | 0 tests/components/logger/test.ln882x-ard.yaml | 1 + ...sp32-idf.yaml => validate-max_buffer_size.esp32-idf.yaml} | 0 ...sp32-idf.yaml => validate-min_buffer_size.esp32-idf.yaml} | 0 12 files changed, 5 insertions(+), 16 deletions(-) delete mode 100644 tests/components/logger/test-custom_buffer_size.esp32-idf.yaml delete mode 100644 tests/components/logger/test-disable_log_buffer.esp32-idf.yaml delete mode 100644 tests/components/logger/test-uart0.ln882x-ard.yaml delete mode 100644 tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml delete mode 100644 tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml delete mode 100644 tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml delete mode 100644 tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml rename tests/components/logger/{test-on_message.host.yaml => test.host.yaml} (100%) rename tests/components/logger/{test-max_buffer_size.esp32-idf.yaml => validate-max_buffer_size.esp32-idf.yaml} (100%) rename tests/components/logger/{test-min_buffer_size.esp32-idf.yaml => validate-min_buffer_size.esp32-idf.yaml} (100%) diff --git a/tests/components/logger/test-custom_buffer_size.esp32-idf.yaml b/tests/components/logger/test-custom_buffer_size.esp32-idf.yaml deleted file mode 100644 index 9a396ca0230..00000000000 --- a/tests/components/logger/test-custom_buffer_size.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -<<: !include common-default_uart.yaml - -logger: - id: logger_id - task_log_buffer_size: 1024B # Set a custom buffer size diff --git a/tests/components/logger/test-disable_log_buffer.esp32-idf.yaml b/tests/components/logger/test-disable_log_buffer.esp32-idf.yaml deleted file mode 100644 index 4260f178f95..00000000000 --- a/tests/components/logger/test-disable_log_buffer.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -<<: !include common-default_uart.yaml - -logger: - id: logger_id - task_log_buffer_size: 0 diff --git a/tests/components/logger/test-uart0.ln882x-ard.yaml b/tests/components/logger/test-uart0.ln882x-ard.yaml deleted file mode 100644 index 864d2b96dea..00000000000 --- a/tests/components/logger/test-uart0.ln882x-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -logger: - hardware_uart: UART0 diff --git a/tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml b/tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml deleted file mode 100644 index cfdaec97715..00000000000 --- a/tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_cdc.yaml diff --git a/tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml b/tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml deleted file mode 100644 index cfdaec97715..00000000000 --- a/tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_cdc.yaml diff --git a/tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml b/tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml b/tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/logger/test.esp32-c3-idf.yaml b/tests/components/logger/test.esp32-c3-idf.yaml index 3fe04e18a30..4260f178f95 100644 --- a/tests/components/logger/test.esp32-c3-idf.yaml +++ b/tests/components/logger/test.esp32-c3-idf.yaml @@ -1 +1,5 @@ <<: !include common-default_uart.yaml + +logger: + id: logger_id + task_log_buffer_size: 0 diff --git a/tests/components/logger/test-on_message.host.yaml b/tests/components/logger/test.host.yaml similarity index 100% rename from tests/components/logger/test-on_message.host.yaml rename to tests/components/logger/test.host.yaml diff --git a/tests/components/logger/test.ln882x-ard.yaml b/tests/components/logger/test.ln882x-ard.yaml index c5bc9de6e87..d5c62d298a7 100644 --- a/tests/components/logger/test.ln882x-ard.yaml +++ b/tests/components/logger/test.ln882x-ard.yaml @@ -2,4 +2,5 @@ logger: id: logger_id + hardware_uart: UART0 task_log_buffer_size: 1024B diff --git a/tests/components/logger/test-max_buffer_size.esp32-idf.yaml b/tests/components/logger/validate-max_buffer_size.esp32-idf.yaml similarity index 100% rename from tests/components/logger/test-max_buffer_size.esp32-idf.yaml rename to tests/components/logger/validate-max_buffer_size.esp32-idf.yaml diff --git a/tests/components/logger/test-min_buffer_size.esp32-idf.yaml b/tests/components/logger/validate-min_buffer_size.esp32-idf.yaml similarity index 100% rename from tests/components/logger/test-min_buffer_size.esp32-idf.yaml rename to tests/components/logger/validate-min_buffer_size.esp32-idf.yaml From b5716a70c2a40b9c2e3d7e29fe014d97a6d26055 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:26:47 -1000 Subject: [PATCH 137/435] [ethernet] Remove deprecated clk_mode option (#20384) --- esphome/components/ethernet/__init__.py | 39 +++++-------------- .../fixtures/bluetooth_proxy_device.yaml | 4 +- .../component_tests/ethernet/test_ethernet.py | 38 ++++++++++++++++++ 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index bc26db99565..58915ff96db 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -import logging from esphome import automation, pins from esphome.automation import Condition @@ -62,7 +61,6 @@ from esphome.types import ConfigType AUTO_LOAD = ["network"] DOMAIN = "ethernet" -LOGGER = logging.getLogger(__name__) # Key for tracking IP state listener count in CORE.data ETHERNET_IP_STATE_LISTENERS_KEY = "ethernet_ip_state_listeners" @@ -214,13 +212,6 @@ CLK_MODES = { "CLK_OUT": emac_rmii_clock_mode_t.EMAC_CLK_OUT, } -CLK_MODES_DEPRECATED = { - "GPIO0_IN": ("CLK_EXT_IN", 0), - "GPIO0_OUT": ("CLK_OUT", 0), - "GPIO16_OUT": ("CLK_OUT", 16), - "GPIO17_OUT": ("CLK_OUT", 17), -} - spi_host_device_t = cg.global_ns.enum("spi_host_device_t") SPI_INTERFACE_MAP = { @@ -387,23 +378,6 @@ def _validate(config: ConfigType) -> ConfigType: get_esp32_variant, ) - if CONF_CLK_MODE in config: - mode, pin = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] - LOGGER.warning( - "[ethernet] The 'clk_mode' option is deprecated. " - "Please replace 'clk_mode: %s' with:\n" - " clk:\n" - " mode: %s\n" - " pin: %s\n" - "Removal scheduled for 2026.11.0.", - config[CONF_CLK_MODE], - mode, - pin, - ) - config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode, CONF_PIN: pin}) - del config[CONF_CLK_MODE] - elif CONF_CLK not in config: - raise cv.Invalid("'clk' is a required option for [ethernet].") variant = get_esp32_variant() if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): raise cv.Invalid( @@ -452,10 +426,17 @@ RMII_SCHEMA = cv.All( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLK_MODE): cv.enum( - CLK_MODES_DEPRECATED, upper=True, space="_" + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_CLK_MODE): cv.invalid( + "The 'clk_mode' option has been removed in ESPHome 2026.11.0.\n" + "Replace it with a 'clk:' block containing 'mode:' and 'pin:':\n" + " GPIO0_IN -> mode: CLK_EXT_IN, pin: GPIO0\n" + " GPIO0_OUT -> mode: CLK_OUT, pin: GPIO0\n" + " GPIO16_OUT -> mode: CLK_OUT, pin: GPIO16\n" + " GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17" ), - cv.Optional(CONF_CLK): CLK_SCHEMA, + cv.Required(CONF_CLK): CLK_SCHEMA, cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), diff --git a/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml index dfa5a487b85..c74846689bb 100644 --- a/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml +++ b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml @@ -25,7 +25,9 @@ ethernet: type: LAN8720 mdc_pin: GPIO23 mdio_pin: GPIO18 - clk_mode: GPIO17_OUT + clk: + mode: CLK_OUT + pin: GPIO17 phy_addr: 0 power_pin: GPIO12 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py index 522357f972f..73985597d9f 100644 --- a/tests/component_tests/ethernet/test_ethernet.py +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -8,6 +8,7 @@ from esphome.components.esp32 import ( KEY_BOARD, KEY_IDF_VERSION, KEY_VARIANT, + VARIANT_ESP32, VARIANT_ESP32S3, ) from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate @@ -82,3 +83,40 @@ def test_ch390_rejects_clock_speed_above_the_datasheet_maximum( CORE.name = "ch390-test" with pytest.raises(Invalid, match="value must be at most 72000000"): CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) + + +_LAN8720_CONFIG = {"type": "LAN8720", "mdc_pin": 23, "mdio_pin": 18} + + +def _set_esp32_core(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32dev", + KEY_VARIANT: VARIANT_ESP32, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + CORE.name = "rmii-test" + + +def test_rmii_rejects_removed_clk_mode( + set_core_config: SetCoreConfigCallable, +) -> None: + """The removed clk_mode option points at the clk: replacement.""" + _set_esp32_core(set_core_config) + with pytest.raises(Invalid, match="GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17"): + CONFIG_SCHEMA( + { + **_LAN8720_CONFIG, + "clk_mode": "GPIO17_OUT", + "clk": {"mode": "CLK_OUT", "pin": 17}, + } + ) + + +def test_rmii_requires_clk(set_core_config: SetCoreConfigCallable) -> None: + """An RMII PHY has no default clock, so clk must be given.""" + _set_esp32_core(set_core_config) + with pytest.raises(Invalid, match=r"required key not provided @ data\['clk'\]"): + CONFIG_SCHEMA(_LAN8720_CONFIG) From 52b274c5e8a7af1e4d1fcc6a5a8127340b909d4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:34:34 -1000 Subject: [PATCH 138/435] [improv_serial] Collapse duplicate CI test variants (#20405) --- .../components/improv_serial/common-usb_serial_jtag.yaml | 8 -------- .../improv_serial/{common-uart0.yaml => common.yaml} | 0 .../improv_serial/test-uart-bus.esp8266-ard.yaml | 3 --- .../components/improv_serial/test-uart0.esp32-c3-idf.yaml | 1 - tests/components/improv_serial/test-uart0.esp32-idf.yaml | 1 - .../components/improv_serial/test-uart0.esp32-s2-idf.yaml | 1 - .../components/improv_serial/test-uart0.esp32-s3-idf.yaml | 1 - .../components/improv_serial/test-uart0.esp8266-ard.yaml | 1 - tests/components/improv_serial/test-uart0.rp2040-ard.yaml | 1 - .../improv_serial/test-usb_cdc.esp32-s2-idf.yaml | 3 ++- .../components/improv_serial/test-usb_cdc.rp2040-ard.yaml | 1 - .../improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml | 1 - .../improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml | 1 - tests/components/improv_serial/test.esp32-c3-idf.yaml | 2 ++ tests/components/improv_serial/test.esp32-idf.yaml | 2 ++ tests/components/improv_serial/test.esp8266-ard.yaml | 2 ++ tests/components/improv_serial/test.rp2040-ard.yaml | 2 ++ 17 files changed, 10 insertions(+), 21 deletions(-) delete mode 100644 tests/components/improv_serial/common-usb_serial_jtag.yaml rename tests/components/improv_serial/{common-uart0.yaml => common.yaml} (100%) delete mode 100644 tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp8266-ard.yaml delete mode 100644 tests/components/improv_serial/test-uart0.rp2040-ard.yaml delete mode 100644 tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml delete mode 100644 tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml delete mode 100644 tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml create mode 100644 tests/components/improv_serial/test.esp32-c3-idf.yaml create mode 100644 tests/components/improv_serial/test.esp32-idf.yaml create mode 100644 tests/components/improv_serial/test.esp8266-ard.yaml create mode 100644 tests/components/improv_serial/test.rp2040-ard.yaml diff --git a/tests/components/improv_serial/common-usb_serial_jtag.yaml b/tests/components/improv_serial/common-usb_serial_jtag.yaml deleted file mode 100644 index 0abcc071686..00000000000 --- a/tests/components/improv_serial/common-usb_serial_jtag.yaml +++ /dev/null @@ -1,8 +0,0 @@ -wifi: - ssid: MySSID - password: password1 - -logger: - hardware_uart: USB_SERIAL_JTAG - -improv_serial: diff --git a/tests/components/improv_serial/common-uart0.yaml b/tests/components/improv_serial/common.yaml similarity index 100% rename from tests/components/improv_serial/common-uart0.yaml rename to tests/components/improv_serial/common.yaml diff --git a/tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml b/tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml deleted file mode 100644 index 40a6b7f4fe2..00000000000 --- a/tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml - improv_serial: !include common-uart-bus.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp8266-ard.yaml b/tests/components/improv_serial/test-uart0.esp8266-ard.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp8266-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.rp2040-ard.yaml b/tests/components/improv_serial/test-uart0.rp2040-ard.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml b/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml index cfdaec97715..f4217b994d1 100644 --- a/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml +++ b/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml @@ -1 +1,2 @@ -<<: !include common-usb_cdc.yaml +packages: + improv_serial: !include common-usb_cdc.yaml diff --git a/tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml b/tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml deleted file mode 100644 index cfdaec97715..00000000000 --- a/tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_cdc.yaml diff --git a/tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml b/tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml b/tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/improv_serial/test.esp32-c3-idf.yaml b/tests/components/improv_serial/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.esp32-c3-idf.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml diff --git a/tests/components/improv_serial/test.esp32-idf.yaml b/tests/components/improv_serial/test.esp32-idf.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml diff --git a/tests/components/improv_serial/test.esp8266-ard.yaml b/tests/components/improv_serial/test.esp8266-ard.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml diff --git a/tests/components/improv_serial/test.rp2040-ard.yaml b/tests/components/improv_serial/test.rp2040-ard.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml From daea315b13d1c0c1be6de0fc06092e13d628e355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:36:42 -1000 Subject: [PATCH 139/435] [nrf52] Fake a larger flash in testing mode (#20439) --- esphome/components/nrf52/__init__.py | 38 ++++++++++++++++++- .../components/zephyr_mcumgr/ota/__init__.py | 8 ++-- .../nrf52/test_testing_mode_flash.py | 30 +++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/components/nrf52/test_testing_mode_flash.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index dc9800a3617..7cd0c6cdc1b 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -12,6 +12,7 @@ from esphome.build_helpers import pch from esphome.build_helpers.ccache import resolve_ccache_path import esphome.codegen as cg from esphome.components.zephyr import ( + Section, add_extra_script, copy_files as zephyr_copy_files, zephyr_add_overlay, @@ -126,11 +127,37 @@ def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: - zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]) + sections = BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]] + if CORE.testing_mode: + sections = _testing_mode_sections(sections) + zephyr_add_pm_static(sections) return config +# In testing mode, fake a larger flash to allow linking grouped component +# tests. The nRF52840 has 1 MB and a mcumgr OTA config halves the app slot, +# which an openthread config alone fills; CI images are never flashed. +NRF52840_FLASH_SIZE = 0x100000 +TESTING_FLASH_SIZE = 0x400000 + + +def _testing_mode_sections(sections: list[Section]) -> list[Section]: + """Move a bootloader pinned to the end of the real flash to the end of the + faked one, so the partition manager still sees a single gap for the app.""" + return [ + Section( + section.name, + section.address + TESTING_FLASH_SIZE - NRF52840_FLASH_SIZE, + section.size, + section.region, + ) + if section.end_address == NRF52840_FLASH_SIZE + else section + for section in sections + ] + + _TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF) _resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF) @@ -381,6 +408,15 @@ async def to_code(config: ConfigType) -> None: zephyr_setup_preferences() zephyr_to_code(config) + if CORE.testing_mode: + zephyr_add_overlay( + f""" + &flash0 {{ + reg = <0x0 {TESTING_FLASH_SIZE:#x}>; + }}; + """ + ) + if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index f5ab04809e4..d4f84e30f24 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -13,6 +13,7 @@ from esphome.components.zephyr import ( from esphome.components.zephyr.const import ( BOOTLOADER_MCUBOOT, KEY_BOOTLOADER, + KEY_PM_STATIC, KEY_SYSBUILD, ) from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu @@ -207,10 +208,11 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - sections = BOOTLOADER_CONFIG[bootloader] - # Derive partition addresses from the SoftDevice and bootloader sections so - # that the DTS flash map matches what the Partition Manager produces: + # Derive partition addresses from the SoftDevice and bootloader sections + # registered with the Partition Manager (moved in testing mode) so that + # the DTS flash map matches what it produces: # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) bl_start = next(s.address for s in sections if "Adafruit" in s.name) diff --git a/tests/unit_tests/components/nrf52/test_testing_mode_flash.py b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py new file mode 100644 index 00000000000..7c66cd3361b --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py @@ -0,0 +1,30 @@ +"""Testing mode fakes a larger flash so grouped component tests link.""" + +from esphome.components import nrf52 +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr import Section + + +def test_testing_mode_sections_move_only_the_bootloader() -> None: + """The bootloader pinned to the end of flash follows the faked end; the + SoftDevice at the start stays.""" + sections = nrf52._testing_mode_sections( + BOOTLOADER_CONFIG[BOOTLOADER_ADAFRUIT_NRF52_SD140_V7] + ) + + assert [(s.name, s.address, s.size) for s in sections] == [ + ("SoftDevice", 0x0, 0x27000), + ( + "Adafruit_nRF52_Bootloader", + nrf52.TESTING_FLASH_SIZE - 0xC000, + 0xC000, + ), + ] + assert sections[1].end_address == nrf52.TESTING_FLASH_SIZE + + +def test_testing_mode_sections_leave_unpinned_sections() -> None: + section = Section("other", 0x50000, 0x1000, "flash_primary") + + assert nrf52._testing_mode_sections([section]) == [section] From 822bbb85dcfe1f5173f2c575c314396531447bca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:50:32 -1000 Subject: [PATCH 140/435] [core] Build trigger callbacks as stateless lambdas (#19606) --- esphome/automation.py | 184 +++++++++++++++--- esphome/codegen.py | 1 + esphome/cpp_generator.py | 7 + tests/unit_tests/test_automation.py | 287 ++++++++++++++++++++++++++-- 4 files changed, 438 insertions(+), 41 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index b69edd04a13..645fe566d41 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass, field import logging import string @@ -23,7 +23,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda from esphome.cpp_generator import ( - FlashStringLiteral, + Expression, LambdaExpression, MockObj, MockObjClass, @@ -245,9 +245,7 @@ ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition) def flash_string(config: ConfigType, value: str) -> str: """Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266.""" - if CORE.is_esp8266: - return f"progmem_string({FlashStringLiteral(value)})" - return str(cg.safe_exp(value)) + return str(cg.progmem_string(value)) def literal_with_length(config: ConfigType, value: str) -> str: @@ -259,6 +257,13 @@ def literal_with_length(config: ConfigType, value: str) -> str: return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" +def string_ref_literal(config: ConfigType, value: str) -> str: + """Renderer for a ``StringRef`` comparison: a flash literal on ESP8266, else ``StringRef(literal, length)``.""" + if CORE.is_esp8266: + return str(cg.FlashStringLiteral(value)) + return f"StringRef({literal_with_length(config, value)})" + + @dataclass(frozen=True) class ApplyCall: """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. @@ -365,9 +370,16 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] +def parent_ref(var: MockObj) -> MockObj: + """``var`` named from global scope, so a trigger argument cannot shadow it. + + Also how a generated callback names its Automation. + """ + return MockObj(f"::{var}", "->") + + async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: - # Global-scope qualified so a trigger arg named like the id cannot shadow it. - return f"::{await cg.get_variable(config[id_key])}" + return str(parent_ref(await cg.get_variable(config[id_key]))) def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: @@ -399,7 +411,7 @@ async def _render_values( members: list[tuple[Any, Any, Any]], values: list[Any], config: ConfigType, - parent: str, + parent: str | None, lambda_args: TemplateArgsType, compare: bool = False, ) -> list[str]: @@ -477,7 +489,7 @@ def register_apply_action( statements: list[str] = [] for target, members in statements_spec: values = _apply_values(config, members) - if members and all(value is None for value in values): + if not _apply_call_active(members, values): continue exprs = await _render_values( name, target, members, values, config, parent, lambda_args @@ -496,6 +508,27 @@ def register_apply_action( register_action(name, ApplyAction, schema, synchronous=True)(builder) +def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool: + """An ``ApplyCall`` is emitted unless it has keys and none of them is set.""" + return not members or any(value is not None for value in values) + + +async def _render_check( + name: str, + target: str, + members: list[tuple[Any, Any, Any]], + values: list[Any], + config: ConfigType, + parent: str | None, + lambda_args: TemplateArgsType, +) -> str: + """Render a boolean check with ``values`` compared against config.""" + exprs = await _render_values( + name, target, members, values, config, parent, lambda_args, compare=True + ) + return target.format(*exprs) + + def register_apply_condition( name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID ) -> None: @@ -521,22 +554,16 @@ def register_apply_condition( ) -> MockObj: parent = await _apply_parent(config, id_key) lambda_args = _apply_lambda_args(args) - exprs = await _render_values( - name, - call.target, - members, - _apply_values(config, members), - config, - parent, - lambda_args, - compare=True, + values = _apply_values(config, members) + check = await _render_check( + name, call.target, members, values, config, parent, lambda_args ) return _apply_function( condition_id, cg.bool_, template_arg, lambda_args, - [f"return {parent}->{call.target.format(*exprs)};"], + [f"return {parent}->{check};"], ) register_condition(name, ApplyCondition, schema)(builder) @@ -1056,23 +1083,77 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: return False -async def build_automation( - trigger: MockObj, args: TemplateArgsType, config: ConfigType +async def _new_automation( + args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: - arg_types = [arg[0] for arg in args] - templ = cg.TemplateArguments(*arg_types) - obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, trigger) + """Create the Automation for ``config`` with its actions.""" + templ = cg.TemplateArguments(*(arg[0] for arg in args)) + obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) actions = await build_action_list(config[CONF_THEN], templ, args) cg.add(obj.add_actions(actions)) return obj +async def build_automation( + trigger: MockObj, args: TemplateArgsType, config: ConfigType +) -> MockObj: + return await _new_automation(args, config, trigger) + + +async def build_trigger_callback( + args: TemplateArgsType, + config: ConfigType, + params: TemplateArgsType, + forward: Sequence[str | Expression] | None = None, + when: str | ApplyCall | None = None, +) -> LambdaExpression: + """Build the Automation for ``config`` and return a stateless callback that triggers it. + + ``params`` are the parent callback's parameters, ``forward`` the expressions passed to + ``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``), + ``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none + of its keys is set. + """ + members: list[tuple[Any, Any, Any]] = [] + if when is not None: + call = when if isinstance(when, ApplyCall) else ApplyCall(when) + members = call.members + # A trigger callback has no parent for a str type to name. + if any(isinstance(t, str) and "{parent}" in t for _, t, _ in members): + raise ValueError(f"trigger filter {call.target!r}: a type names {{parent}}") + obj = await _new_automation(args, config) + lambda_args = _apply_lambda_args(params) + statements: list[str] = [] + if when is not None: + values = _apply_values(config, members) + if _apply_call_active(members, values): + check = await _render_check( + "trigger filter", + call.target, + members, + values, + config, + None, + lambda_args, + ) + statements.append(f"if (!({check}))\n return;") + if forward is None: + forward = [name for _, name in params] + statements.append(f"{parent_ref(obj)}->trigger({', '.join(map(str, forward))});") + return LambdaExpression( + ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + ) + + async def build_callback_automation( parent: MockObj, callback_method: str, args: TemplateArgsType, config: ConfigType, forwarder: MockObj | MockObjClass | None = None, + params: TemplateArgsType | None = None, + forward: Sequence[str | Expression] | None = None, + when: str | ApplyCall | None = None, ) -> None: """Build an Automation and register it as a callback on the parent. @@ -1084,6 +1165,9 @@ async def build_callback_automation( pointer-sized (single Automation* field) to fit inline in Callback::ctx_ and avoid heap allocation. + With ``params``, ``forward`` or ``when`` the callback is instead the stateless + lambda of ``build_trigger_callback``; ``forwarder`` cannot be combined with them. + :param parent: The component object (e.g., button, sensor). :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). :param args: Automation template args as list of (type, name) tuples. @@ -1092,22 +1176,56 @@ async def build_callback_automation( TriggerForwarder. Pass any struct type whose aggregate init takes a single Automation pointer (e.g., TriggerOnTrueForwarder). """ - arg_types = [arg[0] for arg in args] - templ = cg.TemplateArguments(*arg_types) - obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ) - actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) + if params is not None or forward is not None or when is not None: + if forwarder is not None: + raise ValueError( + "forwarder cannot be combined with params, forward or when" + ) + callback = await build_trigger_callback( + args, config, args if params is None else params, forward, when + ) + cg.add(getattr(parent, callback_method)(callback)) + return + obj = await _new_automation(args, config) # Use template forwarder structs for deduplication. The compiler generates # one operator() per forwarder type; different automation pointers are just # data in the struct. if forwarder is None: - forwarder = TriggerForwarder.template(templ) + forwarder = TriggerForwarder.template(*(arg[0] for arg in args)) # RawExpression for aggregate init — both forwarder and obj are codegen # MockObjs (not user input), and there's no Expression type for positional # aggregate initialization (StructInitializer uses named fields). cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}"))) +async def build_parent_callback_automation( + parent: MockObj, callback_method: str, arg: tuple[Any, str], config: ConfigType +) -> None: + """Register an Automation that receives ``parent`` from a callback that carries nothing. + + ``arg`` is the automation's ``(type, name)``, e.g. ``(Fan.operator("ptr"), "x")``. + """ + await build_callback_automation( + parent, callback_method, [arg], config, params=[], forward=[parent_ref(parent)] + ) + + +async def build_trigger_automations( + parent: MockObj | None, + config: ConfigType, + entries: tuple[tuple[str, TemplateArgsType], ...], +) -> None: + """Instantiate each entry's Trigger class, with ``parent`` when given, and build its automations. + + ``entries`` are ``(conf_key, args)`` pairs; the class comes from the entry's ``CONF_TRIGGER_ID``. + """ + ctor_args = () if parent is None else (parent,) + for conf_key, args in entries: + for conf in config.get(conf_key, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], *ctor_args) + await build_automation(trigger, args, conf) + + @dataclass(frozen=True, slots=True) class CallbackAutomation: """A single callback automation entry for build_callback_automations.""" @@ -1116,6 +1234,9 @@ class CallbackAutomation: callback_method: str args: TemplateArgsType = field(default_factory=list) forwarder: MockObj | MockObjClass | None = None + params: TemplateArgsType | None = None + forward: Sequence[str | Expression] | None = None + when: str | ApplyCall | None = None async def build_callback_automations( @@ -1137,4 +1258,7 @@ async def build_callback_automations( entry.args, conf, forwarder=entry.forwarder, + params=entry.params, + forward=entry.forward, + when=entry.when, ) diff --git a/esphome/codegen.py b/esphome/codegen.py index e7c08048b9f..d57ec39aab7 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -39,6 +39,7 @@ from esphome.cpp_generator import ( # noqa: F401 new_variable, process_lambda, progmem_array, + progmem_string, safe_exp, set_cpp_standard, shared_progmem_array, diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 267e86c2edf..f5113d8098e 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -269,6 +269,13 @@ class FlashStringLiteral(Literal): return f"ESPHOME_F({cpp_string_escape(self.string)})" +def progmem_string(value: str) -> Expression: + """A ``std::string`` argument from a literal that stays in flash on ESP8266.""" + if CORE.is_esp8266: + return RawExpression(f"progmem_string({FlashStringLiteral(value)})") + return safe_exp(value) + + class IntLiteral(Literal): __slots__ = ("i",) diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 2bcd32375e6..ddd777fdb07 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -16,10 +16,15 @@ from esphome.automation import ( TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + build_callback_automation, build_callback_automations, + build_parent_callback_automation, + build_trigger_automations, + build_trigger_callback, has_non_synchronous_actions, literal_with_length, maybe_simple_id, + parent_ref, progmem_bytes, register_apply_action, register_apply_condition, @@ -29,11 +34,12 @@ from esphome.automation import ( register_parented_condition, register_simple_action, register_simple_condition, + string_ref_literal, templatable_bytes, ) import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.util import Registry, RegistryEntry @@ -327,7 +333,14 @@ async def test_build_callback_automations_single_entry( (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), ) mock_build_callback.assert_called_once_with( - parent, "add_on_state_callback", [(bool, "x")], conf, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf, + forwarder=None, + params=None, + forward=None, + when=None, ) @@ -347,10 +360,24 @@ async def test_build_callback_automations_multiple_configs( ) assert mock_build_callback.call_count == 2 mock_build_callback.assert_any_call( - parent, "add_on_state_callback", [(bool, "x")], conf1, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf1, + forwarder=None, + params=None, + forward=None, + when=None, ) mock_build_callback.assert_any_call( - parent, "add_on_state_callback", [(bool, "x")], conf2, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf2, + forwarder=None, + params=None, + forward=None, + when=None, ) @@ -378,9 +405,25 @@ async def test_build_callback_automations_multiple_entries( ) assert mock_build_callback.call_count == 2 assert mock_build_callback.call_args_list == [ - call(parent, "add_on_value_callback", [(float, "x")], conf_a, forwarder=None), call( - parent, "add_on_raw_value_callback", [(float, "x")], conf_b, forwarder=None + parent, + "add_on_value_callback", + [(float, "x")], + conf_a, + forwarder=None, + params=None, + forward=None, + when=None, + ), + call( + parent, + "add_on_raw_value_callback", + [(float, "x")], + conf_b, + forwarder=None, + params=None, + forward=None, + when=None, ), ] @@ -403,7 +446,14 @@ async def test_build_callback_automations_with_forwarder( ), ) mock_build_callback.assert_called_once_with( - parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + parent, + "add_on_state_callback", + [], + conf, + forwarder=TriggerOnTrueForwarder, + params=None, + forward=None, + when=None, ) @@ -437,7 +487,14 @@ async def test_build_callback_automations_mixed_entries( assert mock_build_callback.call_count == 3 assert mock_build_callback.call_args_list == [ call( - parent, "add_on_state_callback", [(bool, "x")], conf_state, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf_state, + forwarder=None, + params=None, + forward=None, + when=None, ), call( parent, @@ -445,6 +502,9 @@ async def test_build_callback_automations_mixed_entries( [], conf_press, forwarder=TriggerOnTrueForwarder, + params=None, + forward=None, + when=None, ), call( parent, @@ -452,6 +512,9 @@ async def test_build_callback_automations_mixed_entries( [], conf_release, forwarder=TriggerOnFalseForwarder, + params=None, + forward=None, + when=None, ), ] @@ -477,7 +540,14 @@ async def test_build_callback_automations_skips_missing_keys( ), ) mock_build_callback.assert_called_once_with( - parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + parent, + "add_on_state_callback", + [], + conf, + forwarder=TriggerOnTrueForwarder, + params=None, + forward=None, + when=None, ) @@ -495,7 +565,14 @@ async def test_build_callback_automations_defaults( (CallbackAutomation("on_press", "add_on_press_callback"),), ) mock_build_callback.assert_called_once_with( - parent, "add_on_press_callback", [], conf, forwarder=None + parent, + "add_on_press_callback", + [], + conf, + forwarder=None, + params=None, + forward=None, + when=None, ) @@ -512,9 +589,59 @@ class MockCodegen(NamedTuple): new_pvariable: MagicMock register_parented: AsyncMock add_global: MagicMock + add: MagicMock calls: MagicMock # new_pvariable and add_global attached, to check their order +@pytest.fixture +def mock_build_automation() -> Generator[AsyncMock]: + with patch("esphome.automation.build_automation", new_callable=AsyncMock) as mock: + yield mock + + +@pytest.mark.asyncio +async def test_build_trigger_automations_with_parent( + mock_build_automation: AsyncMock, +) -> None: + """Each entry's Trigger class is instantiated with the parent and built with its args.""" + parent = MockObj("var", "->") + on_conf = {"trigger_id": ID("trig_1"), "then": []} + set_conf = {"trigger_id": ID("trig_2"), "then": []} + config = {"on_turn_on": [on_conf], "on_speed_set": [set_conf]} + with patch("esphome.codegen.new_Pvariable") as new_pvariable: + new_pvariable.side_effect = lambda id_, *args: MockObj(str(id_), "->") + await build_trigger_automations( + parent, + config, + ( + ("on_turn_on", []), + ("on_turn_off", []), + ("on_speed_set", [(cg.int_, "x")]), + ), + ) + assert [c.args for c in new_pvariable.call_args_list] == [ + (ID("trig_1"), parent), + (ID("trig_2"), parent), + ] + calls = mock_build_automation.call_args_list + assert [(str(c.args[0]), c.args[1], c.args[2]) for c in calls] == [ + ("trig_1", [], on_conf), + ("trig_2", [(cg.int_, "x")], set_conf), + ] + + +@pytest.mark.asyncio +async def test_build_trigger_automations_without_parent( + mock_build_automation: AsyncMock, +) -> None: + """A None parent instantiates the Trigger class with no constructor arguments.""" + conf = {"trigger_id": ID("trig_1"), "then": []} + with patch("esphome.codegen.new_Pvariable") as new_pvariable: + await build_trigger_automations(None, {"on_boot": [conf]}, (("on_boot", []),)) + new_pvariable.assert_called_once_with(ID("trig_1")) + mock_build_automation.assert_awaited_once() + + @pytest.fixture def mock_cg() -> Generator[MockCodegen]: """Patch the codegen calls the shared builders make.""" @@ -525,6 +652,7 @@ def mock_cg() -> Generator[MockCodegen]: "esphome.codegen.register_parented", new_callable=AsyncMock ) as register_parented, patch("esphome.cpp_generator.add_global") as add_global, + patch("esphome.codegen.add") as add, ): get_variable.return_value = PARENT_OBJ new_pvariable.return_value = NEW_OBJ @@ -532,7 +660,7 @@ def mock_cg() -> Generator[MockCodegen]: calls.attach_mock(new_pvariable, "new_pvariable") calls.attach_mock(add_global, "add_global") yield MockCodegen( - get_variable, new_pvariable, register_parented, add_global, calls + get_variable, new_pvariable, register_parented, add_global, add, calls ) @@ -927,6 +1055,26 @@ async def test_apply_literal_with_length_is_plain_on_every_platform( assert "progmem_string" not in text +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("platform", "rendered"), + [("esp32", 'StringRef("ON", 2)'), ("esp8266", 'ESPHOME_F("ON")')], +) +async def test_apply_string_ref_literal_stays_in_flash_on_esp8266( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + rendered: str, +) -> None: + fields = ( + ApplyField( + "payload", "set_payload", cg.std_string, const_fn=string_ref_literal + ), + ) + await _run_apply_action(registries, fields, {"payload": "ON"}, platform=platform) + assert f"::{PARENT_OBJ}->set_payload({rendered});" in _apply_definition(mock_cg) + + @pytest.mark.asyncio async def test_register_apply_condition_predicate( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -1036,3 +1184,120 @@ async def test_templatable_bytes_rejects_oversized_payload() -> None: await templatable_bytes( [0] * 0x10000, [], var.set_code_template, var.set_code_static, "payload" ) + + +TRIGGER_CONF = {CONF_AUTOMATION_ID: ID("automation_1"), CONF_THEN: []} + + +def _squash(expr: object) -> str: + """Generated text with its whitespace folded, for one-line assertions.""" + return " ".join(str(expr).split()) + + +def test_parent_ref_is_global_scoped() -> None: + """A parent named through parent_ref cannot be shadowed by a trigger argument.""" + assert ( + str(parent_ref(MockObj("sel", "->")).option_at(RawExpression("i"))) + == "::sel->option_at(i)" + ) + + +@pytest.mark.asyncio +async def test_trigger_callback_forwards_params(mock_cg: MockCodegen) -> None: + """Without forward or when the callback passes its parameters straight to trigger().""" + text = str( + await build_trigger_callback( + [(cg.bool_, "state")], TRIGGER_CONF, [(cg.bool_, "state")] + ) + ) + assert text.startswith("[](const std::remove_cvref_t & state) -> void {") + assert f"::{NEW_OBJ}->trigger(state);" in text + assert "if (" not in text + + +@pytest.mark.asyncio +async def test_trigger_callback_reshapes_and_filters(mock_cg: MockCodegen) -> None: + """A filter returns early, forward picks the trigger args, a string constant is a plain literal.""" + when = ApplyCall("payload == {}", (("payload", cg.std_string),)) + params = [(cg.std_string, "topic"), (cg.std_string, "payload")] + text = _squash( + await build_trigger_callback( + [(cg.std_string, "x")], + {**TRIGGER_CONF, "payload": "hi"}, + params, + forward=["payload"], + when=when, + ) + ) + assert "& topic, const std::remove_cvref_t & payload) -> void" in text + assert f'if (!(payload == "hi")) return; ::{NEW_OBJ}->trigger(payload);' in text + # An absent optional key skips the filter, as for any ApplyCall. + text = str( + await build_trigger_callback( + [(cg.std_string, "x")], TRIGGER_CONF, params, forward=["payload"], when=when + ) + ) + assert "if (" not in text + + +@pytest.mark.asyncio +async def test_trigger_callback_filter_has_no_parent(mock_cg: MockCodegen) -> None: + """A filter type that names {parent} is rejected up front, a trigger callback has none.""" + when = ApplyCall("mode == {}", (("mode", "{parent}::Mode"),)) + with pytest.raises(ValueError, match="names {parent}"): + await build_trigger_callback( + [], {**TRIGGER_CONF, "mode": 1}, [(cg.int_, "mode")], forward=[], when=when + ) + + +@pytest.mark.asyncio +async def test_build_parent_callback_automation(mock_cg: MockCodegen) -> None: + """A no-argument callback registers a lambda that hands the parent to the automation.""" + parent = MockObj("fan", "->") + await build_parent_callback_automation( + parent, + "add_on_state_callback", + (cg.RawExpression("Fan *"), "x"), + TRIGGER_CONF, + ) + assert _squash(mock_cg.add.call_args.args[0]) == ( + f"fan->add_on_state_callback([]() -> void {{ ::{NEW_OBJ}->trigger(::fan); }})" + ) + + +@pytest.mark.asyncio +async def test_build_callback_automation_lambda(mock_cg: MockCodegen) -> None: + """Reshaping keywords switch the registration to the lambda; forwarder cannot join them.""" + parent = MockObj("sel", "->") + await build_callback_automation( + parent, + "add_cb", + [(cg.std_string, "x"), (cg.size_t, "i")], + TRIGGER_CONF, + params=[(cg.size_t, "index")], + forward=[parent_ref(parent).option_at(RawExpression("index")), "index"], + ) + assert _squash(mock_cg.add.call_args.args[0]) == ( + "sel->add_cb([](const std::remove_cvref_t & index) -> void { " + f"::{NEW_OBJ}->trigger(::sel->option_at(index), index); }})" + ) + with pytest.raises(ValueError, match="forwarder"): + await build_callback_automation( + parent, + "add_cb", + [], + TRIGGER_CONF, + forwarder=TriggerOnTrueForwarder, + when="x", + ) + + +@pytest.mark.asyncio +async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None: + """The forwarder path registers a pointer-sized TriggerForwarder, not a lambda.""" + await build_callback_automation( + MockObj("parent", "->"), "add_cb", [(cg.bool_, "x")], TRIGGER_CONF + ) + assert str(mock_cg.add.call_args.args[0]) == ( + f"parent->add_cb(TriggerForwarder{{{NEW_OBJ}}})" + ) From dd8315059c185c16a53eb162174fb63f34bbdcb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:56:50 -1000 Subject: [PATCH 141/435] [core] Document the trigger callback helpers in AGENTS.md (#20435) --- AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d5d33b32af6..8194cd50407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,6 +396,25 @@ file does, and it is the authority when they disagree. The most useful starting ) ``` + When the callback's parameters differ from the automation's arguments, or the trigger should only + fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less + lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build + `forward` from `MockObj` calls, not f-strings of C++: + ```python + parent = automation.parent_ref(var) + index = cg.RawExpression("index") + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.StringRef, "x"), (cg.size_t, "i")], + conf, + params=[(cg.size_t, "index")], + forward=[cg.StringRef(parent.option_at(index)), index], + ) + ``` + `build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and + `build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`. + **C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation): ```cpp class MyComponent : public Component { From dbad1d462975d18dacc1880a4a13dd3985ec6f8c Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:58:33 +0200 Subject: [PATCH 142/435] [core][zigbee] Wake main loop on defer/set_timeout from background task (#20356) Co-authored-by: J. Nick Koston --- esphome/components/esp32/hal.h | 5 + esphome/components/esp8266/hal.h | 4 + esphome/components/host/hal.h | 13 +- esphome/components/libretiny/hal.h | 5 + esphome/components/rp2/hal.h | 5 + esphome/components/zephyr/hal.cpp | 1 + esphome/components/zephyr/hal.h | 8 ++ .../zigbee/time/zigbee_time_esp32.cpp | 2 - .../zigbee/time/zigbee_time_zephyr.cpp | 2 - esphome/components/zigbee/zigbee_esp32.cpp | 3 - esphome/components/zigbee/zigbee_zephyr.cpp | 12 +- esphome/core/scheduler.cpp | 123 +++++++++--------- esphome/core/wake/wake_esp8266.h | 13 +- esphome/core/wake/wake_freertos.cpp | 5 +- esphome/core/wake/wake_freertos.h | 4 +- esphome/core/wake/wake_host.cpp | 23 ++-- esphome/core/wake/wake_host.h | 1 + esphome/core/wake/wake_rp2.h | 8 +- esphome/core/wake/wake_zephyr.cpp | 4 +- esphome/core/wake/wake_zephyr.h | 1 + .../wake_test_component.cpp | 12 ++ .../wake_test_component/wake_test_component.h | 4 + .../fixtures/scheduler_background_wake.yaml | 31 +++++ .../test_scheduler_background_wake.py | 84 ++++++++++++ 24 files changed, 278 insertions(+), 95 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_background_wake.yaml create mode 100644 tests/integration/test_scheduler_background_wake.py diff --git a/esphome/components/esp32/hal.h b/esphome/components/esp32/hal.h index 2180f07f6c3..c01dcc858ed 100644 --- a/esphome/components/esp32/hal.h +++ b/esphome/components/esp32/hal.h @@ -9,6 +9,7 @@ #include #include +#include "esphome/core/main_task.h" #include "esphome/core/time_conversion.h" #ifndef PROGMEM @@ -31,6 +32,10 @@ __attribute__((always_inline)) inline bool in_isr_context() { return xPortInIsrC // NOLINTNEXTLINE(readability-redundant-declaration) extern "C" int64_t esp_timer_get_time(void); +/// Before setup() stores the handle every task counts as background; the wake is a no-op until then. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return xTaskGetCurrentTaskHandle() == esphome_main_task_handle; +} __attribute__((always_inline)) inline void yield() { vPortYield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h index f3b33da6927..1a54065541e 100644 --- a/esphome/components/esp8266/hal.h +++ b/esphome/components/esp8266/hal.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -40,6 +41,9 @@ void delay_microseconds_safe(uint32_t us); /// which is ISR-safe) so this helper is unused on this platform. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +/// SDK SYS callbacks and interrupt handlers cannot yield, so they are not the loop context. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return can_yield(); } + __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } void delay(uint32_t ms); diff --git a/esphome/components/host/hal.h b/esphome/components/host/hal.h index 12abf6684d3..c7676327729 100644 --- a/esphome/components/host/hal.h +++ b/esphome/components/host/hal.h @@ -3,6 +3,7 @@ #ifdef USE_HOST #include +#include #include #define IRAM_ATTR @@ -16,6 +17,16 @@ namespace esphome { /// Host has no ISR concept. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +inline pthread_t &main_loop_thread() { + static pthread_t thread{}; + return thread; +} + +/// arch_init() stores the thread before any component runs, so no unset case exists. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return pthread_equal(pthread_self(), main_loop_thread()) != 0; +} + __attribute__((always_inline)) inline void yield() { ::sched_yield(); } void delay(uint32_t ms); @@ -25,7 +36,7 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) uint32_t arch_get_cpu_cycle_count(); -__attribute__((always_inline)) inline void arch_init() {} +__attribute__((always_inline)) inline void arch_init() { main_loop_thread() = pthread_self(); } __attribute__((always_inline)) inline void arch_feed_wdt() {} __attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 48b94a5214a..838536b866d 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -8,6 +8,7 @@ #include #include +#include "esphome/core/main_task.h" #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an @@ -84,6 +85,10 @@ __attribute__((always_inline)) inline bool in_isr_context() { #endif } +/// Before setup() stores the handle every task counts as background; the wake is a no-op until then. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return xTaskGetCurrentTaskHandle() == esphome_main_task_handle; +} __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } diff --git a/esphome/components/rp2/hal.h b/esphome/components/rp2/hal.h index ec46937bab5..2a538f15713 100644 --- a/esphome/components/rp2/hal.h +++ b/esphome/components/rp2/hal.h @@ -4,6 +4,8 @@ #include +#include + #include "esphome/core/time_conversion.h" #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) @@ -40,6 +42,9 @@ __attribute__((always_inline)) inline bool in_isr_context() { return ipsr != 0; } +/// arduino-pico runs setup() and loop() on core 0; ESPHome never uses core 1. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return !in_isr_context() && get_core_num() == 0; } + __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index 10e8340a409..96c7df896cb 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -23,6 +23,7 @@ static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); // components/zephyr/hal.h. void arch_init() { + main_loop_thread() = k_current_get(); #ifdef CONFIG_WATCHDOG if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; diff --git a/esphome/components/zephyr/hal.h b/esphome/components/zephyr/hal.h index 11994b68b7b..8384225d33a 100644 --- a/esphome/components/zephyr/hal.h +++ b/esphome/components/zephyr/hal.h @@ -17,6 +17,14 @@ namespace esphome { /// Zephyr/nRF52: not currently consulted — wake path is platform-specific. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +inline k_tid_t &main_loop_thread() { + static k_tid_t thread = nullptr; + return thread; +} + +/// arch_init() stores the thread before any component runs, so no unset case exists. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return k_current_get() == main_loop_thread(); } + __attribute__((always_inline)) inline void yield() { ::k_yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::k_msleep(ms); } __attribute__((always_inline)) inline uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp index 1bc0923a1f7..909c714c5e3 100644 --- a/esphome/components/zigbee/time/zigbee_time_esp32.cpp +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -1,7 +1,6 @@ #include "zigbee_time_esp32.h" #if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME) #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome::zigbee { @@ -105,7 +104,6 @@ void ZigbeeTime::set_epoch_time(uint32_t utc) { ESP_LOGV(TAG, "Setting device time to UTC: %u", static_cast(utc)); this->synchronize_epoch_(utc); }); - App.wake_loop_threadsafe(); } void ZigbeeTime::dump_config() { diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index f0ef3f2af2c..7f0a5f979ce 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,7 +1,6 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome::zigbee { @@ -48,7 +47,6 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) { this->synchronize_epoch_(epoch); this->has_time_ = true; }); - App.wake_loop_threadsafe(); } void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 8b7b59acb0b..89417e748d2 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -53,7 +53,6 @@ void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_ if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); - App.wake_loop_threadsafe(); return; } if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { @@ -92,7 +91,6 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); - App.wake_loop_threadsafe(); } } break; case EZB_BDB_SIGNAL_STEERING: { @@ -118,7 +116,6 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } - App.wake_loop_threadsafe(); } } break; case EZB_ZDO_SIGNAL_LEAVE: { diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index f1eadbe6f06..1b2d7392df9 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -1,7 +1,6 @@ #include "zigbee_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) #include "esphome/core/log.h" -#include "esphome/core/application.h" #include #include #include "esphome/core/hal.h" @@ -120,8 +119,6 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; - App.wake_loop_threadsafe(); - // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { const auto &cb = global_zigbee->callbacks_[endpoint - 1]; @@ -138,7 +135,6 @@ void ZigbeeComponent::on_join_(bool factory_new) { ESP_LOGD(TAG, "Joined the network"); this->join_cb_.call(factory_new); }); - App.wake_loop_threadsafe(); } void ZigbeeComponent::on_start_() { @@ -146,7 +142,6 @@ void ZigbeeComponent::on_start_() { ESP_LOGD(TAG, "Started zigbee stack"); this->start_cb_.call(); }); - App.wake_loop_threadsafe(); } #ifdef USE_ZIGBEE_WIPE_ON_BOOT @@ -199,6 +194,7 @@ void ZigbeeComponent::setup() { zigbee_configure_sleepy_behavior(this->sleepy_); #endif zigbee_enable(); + this->disable_loop(); } #ifdef ESPHOME_LOG_HAS_CONFIG @@ -263,7 +259,10 @@ static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { zb_buf_free(bufid); } -void ZigbeeComponent::force_report() { this->force_report_ = true; } +void ZigbeeComponent::force_report() { + this->force_report_ = true; + this->enable_loop_soon_any_context(); +} void ZigbeeComponent::add_radio_sleep_time_ms(uint32_t ms) { this->radio_sleep_remainder_ += ms; @@ -277,6 +276,7 @@ void ZigbeeComponent::loop() { this->force_report_ = false; zb_buf_get_out_delayed_ext(send_attribute_report, 0, 0); } + this->disable_loop(); } void ZigbeeComponent::factory_reset() { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5acd2eec273..5d0b96c38f2 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -138,79 +138,84 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } // Take lock early to protect scheduler_item_pool_head_ access - LockGuard guard{this->lock_}; + { + LockGuard guard{this->lock_}; - // Create and populate the scheduler item - SchedulerItem *item = this->get_item_from_pool_locked_(); - // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. - if (name_type == NameType::SELF_POINTER) { - item->source_name = source; - } else { - item->component = component; - } - item->set_name(name_type, static_name, hash_or_id); - item->type = type; - // Use destroy + placement-new instead of move-assignment. - // GCC's std::function::operator=(function&&) does a full swap dance even when the - // target is empty. Since recycled/new items always have an empty callback, we can - // destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of - // swap/destructor code on Xtensa. - item->callback.~function(); - new (&item->callback) std::function(std::move(func)); - // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use - this->set_item_removed_(item, false); + // Create and populate the scheduler item + SchedulerItem *item = this->get_item_from_pool_locked_(); + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } + item->set_name(name_type, static_name, hash_or_id); + item->type = type; + // Use destroy + placement-new instead of move-assignment. + // GCC's std::function::operator=(function&&) does a full swap dance even when the + // target is empty. Since recycled/new items always have an empty callback, we can + // destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of + // swap/destructor code on Xtensa. + item->callback.~function(); + new (&item->callback) std::function(std::move(func)); + // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use + this->set_item_removed_(item, false); - // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. - // Using a pointer lets both paths share the cancel + push_back epilogue. - auto *target = &this->to_add_; + // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. + // Using a pointer lets both paths share the cancel + push_back epilogue. + auto *target = &this->to_add_; #ifndef ESPHOME_THREAD_SINGLE - // Special handling for defer() (delay = 0, type = TIMEOUT) - // Single-core platforms don't need thread-safe defer handling - if (delay == 0 && type == SchedulerItem::TIMEOUT) { - // Put in defer queue for guaranteed FIFO execution - target = &this->defer_queue_; - } else + // Special handling for defer() (delay = 0, type = TIMEOUT) + // Single-core platforms don't need thread-safe defer handling + if (delay == 0 && type == SchedulerItem::TIMEOUT) { + // Put in defer queue for guaranteed FIFO execution + target = &this->defer_queue_; + } else #endif /* not ESPHOME_THREAD_SINGLE */ - { - // Only non-defer items need a timestamp for scheduling - const uint64_t now_64 = millis_64(); + { + // Only non-defer items need a timestamp for scheduling + const uint64_t now_64 = millis_64(); - // Type-specific setup - if (type == SchedulerItem::INTERVAL) { - item->interval = delay; - // first execution happens immediately after a random smallish offset - uint32_t offset = this->calculate_interval_offset_(delay); - item->set_next_execution(now_64 + offset); + // Type-specific setup + if (type == SchedulerItem::INTERVAL) { + item->interval = delay; + // first execution happens immediately after a random smallish offset + uint32_t offset = this->calculate_interval_offset_(delay); + item->set_next_execution(now_64 + offset); #ifdef ESPHOME_LOG_HAS_VERBOSE - SchedulerNameLog name_log; - ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", - name_log.format(name_type, static_name, hash_or_id), delay, offset); + SchedulerNameLog name_log; + ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", + name_log.format(name_type, static_name, hash_or_id), delay, offset); #endif - } else { - item->interval = 0; - item->set_next_execution(now_64 + delay); - } + } else { + item->interval = 0; + item->set_next_execution(now_64 + delay); + } #ifdef ESPHOME_DEBUG_SCHEDULER - this->debug_log_timer_(item, name_type, static_name, hash_or_id, delay, now_64); + this->debug_log_timer_(item, name_type, static_name, hash_or_id, delay, now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ - } + } - // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) - // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. - if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); - } - target->push_back(item); - if (target == &this->to_add_) { - this->to_add_count_increment_locked_(); - } + // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) + // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. + if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); + } + target->push_back(item); + if (target == &this->to_add_) { + this->to_add_count_increment_locked_(); + } #ifndef ESPHOME_THREAD_SINGLE - else { - this->defer_count_increment_locked_(); - } + else { + this->defer_count_increment_locked_(); + } #endif + } + // A background insertion may shorten a sleep whose deadline was already computed. + if (!is_main_loop_thread()) [[unlikely]] + wake_scheduler_threadsafe(); } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 73b7a38a35e..2f7ffc292fe 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -11,10 +11,7 @@ namespace esphome { /// Inline implementation — IRAM callers inline this directly. -inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { - // Set the wake-requested flag BEFORE esp_schedule so the consumer is - // guaranteed to see it on its next gate check. - wake_request_set(); +inline void ESPHOME_ALWAYS_INLINE wake_scheduler_impl() { // Skip the post when a wake was already signalled and not yet consumed by // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, // which must not be poked per-byte from the software serial RX ISR (see @@ -26,11 +23,19 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { esp_schedule(); } +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); + wake_scheduler_impl(); +} + /// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. void wake_loop_any_context(); /// Non-ISR: always inline. inline void wake_loop_threadsafe() { wake_loop_impl(); } +inline void wake_scheduler_threadsafe() { wake_scheduler_impl(); } /// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 458ef51f89f..02c628718e6 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,9 +30,6 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome -extern "C" void esphome_wake_loop_threadsafe() { - esphome::wake_request_set(); - esphome_main_task_notify(); -} +extern "C" void esphome_wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h index 16afa38fda7..1df7d387656 100644 --- a/esphome/core/wake/wake_freertos.h +++ b/esphome/core/wake/wake_freertos.h @@ -34,9 +34,11 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); void wake_loop_any_context(); +inline void wake_scheduler_threadsafe() { esphome_main_task_notify(); } + inline void wake_loop_threadsafe() { wake_request_set(); - esphome_main_task_notify(); + wake_scheduler_threadsafe(); } namespace internal { diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 8cb382a77e0..9fc1c717032 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,13 +123,12 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } - // A socket woke select() early — open the component-phase gate so the - // owning component's loop() drains the data on this tick rather than - // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() - // already set the flag (wake socket fired); required when an application - // socket fired and nothing else set the flag. + // Application sockets need the component phase to drain queued work. + // The internal wake socket may only be signaling new scheduler work. if (ret > 0) { - wake_request_set(); + const bool only_wake_socket = ret == 1 && g_wake_socket_fd >= 0 && FD_ISSET(g_wake_socket_fd, &g_read_fds); + if (!only_wake_socket) + wake_request_set(); } return; } @@ -146,16 +145,20 @@ void wakeable_delay(uint32_t ms) { } } // namespace internal -void wake_loop_threadsafe() { - // Set flag before sending so the consumer's gate check on the next loop() - // entry observes the wake regardless of select() scheduling. - wake_request_set(); +void wake_scheduler_threadsafe() { if (internal::g_wake_socket_fd >= 0) { const char dummy = 1; ::send(internal::g_wake_socket_fd, &dummy, 1, 0); } } +void wake_loop_threadsafe() { + // Set flag before sending so the consumer's gate check on the next loop() + // entry observes the wake regardless of select() scheduling. + wake_request_set(); + wake_scheduler_threadsafe(); +} + void wake_setup() { // Create UDP socket for wake notifications. internal::g_wake_socket_fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h index 9756ed4c39b..8c58797df25 100644 --- a/esphome/core/wake/wake_host.h +++ b/esphome/core/wake/wake_host.h @@ -13,6 +13,7 @@ namespace esphome { /// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. void wake_loop_threadsafe(); +void wake_scheduler_threadsafe(); /// Register a socket file descriptor with the host select() loop. Not /// thread-safe — main loop only. Returns false if fd is invalid or diff --git a/esphome/core/wake/wake_rp2.h b/esphome/core/wake/wake_rp2.h index 715e5aca0c8..4f5439dbb8c 100644 --- a/esphome/core/wake/wake_rp2.h +++ b/esphome/core/wake/wake_rp2.h @@ -11,12 +11,16 @@ namespace esphome { +inline void wake_scheduler_threadsafe() { + g_main_loop_woke = true; + __sev(); +} + inline void wake_loop_any_context() { // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed // to see it on its next gate check. wake_request_set(); - g_main_loop_woke = true; - __sev(); + wake_scheduler_threadsafe(); } inline void wake_loop_threadsafe() { wake_loop_any_context(); } diff --git a/esphome/core/wake/wake_zephyr.cpp b/esphome/core/wake/wake_zephyr.cpp index 577d53f5d9d..2c46fbc9512 100644 --- a/esphome/core/wake/wake_zephyr.cpp +++ b/esphome/core/wake/wake_zephyr.cpp @@ -21,9 +21,11 @@ K_SEM_DEFINE(esphome_wake_sem, 0, 1); // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) volatile uint8_t g_wake_requested = 0; +void wake_scheduler_threadsafe() { k_sem_give(&esphome_wake_sem); } + void wake_loop_threadsafe() { wake_request_set(); - k_sem_give(&esphome_wake_sem); + wake_scheduler_threadsafe(); } namespace internal { diff --git a/esphome/core/wake/wake_zephyr.h b/esphome/core/wake/wake_zephyr.h index c89cfc68e94..04cbfb230b1 100644 --- a/esphome/core/wake/wake_zephyr.h +++ b/esphome/core/wake/wake_zephyr.h @@ -11,6 +11,7 @@ namespace esphome { /// Zephyr: wakes the main loop via k_sem_give(). Thread- and ISR-safe. /// Defined in wake_zephyr.cpp. void wake_loop_threadsafe(); +void wake_scheduler_threadsafe(); inline void wake_loop_any_context() { wake_loop_threadsafe(); } diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp index b58f1c9adc8..71182a46ad5 100644 --- a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp @@ -16,4 +16,16 @@ void WakeTestComponent::start_async_wake() { }).detach(); } +void WakeTestComponent::start_async_timeout(uint32_t delay_ms) { + const uint32_t start_time = millis(); + std::thread([this, start_time, delay_ms] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + const int start_loop_count = this->get_loop_count(); + this->set_timeout(delay_ms, [this, start_time, start_loop_count, delay_ms] { + ESP_LOGI(TAG, "SCHEDULER_WAKE_RESULT delay=%u elapsed=%u loop_delta=%d", static_cast(delay_ms), + static_cast(millis() - start_time), this->get_loop_count() - start_loop_count); + }); + }).detach(); +} + } // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h index c8e4e0a89fe..a1f06f8998c 100644 --- a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h @@ -18,6 +18,10 @@ class WakeTestComponent : public Component { // loop_interval_ has been raised high enough to gate it off otherwise. void start_async_wake(); + // Spawn a detached thread that inserts a timeout (or a defer when delay_ms + // is 0) while the main loop sleeps, and report how soon it ran. + void start_async_timeout(uint32_t delay_ms); + float get_setup_priority() const override { return setup_priority::DATA; } protected: diff --git a/tests/integration/fixtures/scheduler_background_wake.yaml b/tests/integration/fixtures/scheduler_background_wake.yaml new file mode 100644 index 00000000000..78e6864eb2d --- /dev/null +++ b/tests/integration/fixtures/scheduler_background_wake.yaml @@ -0,0 +1,31 @@ +esphome: + name: scheduler-background-wake + on_boot: + priority: -100 + then: + - lambda: |- + App.set_loop_interval(5000); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [wake_test_component] + +wake_test_component: + id: wake_test + +button: + - platform: template + name: Start Scheduler Timeout + on_press: + - lambda: id(wake_test)->start_async_timeout(100); + - platform: template + name: Start Scheduler Defer + on_press: + - lambda: id(wake_test)->start_async_timeout(0); diff --git a/tests/integration/test_scheduler_background_wake.py b/tests/integration/test_scheduler_background_wake.py new file mode 100644 index 00000000000..fa7d34f4943 --- /dev/null +++ b/tests/integration/test_scheduler_background_wake.py @@ -0,0 +1,84 @@ +"""A background scheduler insertion must interrupt the loop's current sleep. + +Covers both set_timeout and defer: a zero-delay insert from another thread +takes the separate defer queue on multi-threaded builds. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_background_wake( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + results: dict[int, asyncio.Future[tuple[int, int]]] = { + 100: loop.create_future(), + 0: loop.create_future(), + } + + def on_log_line(line: str) -> None: + match = re.search( + r"SCHEDULER_WAKE_RESULT delay=(\d+) elapsed=(\d+) loop_delta=(-?\d+)", + line, + ) + if match is None: + return + result = results[int(match.group(1))] + if not result.done(): + result.set_result((int(match.group(2)), int(match.group(3)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-background-wake" + entities, _ = await client.list_entities_services() + for object_id, delay in ( + ("start_scheduler_timeout", 100), + ("start_scheduler_defer", 0), + ): + start_button = require_entity( + entities, + object_id, + ButtonInfo, + description=f"{object_id} button", + ) + client.button_command(start_button.key) + + try: + elapsed, loop_delta = await asyncio.wait_for( + results[delay], timeout=10.0 + ) + except TimeoutError: + pytest.fail(f"background insert with delay={delay} did not fire") + + assert elapsed < 1000, ( + f"background insert with delay={delay} should interrupt the " + f"five-second sleep; it fired after {elapsed}ms" + ) + assert loop_delta == 0, ( + f"scheduler-only wake must not run component loops; observed " + f"loop_delta={loop_delta} for delay={delay}" + ) From 0652e6c3fb2df6ad1aa9cce4382ca3f73dd79880 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:02:22 -1000 Subject: [PATCH 143/435] [esp32][libretiny] Skip registering static destructors that never run (#20440) --- esphome/components/esp32/__init__.py | 5 +++++ esphome/components/esp32/atexit_stubs.cpp | 20 +++++++++++++++++++ esphome/components/libretiny/__init__.py | 3 +++ esphome/components/libretiny/atexit_stubs.cpp | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 esphome/components/esp32/atexit_stubs.cpp create mode 100644 esphome/components/libretiny/atexit_stubs.cpp diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index bb6333956ef..cfa79e3cf1f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2926,6 +2926,11 @@ async def to_code(config): # produce reproducible outputs and downstream tooling can reuse artifacts. add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + # --undefined: libsrc.a is scanned before the IDF libraries that also register them. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit") + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") diff --git a/esphome/components/esp32/atexit_stubs.cpp b/esphome/components/esp32/atexit_stubs.cpp new file mode 100644 index 00000000000..1230a566814 --- /dev/null +++ b/esphome/components/esp32/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables (about 400 bytes of RAM on ESP32). + */ + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP32 diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 5a82cce7ad7..61eafe5f86d 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -492,6 +492,9 @@ async def component_to_code(config): # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + # LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c). if config[CONF_COMPONENT_ID] == COMPONENT_LN882X: cg.add_build_flag("-Wl,--wrap=pvPortMalloc") diff --git a/esphome/components/libretiny/atexit_stubs.cpp b/esphome/components/libretiny/atexit_stubs.cpp new file mode 100644 index 00000000000..1d237e7f059 --- /dev/null +++ b/esphome/components/libretiny/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables. + */ + +#include "esphome/core/defines.h" + +#ifdef USE_LIBRETINY + +namespace esphome::libretiny {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_LIBRETINY From 76ba7f0c88460e081ae7b798f63211c57b6d362d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:10:06 -1000 Subject: [PATCH 144/435] [espidf] Cache the discovered component list once per target (#20432) --- esphome/build_gen/espidf.py | 49 ++++++++++++++++++----- esphome/espidf/toolchain.py | 29 ++++++-------- tests/unit_tests/build_gen/test_espidf.py | 46 +++++++++++++++++++-- tests/unit_tests/test_espidf_toolchain.py | 28 +++++++------ 4 files changed, 107 insertions(+), 45 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index e7a2dc1536b..759d9a0bef4 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -237,6 +237,35 @@ def idf_macro_matches(idf_path: Path) -> bool: return live == _EXPECTED_MACRO +def _cmake_args_for_write(minimal: bool) -> dict[str, str]: + """The cg.add_cmake_arg() values for one project write. + + The discovery (minimal) write lets every built-in component register so + the list it finds, cached per target, serves any exclusion set; the full + write drops the excluded names from that list and passes all of them to + IDF. Names outside the framework's components directory (managed + components) stay excluded on the discovery write too, since the list + only holds built-in ones. A checkout supplied through IDF_PATH caches + nothing, so its discovery keeps every exclusion. + """ + from esphome.espidf.toolchain import _esphome_manages_idf, _get_idf_path + + args = dict(CORE.cmake_args) + if ( + not minimal + or not _esphome_manages_idf() + or not (excluded := args.get("EXCLUDE_COMPONENTS")) + ): + return args + root = _get_idf_path() / "components" + outside = [name for name in excluded.split(";") if not (root / name).is_dir()] + if outside: + args["EXCLUDE_COMPONENTS"] = ";".join(outside) + else: + del args["EXCLUDE_COMPONENTS"] + return args + + def get_project_cmakelists( minimal: bool = False, builtin_components: list[str] | None = None ) -> str: @@ -300,11 +329,10 @@ def get_project_cmakelists( # CMake variables registered via cg.add_cmake_arg(). Emitted before # include(project.cmake) so values like EXCLUDE_COMPONENTS are already - # set when project.cmake seeds the component list, and on minimal - # (discovery) writes too so excluded components never register. + # set when project.cmake seeds the component list. cmake_args = "\n".join( f"set({name} {_cmake_quote(value)})" - for name, value in sorted(CORE.cmake_args.items()) + for name, value in sorted(_cmake_args_for_write(minimal).items()) ) # Per-project list exposed as a CMake variable so converted PIO libs @@ -327,10 +355,10 @@ def get_project_cmakelists( # component's REQUIRES including real IDF components). Referenced by # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped # on minimal writes because project_description.json may be stale. - # Excluded components are dropped here as well: a stale - # project_description.json from a build without exclusions may still - # list them, and requiring an excluded component pulls it back into - # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). + # Excluded components are dropped here as well: the discovery list + # (cached per target) still holds them, and requiring an excluded + # component pulls it back into the build (IDF requirement expansion + # overrides EXCLUDE_COMPONENTS). # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the # two can never disagree within one generated file. builtin_components_property = ( @@ -554,10 +582,9 @@ def write_project( ) # Snapshot the exclusion set so has_outdated_files() can trigger a - # discovery reconfigure when it changes. Excluded components never - # register in project_description.json, so re-including one (e.g. a - # config gains mqtt) requires a fresh discovery pass before the - # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + # reconfigure when it changes. The ESPHOME_PROJECT_BUILTIN_COMPONENTS + # property drops excluded names, so re-including one (e.g. a config + # gains mqtt) needs a reconfigure to rebuild it from the discovery list. write_file_if_changed( CORE.relative_build_path("exclude_components.esphomeinternal"), ";".join(get_excluded_builtin_components()), diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index effb817f353..af3fda63495 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field import fnmatch -import hashlib import json import logging import os @@ -516,23 +515,21 @@ def _sync_component_mirror() -> None: def _builtin_component_cache_path() -> Path | None: - """Cache file for this build's built-in component list. + """Cache file for this target's built-in component list. The file lives inside the extracted framework directory so it is discarded together with that exact checkout (re-extract, source - override, clean-all); the target and the EXCLUDE_COMPONENTS set name it. - The sdkconfig is not part of the key: IDF components register regardless - of CONFIG_* options and only gate their sources on them. A checkout - supplied through IDF_PATH is not managed by ESPHome and is never cached. + override, clean-all). The discovery configure registers every built-in + component, so the list serves any EXCLUDE_COMPONENTS set (the project + write drops the excluded names) and any sdkconfig (IDF components + register regardless of CONFIG_* options and only gate their sources on + them). A checkout supplied through IDF_PATH is not managed by ESPHome + and is never cached. """ if not _esphome_manages_idf(): return None target = variant_to_idf_target(CORE.data[KEY_ESP32][KEY_VARIANT]) - excluded = CORE.cmake_args.get("EXCLUDE_COMPONENTS", "") - excluded_key = hashlib.sha256(excluded.encode()).hexdigest()[:12] - return ( - _get_idf_path() / ".esphome_component_lists" / f"{target}-{excluded_key}.json" - ) + return _get_idf_path() / ".esphome_component_lists" / f"{target}.json" def load_cached_builtin_components() -> list[str] | None: @@ -635,10 +632,9 @@ def has_outdated_files(): already deletes ``dependencies.lock`` on a change but that signal gets lost as soon as the lock is missing. - ``exclude_components.esphomeinternal`` -- the resolved - EXCLUDE_COMPONENTS set. Excluded components never register in - ``project_description.json``, so re-including one needs a fresh - discovery pass before it can appear in the builtin-components - property that ``src`` REQUIRES. + EXCLUDE_COMPONENTS set. The builtin-components property that + ``src`` REQUIRES drops excluded names, so re-including one needs a + reconfigure to rebuild that property from the discovery list. We deliberately don't watch: - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and @@ -803,8 +799,7 @@ def run_compile(config, verbose: bool) -> int: Uses two-phase configure to auto-discover available components: 1. If no previous build, configure with minimal REQUIRES to discover - components (skipped when a cached list for this IDF/target/exclusion - set exists) + components (skipped when a cached list for this IDF/target exists) 2. Regenerate CMakeLists.txt with discovered components 3. Run full build """ diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 2c95c61b372..c439b87d163 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import os from pathlib import Path from unittest.mock import patch @@ -350,17 +351,54 @@ def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd" not in content -def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: - """The discovery (minimal) write also excludes components so they never - register in project_description.json.""" +def test_get_project_cmakelists_minimal_excludes_only_managed_components( + tmp_path: Path, +) -> None: + """The discovery (minimal) write lets built-in components register so + the cached list serves every exclusion set; managed ones stay excluded.""" + (tmp_path / "components" / "unity").mkdir(parents=True) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "espressif__esp_hosted"} + register_exclude_components_cmake_arg() + + with patch("esphome.espidf.toolchain._get_idf_path", return_value=tmp_path): + minimal = _render(minimal=True) + full = _render() + + assert 'set(EXCLUDE_COMPONENTS "espressif__esp_hosted")' in minimal + assert 'set(EXCLUDE_COMPONENTS "espressif__esp_hosted;unity")' in full + + +def test_get_project_cmakelists_minimal_keeps_exclusions_for_custom_idf_path( + tmp_path: Path, +) -> None: + """Nothing is cached for a user-supplied IDF_PATH, so its discovery write + keeps every exclusion.""" + (tmp_path / "components" / "unity").mkdir(parents=True) CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} register_exclude_components_cmake_arg() - content = _render(minimal=True) + with ( + patch("esphome.espidf.toolchain._get_idf_path", return_value=tmp_path), + patch.dict(os.environ, {"IDF_PATH": str(tmp_path)}), + ): + content = _render(minimal=True) assert 'set(EXCLUDE_COMPONENTS "unity")' in content +def test_get_project_cmakelists_minimal_drops_exclude_line_when_all_builtin( + tmp_path: Path, +) -> None: + (tmp_path / "components" / "unity").mkdir(parents=True) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + register_exclude_components_cmake_arg() + + with patch("esphome.espidf.toolchain._get_idf_path", return_value=tmp_path): + content = _render(minimal=True) + + assert "EXCLUDE_COMPONENTS" not in content + + def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" register_exclude_components_cmake_arg() diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 854c39a9ce1..2a799a4d84a 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -647,7 +647,7 @@ def test_run_compile_cache_hit_skips_discovery(setup_core: Path) -> None: @contextmanager -def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]: +def _cache_env(tmp_path: Path) -> Iterator[Path]: """Patch everything the cache key derives from onto a temp IDF tree and yield that tree's path.""" idf_path = tmp_path / "idf" @@ -655,7 +655,6 @@ def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]: with ( patch.object(toolchain, "_get_idf_path", return_value=idf_path), patch.dict(CORE.data, {KEY_ESP32: {KEY_VARIANT: "ESP32"}}), - patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": excluded}), ): yield idf_path @@ -663,7 +662,7 @@ def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]: def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None: """A saved list is read back until it is dropped.""" _setup_build(setup_core) - with _cache_env(tmp_path, "fatfs") as idf_path: + with _cache_env(tmp_path) as idf_path: for name in ("lwip", "esp_timer"): (idf_path / "components" / name).mkdir() assert toolchain.load_cached_builtin_components() is None @@ -673,25 +672,28 @@ def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None: assert toolchain.load_cached_builtin_components() is None -def test_component_cache_misses_on_key_change_or_missing_component( +def test_component_cache_is_per_target_and_drops_missing_component( setup_core: Path, tmp_path: Path ) -> None: - """A different exclusion set uses another entry, an entry naming a + """One entry per target serves every exclusion set, an entry naming a component that no longer exists is ignored, and a custom IDF_PATH is never cached.""" _setup_build(setup_core) - with _cache_env(tmp_path, "fatfs") as idf_path: + with _cache_env(tmp_path) as idf_path: (idf_path / "components" / "lwip").mkdir() toolchain.save_cached_builtin_components(["lwip"]) path = toolchain._builtin_component_cache_path() assert path.parent == idf_path / ".esphome_component_lists" - assert path.name.startswith("esp32-") + assert path.name == "esp32.json" assert toolchain.load_cached_builtin_components() == ["lwip"] with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}): assert toolchain.load_cached_builtin_components() is None - with _cache_env(tmp_path, "fatfs;unity"): - assert toolchain.load_cached_builtin_components() is None - with _cache_env(tmp_path, "fatfs") as idf_path: + with ( + _cache_env(tmp_path), + patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": "fatfs;unity"}), + ): + assert toolchain.load_cached_builtin_components() == ["lwip"] + with _cache_env(tmp_path) as idf_path: path.write_text(json.dumps(["lwip", "gone"])) assert toolchain.load_cached_builtin_components() is None # A plain file with the right name is not a component directory. @@ -703,7 +705,7 @@ def test_component_cache_save_skips_empty_list_or_custom_idf_path( setup_core: Path, tmp_path: Path ) -> None: _setup_build(setup_core) - with _cache_env(tmp_path, "") as idf_path: + with _cache_env(tmp_path) as idf_path: toolchain.save_cached_builtin_components([]) with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}): toolchain.save_cached_builtin_components(["lwip"]) @@ -715,7 +717,7 @@ def test_component_cache_write_failure_is_logged( ) -> None: _setup_build(setup_core) with ( - _cache_env(tmp_path, ""), + _cache_env(tmp_path), patch.object(toolchain, "write_file", side_effect=EsphomeError("disk full")), ): toolchain.save_cached_builtin_components(["lwip"]) @@ -725,7 +727,7 @@ def test_component_cache_write_failure_is_logged( def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) -> None: _setup_build(setup_core) - with _cache_env(tmp_path, ""): + with _cache_env(tmp_path): path = toolchain._builtin_component_cache_path() path.parent.mkdir(parents=True) path.write_text("{not json") From 456e3d8979e640f318f3156f8d70b5c4cb5c45aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:10:18 -1000 Subject: [PATCH 145/435] [ci] Keep bus group partners on the same runner (#20431) --- script/split_components_for_ci.py | 22 ++++++++++++++++---- tests/script/test_split_components_for_ci.py | 18 ++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 26907936c17..663f2c6d5d7 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -262,8 +262,9 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """Spread items over enough runners to stay near target_seconds each. The runner count comes from the total estimate with every grouped build - counted once. Heaviest items go first, each to the runner that ends up - lightest, so grouped components follow the builds they can join. + counted once. Heaviest items go first, each to the runner it adds the + fewest seconds to among those ending near the lightest, so grouped + components follow the builds they can join. """ if not items: return [] @@ -274,10 +275,23 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] + # Among the runners ending within slack of the lightest, the one the item + # adds the fewest seconds to wins, so a component lands with the bus + # group partners it shares builds with; placed away from them it builds + # alone on every platform they only share there. + slack = target_seconds // 4 for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): - min(batches, key=lambda b: (b.seconds + b.added_seconds(item), b.seconds)).add( - item + costs = [(batch, batch.added_seconds(item)) for batch in batches] + ceiling = min(batch.seconds + added for batch, added in costs) + slack + batch, _ = min( + ( + (batch, added) + for batch, added in costs + if batch.seconds + added <= ceiling + ), + key=lambda cost: (cost[1], cost[0].seconds + cost[1]), ) + batch.add(item) return [batch.components for batch in batches if batch.components] diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 4da644d6798..718d754f016 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -249,6 +249,24 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: assert item.own_seconds == 55 +def test_balance_batches_keeps_partners_together() -> None: + """Two components sharing a build land on one runner even when the + lightest runner is the other one.""" + share = { + ("i2c", "esp32-idf"): split_components_for_ci._GroupedShare(55, frozenset()), + ("i2c", "esp8266-ard"): split_components_for_ci._GroupedShare(30, frozenset()), + } + items = [ + split_components_for_ci._BatchItem("partner_a", 0, dict(share)), + split_components_for_ci._BatchItem("partner_b", 0, dict(share)), + split_components_for_ci._BatchItem("alone", 80), + ] + + batches = split_components_for_ci.balance_batches(items, target_seconds=120) + + assert sorted(batches) == [["alone"], ["partner_a", "partner_b"]] + + def test_balance_batches_empty() -> None: """No components means no runners.""" assert split_components_for_ci.balance_batches([], 600) == [] From 94e6bd90d8c5e4f709f76b88dfcd5182dc31f8b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:19:22 -1000 Subject: [PATCH 146/435] [sensor] Merge the radar timeout and throttle filters into one (#20118) --- esphome/components/ld2410/sensor.py | 80 ++---------- esphome/components/ld2412/sensor.py | 80 ++---------- esphome/components/ld2450/sensor.py | 120 ++---------------- esphome/components/ld6002b/sensor.py | 20 +-- esphome/components/sensor/__init__.py | 57 ++++++++- esphome/components/sensor/filter.cpp | 33 ++++- esphome/components/sensor/filter.h | 13 ++ .../sensor/test_timeout_throttle.py | 24 ++++ .../sensor/timeout_throttle.yaml | 52 ++++++++ .../sensor_timeout_throttle_filter.yaml | 67 ++++++++++ .../test_sensor_timeout_throttle_filter.py | 61 +++++++++ 11 files changed, 333 insertions(+), 274 deletions(-) create mode 100644 tests/component_tests/sensor/test_timeout_throttle.py create mode 100644 tests/component_tests/sensor/timeout_throttle.yaml create mode 100644 tests/integration/fixtures/sensor_timeout_throttle_filter.yaml create mode 100644 tests/integration/test_sensor_timeout_throttle_filter.py diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index 5b523d09442..a8636f3df69 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -34,83 +34,35 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_LIGHTBULB, ), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), @@ -123,29 +75,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index e236a7d5f38..f350fb70505 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -34,84 +34,36 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_LIGHTBULB, unit_of_measurement=UNIT_EMPTY, # No standard unit for this light sensor ), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), @@ -124,29 +76,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index e714d353d1f..6f2056330bd 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -46,41 +46,17 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ACCOUNT_GROUP, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_HUMAN_GREETING_PROXIMITY, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ACCOUNT_SWITCH, ), } @@ -92,85 +68,37 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_X): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ALPHA_X_BOX_OUTLINE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_Y): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ALPHA_Y_BOX_OUTLINE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_SPEED): sensor.sensor_schema( device_class=DEVICE_CLASS_SPEED, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SPEEDOMETER_SLOW, unit_of_measurement=UNIT_MILLIMETER_PER_SECOND, ), cv.Optional(CONF_ANGLE): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, unit_of_measurement=UNIT_DEGREES, accuracy_decimals=1, ), cv.Optional(CONF_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_DISTANCE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_RESOLUTION): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_RELATION_ZERO_OR_ONE_TO_ZERO_OR_ONE, unit_of_measurement=UNIT_MILLIMETER, ), @@ -183,41 +111,17 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), } diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 5653f8b9449..d2285187f6b 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -30,44 +30,32 @@ from .const import ( DEPENDENCIES = ["ld6002b"] -# The ld2450 defaults for a streamed value: hold the last reading for a second so a -# dropped frame does not read as absence, then rate-limit what reaches the frontend. -_VALUE_SENSOR_FILTERS = [ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, -] - TARGET_SCHEMA = cv.Schema( { cv.Optional(CONF_X): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_Y): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_Z): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( accuracy_decimals=0, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 4a00904c4b5..2b404442a4d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,5 +1,6 @@ import logging import math +from typing import Any from esphome import automation import esphome.codegen as cg @@ -43,6 +44,7 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TO, CONF_TRIGGER_ID, + CONF_TYPE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WEB_SERVER, @@ -222,6 +224,13 @@ def validate_send_first_at(value): FILTER_REGISTRY = Registry() validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) +# Default for streamed readings (radar sensors): hold the last value for a second so a dropped +# frame doesn't read as absence, then rate limit. Codegen turns the pair into one TimeoutThrottleFilter. +TIMEOUT_THROTTLE_FILTERS = [ + {"timeout": {"timeout": cv.TimePeriod(milliseconds=1000), "value": "last"}}, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, +] + def validate_datapoint(value): if isinstance(value, dict): @@ -293,6 +302,7 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_( TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) +TimeoutThrottleFilter = sensor_ns.class_("TimeoutThrottleFilter", TimeoutFilterLast) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter) HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) @@ -683,6 +693,12 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( ) +def _is_nan_only(values: Any) -> bool: + if not isinstance(values, list): + values = [values] + return bool(values) and all(isinstance(v, float) and math.isnan(v) for v in values) + + @FILTER_REGISTRY.register( "throttle_with_priority", ThrottleWithPriorityFilter, @@ -696,7 +712,7 @@ async def throttle_with_priority_filter_to_code(config, filter_id): # omits `value:`) to avoid the TemplatableFn array + NaN lambda the # generic ValueListFilter path requires. Behavior is identical: NaN sensor # readings always bypass the throttle. - if values and all(isinstance(v, float) and math.isnan(v) for v in values): + if _is_nan_only(values): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) @@ -941,6 +957,43 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +def _timeout_throttle_period( + first: ConfigType, second: ConfigType +) -> cv.TimePeriod | None: + """Period of a `timeout` (value `last`) directly followed by a NaN only `throttle_with_priority`.""" + timeout = first.get("timeout") + throttle = second.get("throttle_with_priority") + if not isinstance(timeout, dict) or not isinstance(throttle, dict): + return None + if timeout[CONF_VALUE] != "last" or timeout[CONF_TIMEOUT] != throttle[CONF_TIMEOUT]: + return None + if not _is_nan_only(throttle[CONF_VALUE]): + return None + return timeout[CONF_TIMEOUT] + + +async def _build_chain_filters(config: list[ConfigType]) -> list: + """Like build_filters, but merges the radar `timeout` + `throttle_with_priority` pair into one filter.""" + filters = [] + i = 0 + while i < len(config): + if ( + i + 1 < len(config) + and (period := _timeout_throttle_period(config[i], config[i + 1])) + is not None + ): + filter_id = config[i][CONF_TYPE_ID].copy() + filter_id.type = TimeoutThrottleFilter + var = cg.new_Pvariable(filter_id, period) + await cg.register_component(var, {}) + filters.append(var) + i += 2 + continue + filters.append(await cg.build_registry_entry(FILTER_REGISTRY, config[i])) + i += 1 + return filters + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_VALUE, "add_on_state_callback", [(float, "x")] @@ -979,7 +1032,7 @@ async def setup_sensor_core_(var, config): cg.add(var.set_force_update(True)) if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") - filters = await build_filters(config[CONF_FILTERS]) + filters = await _build_chain_filters(config[CONF_FILTERS]) cg.add(var.set_filters(filters)) CORE.add_job(_build_sensor_automations, var, config) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 28f7e6100d3..732db5b0b4f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -13,6 +13,17 @@ namespace esphome::sensor { ESPHOME_LOG_TAG(TAG, "sensor.filter"); +/// Shared pass check for throttle_with_priority (NaN only): passes and stamps `last_input` when the +/// period has elapsed, on the first value, or for NaN. +static bool throttle_nan_passes(uint32_t &last_input, uint32_t period, float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (last_input == 0 || now - last_input >= period || std::isnan(value)) { + last_input = now; + return true; + } + return false; +} + // Filter void Filter::input(float value) { ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value); @@ -266,11 +277,8 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleWithPriorityNanFilter::new_value(float value) { - const uint32_t now = App.get_loop_component_start_time(); - if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { - this->last_input_ = now; + if (throttle_nan_passes(this->last_input_, this->min_time_between_inputs_, value)) return value; - } return {}; } @@ -355,6 +363,23 @@ optional TimeoutFilterConfigured::new_value(float value) { return value; } +// TimeoutThrottleFilter +optional TimeoutThrottleFilter::new_value(float value) { + TimeoutFilterLast::new_value(value); + if (throttle_nan_passes(this->last_input_, this->time_period_, value)) + return value; + return {}; +} + +void TimeoutThrottleFilter::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->timeout_start_time_ >= this->time_period_) { + if (throttle_nan_passes(this->last_input_, this->time_period_, this->pending_value_)) + this->output(this->pending_value_); + this->disable_loop(); + } +} + // DebounceFilter optional DebounceFilter::new_value(float value) { App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); }); diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index bc086e3805b..510ee94c6a7 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -479,6 +479,19 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; +/// Codegen use only: `timeout` (value `last`) followed by `throttle_with_priority` (NaN only) with the +/// same period, as one object. Output matches the two filter chain exactly. +class TimeoutThrottleFilter : public TimeoutFilterLast { + public: + explicit TimeoutThrottleFilter(uint32_t time_period) : TimeoutFilterLast(time_period) {} + + optional new_value(float value) override; + void loop() override; + + protected: + uint32_t last_input_{0}; // When the throttle stage last let a value through +}; + class DebounceFilter : public Filter { public: explicit DebounceFilter(uint32_t time_period); diff --git a/tests/component_tests/sensor/test_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py new file mode 100644 index 00000000000..8154471e472 --- /dev/null +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -0,0 +1,24 @@ +"""timeout (value last) followed by throttle_with_priority with the same period becomes one filter.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_timeout_throttle_pair_is_merged( + generate_main: Callable[[str | Path], str], +) -> None: + main_cpp = generate_main("tests/component_tests/sensor/timeout_throttle.yaml") + + # Only the `merged` sensor's adjacent pair becomes one object + assert ( + len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp)) + == 1 + ) + merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp) + assert merged is not None + assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp + # Every other shape keeps its two filters + for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"): + call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp) + assert call is not None and len(call.group(1).split(",")) == 2, sensor_id diff --git a/tests/component_tests/sensor/timeout_throttle.yaml b/tests/component_tests/sensor/timeout_throttle.yaml new file mode 100644 index 00000000000..2557db683cb --- /dev/null +++ b/tests/component_tests/sensor/timeout_throttle.yaml @@ -0,0 +1,52 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: merged + filters: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: 1s + - platform: template + id: other_periods + filters: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: 2s + - platform: template + id: other_order + filters: + - throttle_with_priority: 1s + - timeout: + timeout: 1s + value: last + - platform: template + id: value_list + filters: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: + timeout: 1s + value: [0.0] + - platform: template + id: configured_value + filters: + - timeout: + timeout: 1s + value: 0.0 + - throttle_with_priority: 1s + - platform: template + id: inside_or + filters: + - or: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: 1s diff --git a/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml b/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml new file mode 100644 index 00000000000..5f1f9556e0f --- /dev/null +++ b/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml @@ -0,0 +1,67 @@ +esphome: + name: test-timeout-throttle + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +sensor: + - platform: template + name: "Source" + id: source_sensor + accuracy_decimals: 2 + + # The default radar pair; codegen merges it into one TimeoutThrottleFilter + - platform: copy + source_id: source_sensor + name: "Merged" + filters: + - timeout: + timeout: 200ms + value: last + - throttle_with_priority: 200ms + + # Same chain with an identity filter in between, which keeps the two filter objects + - platform: copy + source_id: source_sensor + name: "Reference" + filters: + - timeout: + timeout: 200ms + value: last + - multiply: 1.0 + - throttle_with_priority: 200ms + +script: + - id: drive + then: + # Burst faster than the period: first passes, the rest are dropped, the timeout republishes the last + - sensor.template.publish: {id: source_sensor, state: 1.0} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: 2.0} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: 3.0} + - delay: 450ms + # NaN inside a burst always passes + - sensor.template.publish: {id: source_sensor, state: 4.0} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: !lambda "return NAN;"} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: 5.0} + - delay: 450ms + # Values right around the period + - sensor.template.publish: {id: source_sensor, state: 6.0} + - delay: 250ms + - sensor.template.publish: {id: source_sensor, state: 7.0} + - delay: 190ms + - sensor.template.publish: {id: source_sensor, state: 8.0} + - delay: 600ms + - sensor.template.publish: {id: source_sensor, state: 99.0} + +button: + - platform: template + name: "Drive" + on_press: + - script.execute: drive diff --git a/tests/integration/test_sensor_timeout_throttle_filter.py b/tests/integration/test_sensor_timeout_throttle_filter.py new file mode 100644 index 00000000000..739421ca3aa --- /dev/null +++ b/tests/integration/test_sensor_timeout_throttle_filter.py @@ -0,0 +1,61 @@ +"""The merged timeout + throttle_with_priority filter publishes exactly what the two filter chain does.""" + +from __future__ import annotations + +import asyncio +import math + +from aioesphomeapi import ButtonInfo, EntityState, SensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_sensor_timeout_throttle_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + loop = asyncio.get_running_loop() + values: dict[str, list[float]] = {"merged": [], "reference": []} + done = {name: loop.create_future() for name in values} + + def on_state(state: EntityState) -> None: + if not isinstance(state, SensorState): + return + name = key_to_sensor.get(state.key) + if name not in values: + return + values[name].append(math.nan if state.missing_state else state.state) + # 99 is published last and republished once by the timeout + if values[name].count(99.0) == 2 and not done[name].done(): + done[name].set_result(True) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + key_to_sensor = build_key_to_entity_mapping( + entities, {"merged": "Merged", "reference": "Reference"} + ) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + button = next( + e.key for e in entities if isinstance(e, ButtonInfo) and e.name == "Drive" + ) + client.button_command(button) + try: + await asyncio.wait_for(asyncio.gather(*done.values()), timeout=5.0) + except TimeoutError: + pytest.fail(f"Timed out: {values}") + + merged, reference = values["merged"], values["reference"] + assert len(merged) == len(reference), (merged, reference) + for got, want in zip(merged, reference, strict=True): + assert (math.isnan(got) and math.isnan(want)) or got == want, ( + merged, + reference, + ) + assert any(math.isnan(v) for v in merged) From 588fde91b252f55d5e19c95f634393ca3e423f5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:19:35 -1000 Subject: [PATCH 147/435] [template] Keep alarm codes in a shared flash table (#20084) --- .../template/alarm_control_panel/__init__.py | 9 ++++++-- .../template_alarm_control_panel.cpp | 16 ++++++++----- .../template_alarm_control_panel.h | 15 +++++------- .../template/config/alarm_codes.yaml | 23 +++++++++++++++++++ .../template/test_template_alarm_codes.py | 20 ++++++++++++++++ 5 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/template/config/alarm_codes.yaml create mode 100644 tests/component_tests/template/test_template_alarm_codes.py diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index 59624a5f53f..a708c83d09f 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -117,8 +117,13 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await alarm_control_panel.new_alarm_control_panel(config) await cg.register_component(var, config) - if CONF_CODES in config: - cg.add(var.set_codes(config[CONF_CODES])) + # Shared flash table ended by nullptr, so the panel stores only a pointer. + # requires_code_to_arm needs codes (validate_config) and defaults to false. + if codes := config.get(CONF_CODES): + table = cg.shared_progmem_array( + "alarm_codes", cg.const_char_ptr, [*codes, cg.nullptr] + ) + cg.add(var.set_codes(table)) if CONF_REQUIRES_CODE_TO_ARM in config: cg.add(var.set_requires_code_to_arm(config[CONF_REQUIRES_CODE_TO_ARM])) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index 293f0255278..12ff0b5a4b4 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -36,6 +36,11 @@ static const LogString *sensor_type_to_string(AlarmSensorType type) { #endif void TemplateAlarmControlPanel::dump_config() { + size_t num_codes = 0; + if (this->codes_ != nullptr) { + while (this->codes_[num_codes] != nullptr) + num_codes++; + } ESP_LOGCONFIG(TAG, "TemplateAlarmControlPanel:\n" " Current State: %s\n" @@ -47,8 +52,8 @@ void TemplateAlarmControlPanel::dump_config() { " Pending Time: %" PRIu32 "s\n" " Trigger Time: %" PRIu32 "s\n" " Supported Features: %" PRIu32, - LOG_STR_ARG(alarm_control_panel_state_to_string(this->current_state_)), this->codes_.size(), - YESNO(!this->codes_.empty() && this->requires_code_to_arm_), (this->arming_away_time_ / 1000), + LOG_STR_ARG(alarm_control_panel_state_to_string(this->current_state_)), num_codes, + YESNO(this->codes_ != nullptr && this->requires_code_to_arm_), (this->arming_away_time_ / 1000), (this->arming_home_time_ / 1000), (this->arming_night_time_ / 1000), (this->pending_time_ / 1000), (this->trigger_time_ / 1000), this->get_supported_features()); #ifdef USE_BINARY_SENSOR @@ -194,13 +199,12 @@ void TemplateAlarmControlPanel::loop() { } bool TemplateAlarmControlPanel::is_code_valid_(optional code) { - if (!this->codes_.empty()) { + if (this->codes_ != nullptr) { if (code.has_value()) { ESP_LOGVV(TAG, "Checking code: %s", code.value().c_str()); - // Use strcmp for const char* comparison const char *code_cstr = code.value().c_str(); - for (const char *stored_code : this->codes_) { - if (strcmp(stored_code, code_cstr) == 0) + for (const char *const *stored = this->codes_; *stored != nullptr; stored++) { + if (strcmp(*stored, code_cstr) == 0) return true; } return false; diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 5888ce5e29b..c977da10a9b 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -61,7 +61,7 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl void setup() override; void loop() override; uint32_t get_supported_features() const override; - bool get_requires_code() const override { return !this->codes_.empty(); } + bool get_requires_code() const override { return this->codes_ != nullptr; } bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -83,14 +83,11 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl AlarmSensorType type = ALARM_SENSOR_TYPE_DELAYED); #endif - /** Set the codes (from initializer list). + /** Set the codes. * - * @param codes The list of valid codes + * @param codes nullptr-terminated table of valid codes; must outlive the panel */ - void set_codes(std::initializer_list codes) { this->codes_ = codes; } - - // Deleted overload to catch incorrect std::string usage at compile time - void set_codes(std::initializer_list codes) = delete; + void set_codes(const char *const *codes) { this->codes_ = codes; } /** set requires a code to arm * @@ -150,8 +147,8 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl uint32_t pending_time_; // the time in trigger uint32_t trigger_time_; - // a list of codes (const char* pointers to string literals in flash) - FixedVector codes_; + // nullptr-terminated table of codes, shared in flash + const char *const *codes_{nullptr}; // requires a code to arm bool requires_code_to_arm_ = false; bool supports_arm_home_ = false; diff --git a/tests/component_tests/template/config/alarm_codes.yaml b/tests/component_tests/template/config/alarm_codes.yaml new file mode 100644 index 00000000000..afeb45e1d29 --- /dev/null +++ b/tests/component_tests/template/config/alarm_codes.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +alarm_control_panel: + - platform: template + id: panel_a + name: Panel A + codes: + - "1234" + - "5678" + - platform: template + id: panel_b + name: Panel B + codes: + - "1234" + - "5678" + - platform: template + id: panel_none + name: Panel None + codes: [] diff --git a/tests/component_tests/template/test_template_alarm_codes.py b/tests/component_tests/template/test_template_alarm_codes.py new file mode 100644 index 00000000000..1a8e94be909 --- /dev/null +++ b/tests/component_tests/template/test_template_alarm_codes.py @@ -0,0 +1,20 @@ +"""Tests for the template alarm control panel codes codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_codes_share_one_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Identical code lists share one nullptr-terminated flash table; empty lists set none.""" + main_cpp = generate_main(component_config_path("alarm_codes.yaml")) + + assert ( + 'static constexpr const char * alarm_codes[] PROGMEM = {"1234", "5678", nullptr};' + in main_cpp + ) + assert "panel_a->set_codes(alarm_codes);" in main_cpp + assert "panel_b->set_codes(alarm_codes);" in main_cpp + assert "panel_none->set_codes(" not in main_cpp From f7b80e6d44b70fa444cf6d3184f609112f917f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:20:17 -1000 Subject: [PATCH 148/435] [dlms_meter] Remove deprecated provider option (#20385) --- esphome/components/dlms_meter/__init__.py | 75 +++++-------------- tests/component_tests/dlms_meter/__init__.py | 0 .../dlms_meter/test_provider.py | 17 +++++ tests/components/dlms_meter/common.yaml | 7 +- 4 files changed, 40 insertions(+), 59 deletions(-) create mode 100644 tests/component_tests/dlms_meter/__init__.py create mode 100644 tests/component_tests/dlms_meter/test_provider.py diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index f23b2de6c21..6d86da6ccf5 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,4 +1,3 @@ -import logging import re from typing import Any @@ -18,8 +17,6 @@ from esphome.const import ( from esphome.core import CORE from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] DOMAIN = "dlms_meter" @@ -75,56 +72,6 @@ def validate_custom_pattern(value: ConfigType) -> ConfigType: return value -def validate_provider_deprecation(config: ConfigType) -> ConfigType: - if CONF_PROVIDER in config: - provider = str(config[CONF_PROVIDER]).lower() - if provider == "netznoe": - _LOGGER.warning( - "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " - "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" - "Please remove the 'provider' key and explicitly replace it with the following:\n\n" - "custom_patterns:\n" - ' - pattern: "L, TSTR"\n' - ' name: "MeterID"\n' - ' default_obis: "0.0.96.1.0.255"\n' - ' - pattern: "F, TDTM"\n' - ' name: "DateTime"\n' - ' default_obis: "0.0.1.0.0.255"\n' - ) - patterns = config.get(CONF_CUSTOM_PATTERNS, []) - - # Ensure "L, TSTR" for MeterID is present - if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): - patterns.append( - { - CONF_PATTERN: "L, TSTR", - CONF_NAME: "MeterID", - CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], - CONF_PRIORITY: 0, - } - ) - - # Ensure "F, TDTM" for DateTime is present - if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): - patterns.append( - { - CONF_PATTERN: "F, TDTM", - CONF_NAME: "DateTime", - CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], - CONF_PRIORITY: 0, - } - ) - - config[CONF_CUSTOM_PATTERNS] = patterns - else: - _LOGGER.warning( - "The 'provider' option is deprecated and will be removed in 2026.11.0. " - "The dlms_parser library now handles quirks dynamically. " - "Please remove this option from your configuration." - ) - return config - - CUSTOM_PATTERN_SCHEMA = cv.All( custom_pattern_dict, cv.Schema( @@ -138,7 +85,7 @@ CUSTOM_PATTERN_SCHEMA = cv.All( validate_custom_pattern, ) -CONFIG_SCHEMA = cv.All( +CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), @@ -146,15 +93,27 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"), cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, - cv.Optional(CONF_PROVIDER): cv.string, + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_PROVIDER): cv.invalid( + "The 'provider' option has been removed in ESPHome 2026.11.0.\n" + "For 'provider: netznoe', replace it with:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n\n' + "For any other provider, remove the option" + ), cv.Optional( CONF_RECEIVE_TIMEOUT, default="1000ms" ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), - validate_provider_deprecation, + .extend(cv.COMPONENT_SCHEMA) ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) @@ -193,7 +152,7 @@ async def to_code(config: ConfigType) -> None: cg.ArrayInitializer( p[CONF_PATTERN], name_expr, - p.get(CONF_PRIORITY, 0), + p[CONF_PRIORITY], obis_expr, ) ) diff --git a/tests/component_tests/dlms_meter/__init__.py b/tests/component_tests/dlms_meter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/dlms_meter/test_provider.py b/tests/component_tests/dlms_meter/test_provider.py new file mode 100644 index 00000000000..9ce07f118ac --- /dev/null +++ b/tests/component_tests/dlms_meter/test_provider.py @@ -0,0 +1,17 @@ +"""Tests for the dlms_meter provider option removed in 2026.11.0.""" + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.dlms_meter import CONFIG_SCHEMA + + +def test_removed_provider_shows_the_netznoe_patterns() -> None: + with pytest.raises(Invalid) as exc_info: + CONFIG_SCHEMA({"provider": "netznoe"}) + errors = ( + exc_info.value.errors + if isinstance(exc_info.value, MultipleInvalid) + else [exc_info.value] + ) + assert any('pattern: "L, TSTR"' in str(error) for error in errors) diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 59d854a3ae5..4d32f4e02fe 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -4,10 +4,15 @@ dlms_meter: decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" auth_key: "11223344556677889900AABBCCDDEEFF" skip_crc: true - provider: "netznoe" custom_patterns: - "custom_pattern_1" - "custom_pattern_2" + - pattern: "L, TSTR" + name: "MeterID" + default_obis: "0.0.96.1.0.255" + - pattern: "F, TDTM" + name: "DateTime" + default_obis: "0.0.1.0.0.255" sensor: # Old Schema tests From 5cd7bf59931b8775e95e8bc84774e7a1ccbba16b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:20:46 -1000 Subject: [PATCH 149/435] [wireguard] Keep allowed IPs in a shared flash table (#20087) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/wireguard/__init__.py | 19 ++++++++------ esphome/components/wireguard/wireguard.cpp | 8 +++--- esphome/components/wireguard/wireguard.h | 8 +++--- .../wireguard/config/allowed_ips.yaml | 26 +++++++++++++++++++ .../wireguard/test_allowed_ips.py | 21 +++++++++++++++ 5 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/wireguard/config/allowed_ips.yaml create mode 100644 tests/component_tests/wireguard/test_allowed_ips.py diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 069b291574d..e9641a5957c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -121,16 +121,19 @@ async def to_code(config): ) ) + # Shared flash table ended by an empty entry, so the component stores only a pointer. + entries = [ + cg.StructInitializer( + AllowedIP, + ("ip", str(ip.network_address)), + ("netmask", str(ip.netmask)), + ) + for ip in allowed_ips + ] + end = cg.StructInitializer(AllowedIP, ("ip", cg.nullptr), ("netmask", cg.nullptr)) cg.add( var.set_allowed_ips( - [ - cg.StructInitializer( - AllowedIP, - ("ip", str(ip.network_address)), - ("netmask", str(ip.netmask)), - ) - for ip in allowed_ips - ] + cg.shared_progmem_array("wireguard_allowed_ips", AllowedIP, [*entries, end]) ) ) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index 73e34b6592e..01921f17e8a 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -154,8 +154,8 @@ void Wireguard::dump_config() { (this->preshared_key_ != nullptr ? preshared_key_masked : LOG_STR_LITERAL("NOT IN USE"))); // clang-format on ESP_LOGCONFIG(TAG, " Peer Allowed IPs:"); - for (const AllowedIP &allowed_ip : this->allowed_ips_) { - ESP_LOGCONFIG(TAG, " - %s/%s", allowed_ip.ip, allowed_ip.netmask); + for (const AllowedIP *it = this->allowed_ips_; it != nullptr && it->ip != nullptr; it++) { + ESP_LOGCONFIG(TAG, " - %s/%s", it->ip, it->netmask); } ESP_LOGCONFIG(TAG, " Peer Persistent Keepalive: %d%s", this->keepalive_, (this->keepalive_ > 0 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL(" (DISABLED)"))); @@ -249,8 +249,8 @@ void Wireguard::start_connection_() { ESP_LOGD(TAG, "Configuring allowed IPs list"); bool allowed_ips_ok = true; - for (const AllowedIP &ip : this->allowed_ips_) { - allowed_ips_ok &= (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), ip.ip, ip.netmask) == ESP_OK); + for (const AllowedIP *it = this->allowed_ips_; it != nullptr && it->ip != nullptr; it++) { + allowed_ips_ok &= (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), it->ip, it->netmask) == ESP_OK); } if (allowed_ips_ok) { diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index faf63cd444c..6125d2f52cf 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -2,7 +2,6 @@ #include "esphome/core/defines.h" #ifdef USE_WIREGUARD #include -#include #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -58,9 +57,8 @@ class Wireguard final : public PollingComponent { void set_peer_public_key(const std::string &key) = delete; void set_preshared_key(const std::string &key) = delete; - void set_allowed_ips(std::initializer_list ips) { this->allowed_ips_ = ips; } - /// Prevent accidental use of std::string which would dangle - void set_allowed_ips(std::initializer_list> ips) = delete; + /// Table ended by an entry with a null ip; must outlive the component. + void set_allowed_ips(const AllowedIP *ips) { this->allowed_ips_ = ips; } void set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } void set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } @@ -105,7 +103,7 @@ class Wireguard final : public PollingComponent { const char *peer_public_key_{nullptr}; const char *preshared_key_{nullptr}; - FixedVector allowed_ips_; + const AllowedIP *allowed_ips_{nullptr}; uint16_t peer_port_; uint16_t keepalive_; diff --git a/tests/component_tests/wireguard/config/allowed_ips.yaml b/tests/component_tests/wireguard/config/allowed_ips.yaml new file mode 100644 index 00000000000..7f191f90fb7 --- /dev/null +++ b/tests/component_tests/wireguard/config/allowed_ips.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: test + password: password1 + +time: + - platform: sntp + id: sntp_time + +wireguard: + time_id: sntp_time + address: 172.16.34.100 + netmask: 255.255.255.0 + private_key: wPBMxtNYH3mChicrbpsRpZIasIdPq3yZuthn23FbGG8= + peer_public_key: Hs2JfikvYU03/Kv3YoAs1hrUIPPTEkpsZKSPUljE9yc= + peer_endpoint: wg.server.example + peer_allowed_ips: + - 172.16.34.0/24 + - 192.168.0.0/16 diff --git a/tests/component_tests/wireguard/test_allowed_ips.py b/tests/component_tests/wireguard/test_allowed_ips.py new file mode 100644 index 00000000000..ff490c516e2 --- /dev/null +++ b/tests/component_tests/wireguard/test_allowed_ips.py @@ -0,0 +1,21 @@ +"""Tests for the wireguard allowed IPs codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_allowed_ips_are_a_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Allowed IPs live in a flash table ended by an empty entry instead of a heap vector.""" + main_cpp = generate_main(component_config_path("allowed_ips.yaml")) + + table = main_cpp.split( + "wireguard::AllowedIP wireguard_allowed_ips[] PROGMEM = ", 1 + )[1] + table = table.split(";", 1)[0] + assert '.ip = "172.16.34.0"' in table + assert '.netmask = "255.255.0.0"' in table + assert ".ip = nullptr" in table + assert "->set_allowed_ips(wireguard_allowed_ips);" in main_cpp From 23d13d3a6ee5cd3e8efa59be2ec6b56e908e8d4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:21:16 -1000 Subject: [PATCH 150/435] [core] Stop re-exporting alloc_helpers.h from helpers.h (#20389) --- esphome/components/debug/debug_zephyr.cpp | 1 + esphome/core/alloc_helpers.h | 32 +++++++++++++ esphome/core/helpers.cpp | 19 -------- esphome/core/helpers.h | 56 ----------------------- script/ci-custom.py | 4 -- 5 files changed, 33 insertions(+), 79 deletions(-) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index f020c8cd772..cec2ab0be8b 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -1,6 +1,7 @@ #include "debug_component.h" #ifdef USE_ZEPHYR #include +#include "esphome/core/alloc_helpers.h" #include "esphome/core/log.h" #include #include diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h index 1da3162333b..6f824b5383c 100644 --- a/esphome/core/alloc_helpers.h +++ b/esphome/core/alloc_helpers.h @@ -10,12 +10,16 @@ /// Prefer the stack-based alternatives documented on each function instead. /// New code should avoid using these functions. +#include #include #include #include #include +#include #include +#include "esphome/core/helpers.h" + namespace esphome { // --- String helpers (allocating) --- @@ -70,6 +74,19 @@ std::string format_hex(const uint8_t *data, size_t length); /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. std::string format_hex(const std::vector &data); +/// Format an unsigned integer in lowercased hex, starting with the most significant byte. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +template::value, int> = 0> std::string format_hex(T val) { + val = convert_big_endian(val); + return format_hex(reinterpret_cast(&val), sizeof(T)); +} + +/// Format the std::array \p data in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +template std::string format_hex(const std::array &data) { + return format_hex(data.data(), data.size()); +} + /// Format a byte array in pretty-printed, human-readable hex format. /// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); @@ -90,10 +107,25 @@ std::string format_hex_pretty(const std::vector &data, char separator /// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); +/// Format an unsigned integer in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +template::value, int> = 0> +std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { + val = convert_big_endian(val); + return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); +} + /// Format the byte array \p data of length \p len in binary. /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. std::string format_bin(const uint8_t *data, size_t length); +/// Format an unsigned integer in binary, starting with the most significant byte. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +template::value, int> = 0> std::string format_bin(T val) { + val = convert_big_endian(val); + return format_bin(reinterpret_cast(&val), sizeof(T)); +} + // --- Base64 helpers (allocating) --- /// Encode a byte buffer to base64 string. diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 6130f7e27df..1b332515f66 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -261,7 +261,6 @@ bool str_contains_ignore_case_p(const char *haystack, PGM_P needle) { } #endif // USE_ESP8266 -// str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { return buffer; @@ -274,8 +273,6 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { return buffer; } -// str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp - size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len) { size_t total_len = name_len + 1 + suffix_len; @@ -309,8 +306,6 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { return chars; } -// format_mac_address_pretty moved to alloc_helpers.cpp - // Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. // When separator is set, it is written unconditionally after each byte and the last // one is overwritten with '\0', eliminating the per-byte `i < length - 1` check. @@ -428,8 +423,6 @@ const char *json_escape_into_buffer(std::span buf, StringRef value, bool s return buf.data(); } -// format_hex (std::string returning overloads) moved to alloc_helpers.cpp - char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator) { return format_hex_internal(buffer, buffer_size, data, length, separator, 'A'); } @@ -465,8 +458,6 @@ char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint16_t *dat return buffer; } -// format_hex_pretty (all std::string returning overloads) moved to alloc_helpers.cpp - char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { if (buffer_size == 0) { return buffer; @@ -488,8 +479,6 @@ char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return buffer; } -// format_bin moved to alloc_helpers.cpp - ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { if (on == nullptr && ESPHOME_strcasecmp_P(str, ESPHOME_PSTR("on")) == 0) return PARSE_ON; @@ -537,8 +526,6 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -// value_accuracy_to_string moved to alloc_helpers.cpp - // Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). // Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. // Caller must guarantee value is finite and |value| * mult fits in uint32_t. @@ -639,8 +626,6 @@ static constexpr uint8_t base64_char_value(uint8_t c) { return INVALID_BASE64_CHAR; } -// base64_encode (both overloads) moved to alloc_helpers.cpp - size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); } @@ -668,8 +653,6 @@ size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *b return out; } -// base64_decode (vector-returning overload) moved to alloc_helpers.cpp - /// Decode base64/base64url string directly into vector of little-endian int32 values /// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted) /// @param out Output vector (cleared and filled with decoded int32 values) @@ -790,8 +773,6 @@ void HighFrequencyLoopRequester::stop() { this->started_ = false; } -// get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp - void get_mac_address_into_buffer(std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b88a9d70e93..a2ede273413 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -26,12 +26,6 @@ #include "esphome/core/optional.h" #include "esphome/core/time_conversion.h" -// Backward compatibility re-export of heap-allocating helpers. -// These functions have moved to alloc_helpers.h. External components should -// update their includes to use #include "esphome/core/alloc_helpers.h" directly. -// This re-export will be removed in 2026.11.0. -#include "esphome/core/alloc_helpers.h" - #ifdef USE_ESP8266 #include #include @@ -1105,13 +1099,8 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) { } #endif // USE_ESP8266 -// str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 - -// str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } -// str_snake_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. constexpr char to_sanitized_char(char c) { @@ -1134,8 +1123,6 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s return str_sanitize_to(buffer, N, str); } -// str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. /// This computes object_id hashes directly from names without creating an intermediate buffer. /// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. @@ -1150,8 +1137,6 @@ inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { return hash; } -// str_snprintf, str_sprintf moved to alloc_helpers.h - remove this comment before 2026.11.0 - #ifdef USE_ESP8266 // ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM) // Format strings must be wrapped with PSTR() macro @@ -1605,32 +1590,6 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { format_hex_to(output, MAC_ADDRESS_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE); } -// format_mac_address_pretty, format_hex (all overloads) moved to alloc_helpers.h -// Remove this comment and the template overloads below before 2026.11.0 - -/// Format an unsigned integer in lowercased hex, starting with the most significant byte. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -template::value, int> = 0> std::string format_hex(T val) { - val = convert_big_endian(val); - return format_hex(reinterpret_cast(&val), sizeof(T)); -} -/// Format the std::array \p data in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -template std::string format_hex(const std::array &data) { - return format_hex(data.data(), data.size()); -} - -// format_hex_pretty (all overloads) moved to alloc_helpers.h -// Remove this comment and the template overload below before 2026.11.0 - -/// Format an unsigned integer in pretty-printed, human-readable hex format. -/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. -template::value, int> = 0> -std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { - val = convert_big_endian(val); - return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); -} - /// Calculate buffer size needed for format_bin_to: "01234567...\0" = bytes * 8 + 1 constexpr size_t format_bin_size(size_t byte_count) { return byte_count * 8 + 1; } @@ -1684,15 +1643,6 @@ inline char *format_bin_to(char (&buffer)[N], T val) { return format_bin_to(buffer, reinterpret_cast(&val), sizeof(T)); } -// format_bin moved to alloc_helpers.h - remove this comment and template overload before 2026.11.0 - -/// Format an unsigned integer in binary, starting with the most significant byte. -/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -template::value, int> = 0> std::string format_bin(T val) { - val = convert_big_endian(val); - return format_bin(reinterpret_cast(&val), sizeof(T)); -} - /// Return values for parse_on_off(). enum ParseOnOffState : uint8_t { PARSE_NONE = 0, @@ -1703,8 +1653,6 @@ enum ParseOnOffState : uint8_t { /// Parse a string that contains either on, off or toggle. ParseOnOffState parse_on_off(const char *str, const char *on = nullptr, const char *off = nullptr); -// value_accuracy_to_string moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Maximum buffer size for value_accuracy formatting (float ~15 chars + space + UOM ~40 chars + null) static constexpr size_t VALUE_ACCURACY_MAX_LEN = 64; @@ -1717,8 +1665,6 @@ size_t value_accuracy_with_uom_to_buf(std::span bu /// Derive accuracy in decimals from an increment step. int8_t step_to_accuracy_decimals(float step); -// base64_encode (both overloads), base64_decode (vector overload) moved to alloc_helpers.h -// Remove this comment before 2026.11.0 size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len); size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len); @@ -2177,8 +2123,6 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) -// get_mac_address, get_mac_address_pretty moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Get the device MAC address into the given buffer, in lowercase hex notation. /// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null /// terminator). diff --git a/script/ci-custom.py b/script/ci-custom.py index bd9b4597539..1b3abc40f2d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1071,9 +1071,6 @@ HEAP_ALLOCATING_HELPERS = { # The definitions themselves "esphome/core/alloc_helpers.h", "esphome/core/alloc_helpers.cpp", - # Backward compatibility re-exports (remove before 2026.11.0) - "esphome/core/helpers.h", - "esphome/core/helpers.cpp", # Vendored third-party library "esphome/components/http_request/httplib.h", ], @@ -1126,7 +1123,6 @@ def lint_no_sprintf(fname, match): # Vendored library "esphome/components/http_request/httplib.h", # Deprecated helpers that return std::string - "esphome/core/helpers.cpp", "esphome/core/alloc_helpers.cpp", # The using declaration itself "esphome/core/helpers.h", From 3442bb6624880fe0506791f62d4841be1a2d2fb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:21:29 -1000 Subject: [PATCH 151/435] [core] Remove deprecated esphome::RingBuffer alias (#20390) --- esphome/core/ring_buffer.h | 22 ---------------------- esphome/writer.py | 5 +---- 2 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 esphome/core/ring_buffer.h diff --git a/esphome/core/ring_buffer.h b/esphome/core/ring_buffer.h deleted file mode 100644 index fbb089e9066..00000000000 --- a/esphome/core/ring_buffer.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#ifdef USE_ESP32 - -// Deprecated: include "esphome/components/ring_buffer/ring_buffer.h" and use -// esphome::ring_buffer::RingBuffer. This shim will be removed in 2026.11.0. -#if __has_include("esphome/components/ring_buffer/ring_buffer.h") -#include "esphome/components/ring_buffer/ring_buffer.h" -#else -#error \ - "esphome/components/ring_buffer/ring_buffer.h not found. Add 'ring_buffer' to your component's AUTO_LOAD list to use esphome::ring_buffer::RingBuffer." -#endif -#include "esphome/core/helpers.h" // for ESPDEPRECATED - -namespace esphome { - -using RingBuffer ESPDEPRECATED("Use esphome::ring_buffer::RingBuffer instead. Removed in 2026.11.0.", - "2026.5.0") = ring_buffer::RingBuffer; - -} // namespace esphome - -#endif diff --git a/esphome/writer.py b/esphome/writer.py index 0bd485bfd31..559d7d85828 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -212,14 +212,11 @@ BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" # Headers that must not be included bare from esphome.h or the clang-tidy -# all-headers file: X-macro files, headers main.cpp includes itself, and -# deprecated headers that only resolve when their new component is loaded. +# all-headers file: X-macro files and headers main.cpp includes itself. ESPHOME_H_EXCLUDE = { Path(ENTITY_TYPES_H_TARGET), # main.cpp includes it after defining esphome_controllers() Path("esphome/core/controller_dispatch.h"), - # moved to components/ring_buffer/, removed in 2026.11.0 - Path("esphome/core/ring_buffer.h"), # build machinery, not user API Path("esphome/core/pch_prefix.h"), } From 9d02ab3734d003d36fe63964f57e9f8a339daa37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:22:32 -1000 Subject: [PATCH 152/435] [remote_base] Keep constant IR codes in flash (#20083) --- esphome/components/remote_base/__init__.py | 32 ++++++++------ .../components/remote_base/aeha_protocol.h | 3 +- .../components/remote_base/haier_protocol.h | 2 +- .../components/remote_base/midea_protocol.h | 10 ++++- .../components/remote_base/mirage_protocol.h | 2 +- .../remote_transmitter/config/ir_codes.yaml | 43 +++++++++++++++++++ .../remote_transmitter/test_ir_codes.py | 24 +++++++++++ 7 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/ir_codes.yaml create mode 100644 tests/component_tests/remote_transmitter/test_ir_codes.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 872106e405d..f8e0b4d33c7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2061,11 +2061,20 @@ def midea_dumper(var, config): pass +async def _code_bytes( + value: Any, args: TemplateArgsType, set_template: MockObj, set_static: MockObj +) -> None: + """Constant codes share one PROGMEM table pool across the remote protocols.""" + await automation.templatable_bytes( + value, args, set_template, set_static, "remote_base_code" + ) + + @register_action("midea", MideaAction, MIDEA_SCHEMA) async def midea_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) + await _code_bytes( + config[CONF_CODE], args, var.set_code_template, var.set_code_static + ) # AEHA @@ -2117,10 +2126,9 @@ def aeha_dumper(var, config): async def aeha_action(var, config, args): template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) cg.add(var.set_address(template_)) - template_ = await cg.templatable( - config[CONF_DATA], args, cg.std_vector.template(cg.uint8) + await _code_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static ) - cg.add(var.set_data(template_)) templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) cg.add(var.set_carrier_frequency(templ)) @@ -2216,9 +2224,9 @@ def haier_dumper(var, config): @register_action("haier", HaierAction, HAIER_SCHEMA) async def haier_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) + await _code_bytes( + config[CONF_CODE], args, var.set_code_template, var.set_code_static + ) # ABBWelcome @@ -2359,9 +2367,9 @@ def mirage_dumper(var, config): @register_action("mirage", MirageAction, MIRAGE_SCHEMA) async def mirage_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) + await _code_bytes( + config[CONF_CODE], args, var.set_code_template, var.set_code_static + ) # Toto diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 98a55011552..57706a7e4f1 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -28,10 +28,9 @@ DECLARE_REMOTE_PROTOCOL(AEHA) template class AEHAAction : public RemoteTransmitterActionBase { public: TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(std::vector, data) + TEMPLATABLE_BYTES(data) TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - void set_data(const std::vector &data) { data_ = data; } void encode(RemoteTransmitData *dst, Ts... x) override { AEHAData data{}; data.address = this->address_.value(x...); diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index e1fd60411fc..90af88d2c78 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -25,7 +25,7 @@ DECLARE_REMOTE_PROTOCOL(Haier) template class HaierAction : public RemoteTransmitterActionBase { public: - TEMPLATABLE_VALUE(std::vector, code) + TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { HaierData data{}; diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 85bbef1cb1f..19521e8f6d2 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -4,6 +4,7 @@ #include #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "remote_base.h" @@ -21,6 +22,9 @@ class MideaData { MideaData(const std::vector &data) { std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); } + MideaData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } uint8_t *data() { return this->data_.data(); } const uint8_t *data() const { return this->data_.data(); } @@ -75,10 +79,12 @@ class MideaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Midea) template class MideaAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(std::vector, code) + public: + TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - MideaData data(this->code_.value(x...)); + MideaData data = this->code_.is_static() ? MideaData(this->code_.data(), this->code_.size()) + : MideaData(this->code_.value(x...)); data.finalize(); MideaProtocol().encode(dst, data); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index a37fb93f4fd..958127ba664 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -25,7 +25,7 @@ DECLARE_REMOTE_PROTOCOL(Mirage) template class MirageAction : public RemoteTransmitterActionBase { public: - TEMPLATABLE_VALUE(std::vector, code) + TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { MirageData data{}; diff --git a/tests/component_tests/remote_transmitter/config/ir_codes.yaml b/tests/component_tests/remote_transmitter/config/ir_codes.yaml new file mode 100644 index 00000000000..69cf1fb039c --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/ir_codes.yaml @@ -0,0 +1,43 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: Midea A + on_press: + - remote_transmitter.transmit_midea: + code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF] + - platform: template + name: Midea B + on_press: + - remote_transmitter.transmit_midea: + code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF] + - platform: template + name: AEHA + on_press: + - remote_transmitter.transmit_aeha: + address: 0x8008 + data: [0x00, 0x02, 0xFD, 0xFF, 0x00, 0x33] + - platform: template + name: Haier lambda + on_press: + - remote_transmitter.transmit_haier: + code: !lambda |- + return {0xA6, 0xDA, 0x00, 0x00}; + - platform: template + name: Haier static + on_press: + - remote_transmitter.transmit_haier: + code: [0xA6, 0xDA, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05] + - platform: template + name: Mirage static + on_press: + - remote_transmitter.transmit_mirage: + code: [0x56, 0x6C, 0x00, 0x00, 0x20, 0x1A, 0x00, 0x00, 0x0C, 0x00, 0x0C, 0x00, 0x00, 0x42] diff --git a/tests/component_tests/remote_transmitter/test_ir_codes.py b/tests/component_tests/remote_transmitter/test_ir_codes.py new file mode 100644 index 00000000000..9be34941b61 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_ir_codes.py @@ -0,0 +1,24 @@ +"""Constant IR byte codes live in shared PROGMEM tables; lambdas stay lambdas.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_ir_codes_use_shared_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("ir_codes.yaml")) + + tables = re.findall( + r"static constexpr uint8_t (remote_base_code\w*)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 4 # Midea (shared by two actions), AEHA, Haier, Mirage + midea = tables[0] + assert main_cpp.count(f"->set_code_static({midea}, 5);") == 2 + assert re.search(r"->set_data_static\(remote_base_code\w*, 6\);", main_cpp) + assert re.search(r"->set_code_static\(remote_base_code\w*, 13\);", main_cpp) + assert re.search(r"->set_code_static\(remote_base_code\w*, 14\);", main_cpp) + assert "->set_code_template([](" in main_cpp + assert "std::vector{" not in main_cpp From 8084916e30f5c7f9a862856acb72a09cfe288e09 Mon Sep 17 00:00:00 2001 From: Steve Vigneau <67119938+c0nsumer@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:23:10 -0400 Subject: [PATCH 153/435] [remote_base] Add Onkyo RI Wired Serial Support (#18595) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 37 ++++++++++++ .../remote_base/onkyori_protocol.cpp | 59 +++++++++++++++++++ .../components/remote_base/onkyori_protocol.h | 33 +++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_transmitter/common-buttons.yaml | 10 ++++ 5 files changed, 144 insertions(+) create mode 100644 esphome/components/remote_base/onkyori_protocol.cpp create mode 100644 esphome/components/remote_base/onkyori_protocol.h diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index f8e0b4d33c7..a880169fb20 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1019,6 +1019,43 @@ async def nec_action(var, config, args): cg.add(var.set_command_repeats(template_)) +# Onkyo RI +OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, OnkyoRIAction, OnkyoRIDumper = ( + declare_protocol("OnkyoRI") +) +ONKYORI_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_int_range(0, 0xFFF)}) + + +@register_binary_sensor("onkyori", OnkyoRIBinarySensor, ONKYORI_SCHEMA) +def onkyori_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + OnkyoRIData, + ("data", config[CONF_DATA]), + ) + ) + ) + + +@register_trigger("onkyori", OnkyoRITrigger, OnkyoRIData) +def onkyori_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("onkyori", OnkyoRIDumper) +def onkyori_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +@register_action("onkyori", OnkyoRIAction, ONKYORI_SCHEMA) +async def onkyori_action( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: + template_ = await cg.templatable(config[CONF_DATA], args, cg.uint16) + cg.add(var.set_data(template_)) + + # Pioneer ( PioneerData, diff --git a/esphome/components/remote_base/onkyori_protocol.cpp b/esphome/components/remote_base/onkyori_protocol.cpp new file mode 100644 index 00000000000..6290e5bc507 --- /dev/null +++ b/esphome/components/remote_base/onkyori_protocol.cpp @@ -0,0 +1,59 @@ +#include "onkyori_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.onkyori"); + +static constexpr uint8_t NBITS = 12; +static constexpr uint16_t MAX_DATA = (1U << NBITS) - 1; +static constexpr uint32_t HEADER_HIGH_US = 3000; +static constexpr uint32_t HEADER_LOW_US = 1000; +static constexpr uint32_t BIT_HIGH_US = 1000; +static constexpr uint32_t BIT_ONE_LOW_US = 2000; +static constexpr uint32_t BIT_ZERO_LOW_US = 1000; +static constexpr uint32_t TRAILER_HIGH_US = 1000; + +void OnkyoRIProtocol::encode(RemoteTransmitData *dst, const OnkyoRIData &data) { + if (data.data > MAX_DATA) { + ESP_LOGE(TAG, "Data 0x%04X does not fit in a 12-bit RI frame, not sending", data.data); + return; + } + + // RI is a baseband signal on a wire, so it has no carrier to modulate. + dst->set_carrier_frequency(0); + dst->reserve(2 + NBITS * 2u + 1); + + dst->item(HEADER_HIGH_US, HEADER_LOW_US); + + for (uint32_t mask = 1UL << (NBITS - 1); mask != 0; mask >>= 1) { + dst->item(BIT_HIGH_US, (data.data & mask) != 0 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + } + + dst->mark(TRAILER_HIGH_US); +} + +optional OnkyoRIProtocol::decode(RemoteReceiveData src) { + OnkyoRIData out{.data = 0}; + + if (!src.expect_item(HEADER_HIGH_US, HEADER_LOW_US)) + return {}; + + for (uint8_t i = 0; i < NBITS; i++) { + out.data <<= 1; + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + out.data |= 1; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return {}; + } + } + + if (!src.expect_mark(TRAILER_HIGH_US)) + return {}; + + return out; +} + +void OnkyoRIProtocol::dump(const OnkyoRIData &data) { ESP_LOGI(TAG, "Received OnkyoRI: data=0x%03X", data.data); } + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/onkyori_protocol.h b/esphome/components/remote_base/onkyori_protocol.h new file mode 100644 index 00000000000..c6f0f1dd97f --- /dev/null +++ b/esphome/components/remote_base/onkyori_protocol.h @@ -0,0 +1,33 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct OnkyoRIData { + uint16_t data; + + bool operator==(const OnkyoRIData &rhs) const { return data == rhs.data; } +}; + +class OnkyoRIProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const OnkyoRIData &data); + optional decode(RemoteReceiveData src); + void dump(const OnkyoRIData &data); +}; + +DECLARE_REMOTE_PROTOCOL(OnkyoRI) + +template class OnkyoRIAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, data) + + void encode(RemoteTransmitData *dst, Ts... x) override { + OnkyoRIData data{}; + data.data = this->data_.value(x...); + OnkyoRIProtocol().encode(dst, data); + } +}; + +} // namespace esphome::remote_base diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 5d12dc68d43..4dadec6a015 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -103,6 +103,11 @@ on_nexa: - logger.log: format: "on_nexa: %lu %u %u %u %u" args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"] +on_onkyori: + then: + - logger.log: + format: "on_onkyori: 0x%03X" + args: ["x.data"] on_panasonic: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index bdb5f2eea25..776a03e6809 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -252,6 +252,16 @@ button: channel: 1 button: 1 check: 1 + - platform: template + name: OnkyoRI + on_press: + remote_transmitter.transmit_onkyori: + data: 0xD9 + - platform: template + name: OnkyoRI 12-bit + on_press: + remote_transmitter.transmit_onkyori: + data: 0x170 - platform: template name: Toto on_press: From cfbdd58e4a9369d236dbcf123b8e9e2da872e9ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:23:28 -1000 Subject: [PATCH 154/435] [lvgl] Remove deprecated update_on_release option (#20387) --- esphome/components/lvgl/number/__init__.py | 26 ++++++++-------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index 0d8d99ab218..b23e77df22f 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -1,16 +1,10 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv -from esphome.const import CONF_ON_RELEASE, CONF_RESTORE_VALUE +from esphome.const import CONF_RESTORE_VALUE from esphome.cpp_generator import MockObj -from ..defines import ( - CONF_ANIMATED, - CONF_TRIGGER, - CONF_UPDATE_ON_RELEASE, - CONF_WIDGET, - LOGGER, -) +from ..defines import CONF_ANIMATED, CONF_TRIGGER, CONF_UPDATE_ON_RELEASE, CONF_WIDGET from ..lv_validation import animated from ..lvcode import ( EVENT_ARG, @@ -31,20 +25,18 @@ CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend( cv.Required(CONF_WIDGET): cv.use_id(LvNumber), **VALUE_TRIGGER_SCHEMA, cv.Optional(CONF_ANIMATED, default=True): animated, - cv.Optional(CONF_UPDATE_ON_RELEASE): cv.boolean, + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_UPDATE_ON_RELEASE): cv.invalid( + "The 'update_on_release' option has been removed in ESPHome 2026.11.0.\n" + "Use 'trigger: on_release' instead." + ), cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, } ) async def to_code(config): - trigger = config[CONF_TRIGGER] - if CONF_UPDATE_ON_RELEASE in config: - LOGGER.warning( - "Option 'update_on_release' is deprecated and will be removed in 2026.11.0 - use 'trigger: on_release' instead" - ) - if config[CONF_UPDATE_ON_RELEASE]: - trigger = CONF_ON_RELEASE widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] async with LambdaContext([], return_type=cg.float_) as value: @@ -70,6 +62,6 @@ async def to_code(config): lvgl_static.add_event_cb( widget.obj, await event.get_lambda(), - *TRIGGER_EVENT_MAP[trigger], + *TRIGGER_EVENT_MAP[config[CONF_TRIGGER]], ) ) From 7ab24bf8508c599040a1462b97717fdfeb727814 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:23:46 -1000 Subject: [PATCH 155/435] [nextion] Remove deprecated dump_device_info option (#20388) --- esphome/components/nextion/display.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 18f0a73928d..3a70be21caf 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -1,5 +1,3 @@ -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart @@ -43,8 +41,6 @@ from .base_component import ( CONF_WAKE_UP_PAGE, ) -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] DEPENDENCIES = ["uart"] @@ -56,15 +52,6 @@ def AUTO_LOAD() -> list[str]: return base -def _deprecated_dump_device_info(value): - _LOGGER.warning( - "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " - "Device info is now always logged at connection time. " - "Please remove this option from your configuration." - ) - return value - - def _validate_tft_upload(config): has_tft_url = CONF_TFT_URL in config for conf_key in ( @@ -91,9 +78,11 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=TimePeriod(milliseconds=255)), ), - # Deprecated — device info is now always logged. Remove before 2026.11.0. - cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All( - cv.boolean, _deprecated_dump_device_info + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_DUMP_DEVICE_INFO): cv.invalid( + "The 'dump_device_info' option has been removed in ESPHome 2026.11.0.\n" + "Device info is now always logged, so remove this option from your configuration." ), cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean, cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All( From c859c347cb776bce4e296c22374e2fd0606ba7a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:24:32 -1000 Subject: [PATCH 156/435] [core] Pass a single condition to actions directly instead of wrapping it in a group (#19223) --- esphome/automation.py | 57 ++----------------- tests/component_tests/automation/__init__.py | 0 .../automation/config/condition_groups.yaml | 44 ++++++++++++++ .../automation/test_condition_groups.py | 25 ++++++++ 4 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 tests/component_tests/automation/__init__.py create mode 100644 tests/component_tests/automation/config/condition_groups.yaml create mode 100644 tests/component_tests/automation/test_condition_groups.py diff --git a/esphome/automation.py b/esphome/automation.py index 645fe566d41..859ec2f1f3c 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -699,52 +699,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition) @register_condition("and", AndCondition, validate_condition_list) -async def and_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - -@register_condition("or", OrCondition, validate_condition_list) -async def or_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("all", AndCondition, validate_condition_list) -async def all_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - +@register_condition("or", OrCondition, validate_condition_list) @register_condition("any", OrCondition, validate_condition_list) -async def any_condition_to_code( +@register_condition("xor", XorCondition, validate_condition_list) +async def condition_group_to_code( config: ConfigType, condition_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) + # A group of one is that condition for and, or and xor; skip the wrapper. + if len(conditions) == 1: + return conditions[0] return cg.new_Pvariable( condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions ) @@ -761,19 +729,6 @@ async def not_condition_to_code( return cg.new_Pvariable(condition_id, template_arg, condition) -@register_condition("xor", XorCondition, validate_condition_list) -async def xor_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("lambda", LambdaCondition, cv.returning_lambda) async def lambda_condition_to_code( config: ConfigType, diff --git a/tests/component_tests/automation/__init__.py b/tests/component_tests/automation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/automation/config/condition_groups.yaml b/tests/component_tests/automation/config/condition_groups.yaml new file mode 100644 index 00000000000..9624531862f --- /dev/null +++ b/tests/component_tests/automation/config/condition_groups.yaml @@ -0,0 +1,44 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +logger: + +binary_sensor: + - platform: template + id: single_and + on_press: + - if: + condition: + - lambda: return true; + then: + - logger.log: single and + - platform: template + id: single_or + on_press: + - if: + any: + - lambda: return true; + then: + - logger.log: single or + - platform: template + id: double_and + on_press: + - if: + condition: + - lambda: return true; + - lambda: return false; + then: + - logger.log: double and + - platform: template + id: single_xor + on_press: + - if: + condition: + xor: + - lambda: return true; + then: + - logger.log: single xor diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py new file mode 100644 index 00000000000..effe7b5b0bd --- /dev/null +++ b/tests/component_tests/automation/test_condition_groups.py @@ -0,0 +1,25 @@ +"""Tests for and/or condition group codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_single_condition_groups_are_unwrapped( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A group of one condition is passed to the action directly.""" + main_cpp = generate_main(component_config_path("condition_groups.yaml")) + + assert "IfAction(lambdacondition_id);" in main_cpp + assert "IfAction(lambdacondition_id_2);" in main_cpp + group = re.search( + r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + main_cpp, + ) + assert group is not None + assert f"IfAction({group.group(1)});" in main_cpp + # xor of one condition equals that condition, so it is unwrapped too. + assert "IfAction(lambdacondition_id_5);" in main_cpp + assert "XorCondition<" not in main_cpp From 05aeb2f0943bbd78f9c7dcbd59c916a08a22ec57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:25:36 -1000 Subject: [PATCH 157/435] [http_request] Test shared, lambda and trigger argument request headers (#20224) --- .../components/http_request/http_request.yaml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index eb8a6d33e7a..b82c0581d1b 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -71,6 +71,8 @@ esphome: # encode_json_ receives const copies of non-const reference args. - http_request.post: url: https://esphome.io + request_headers: + X-Body-Length: !lambda 'return body.empty() ? "0" : "1";' json: status: "ok" # Same with json: lambda variant, exercises json_func_ path @@ -102,3 +104,23 @@ script: then: - http_request.get: url: "http://google.com" + + - id: send_with_headers + parameters: + token: string + then: + - http_request.get: + url: https://esphome.io + request_headers: + Accept: application/json + X-Device: esphome + - http_request.get: + url: https://esphome.io/other + request_headers: + Accept: application/json + X-Device: esphome + - http_request.get: + url: https://esphome.io + request_headers: + Accept: application/json + X-Token-Set: !lambda 'return token.empty() ? "no" : "yes";' From 9ab0fa9669aa23bc169e6614087f84258dc1f4a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:36:28 -1000 Subject: [PATCH 158/435] [dlms_meter] Remove deprecated predefined sensor keys (#20386) --- esphome/components/dlms_meter/__init__.py | 48 ++--- .../components/dlms_meter/sensor/__init__.py | 178 ++++-------------- .../dlms_meter/text_sensor/__init__.py | 53 ++---- .../dlms_meter/test_removed_keys.py | 33 ++++ tests/components/dlms_meter/common.yaml | 49 ++--- 5 files changed, 112 insertions(+), 249 deletions(-) create mode 100644 tests/component_tests/dlms_meter/test_removed_keys.py diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 6d86da6ccf5..03aca53711e 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -172,43 +172,17 @@ async def to_code(config: ConfigType) -> None: hub_id = config[CONF_ID].id - sensor_count = 0 - for sens_conf in CORE.config.get(SENSOR_DOMAIN, []): - if ( - sens_conf.get("platform") == DOMAIN - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - if CONF_OBIS_CODE in sens_conf: - sensor_count += 1 - else: - from .sensor import NUMERIC_KEYS - - sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) - - text_sensor_count = 0 - for sens_conf in CORE.config.get(TEXT_SENSOR_DOMAIN, []): - if ( - sens_conf.get("platform") == DOMAIN - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - if CONF_OBIS_CODE in sens_conf: - text_sensor_count += 1 - else: - from .text_sensor import TEXT_KEYS - - text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) - - binary_sensor_count = 0 - for sens_conf in CORE.config.get(BINARY_SENSOR_DOMAIN, []): - if ( - sens_conf.get("platform") == DOMAIN - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - binary_sensor_count += 1 - - cg.add_define("DLMS_MAX_SENSORS", sensor_count) - cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) - cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) + for domain, define in ( + (SENSOR_DOMAIN, "DLMS_MAX_SENSORS"), + (TEXT_SENSOR_DOMAIN, "DLMS_MAX_TEXT_SENSORS"), + (BINARY_SENSOR_DOMAIN, "DLMS_MAX_BINARY_SENSORS"), + ): + count = sum( + 1 + for conf in CORE.config.get(domain, []) + if conf.get("platform") == DOMAIN and conf[CONF_DLMS_METER_ID].id == hub_id + ) + cg.add_define(define, count) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 8ded150cd09..0c895ccefb2 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,159 +1,59 @@ -import logging - import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, - UNIT_AMPERE, - UNIT_VOLT, - UNIT_WATT, - UNIT_WATT_HOURS, -) from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["dlms_meter"] -NUMERIC_KEYS = { - "voltage_l1": "1.0.32.7.0.255", - "voltage_l2": "1.0.52.7.0.255", - "voltage_l3": "1.0.72.7.0.255", - "current_l1": "1.0.31.7.0.255", - "current_l2": "1.0.51.7.0.255", - "current_l3": "1.0.71.7.0.255", - "active_power_plus": "1.0.1.7.0.255", - "active_power_minus": "1.0.2.7.0.255", - "active_energy_plus": "1.0.1.8.0.255", - "active_energy_minus": "1.0.2.8.0.255", - "reactive_energy_plus": "1.0.3.8.0.255", - "reactive_energy_minus": "1.0.4.8.0.255", - "power_factor": "1.0.13.7.0.255", +# Removed in 2026.11.0 - kept to provide helpful error message +# Remove before 2027.5.0 +_MEASUREMENT = "state_class: measurement" +_VOLTAGE = f"unit_of_measurement: V, accuracy_decimals: 1, device_class: voltage, {_MEASUREMENT}" +_CURRENT = f"unit_of_measurement: A, accuracy_decimals: 2, device_class: current, {_MEASUREMENT}" +_POWER = ( + f"unit_of_measurement: W, accuracy_decimals: 0, device_class: power, {_MEASUREMENT}" +) +_ENERGY = ( + "unit_of_measurement: Wh, accuracy_decimals: 0, device_class: energy, " + "state_class: total_increasing" +) +_POWER_FACTOR = f"accuracy_decimals: 3, device_class: power_factor, {_MEASUREMENT}" +REMOVED_KEYS = { + "voltage_l1": ("1.0.32.7.0.255", _VOLTAGE), + "voltage_l2": ("1.0.52.7.0.255", _VOLTAGE), + "voltage_l3": ("1.0.72.7.0.255", _VOLTAGE), + "current_l1": ("1.0.31.7.0.255", _CURRENT), + "current_l2": ("1.0.51.7.0.255", _CURRENT), + "current_l3": ("1.0.71.7.0.255", _CURRENT), + "active_power_plus": ("1.0.1.7.0.255", _POWER), + "active_power_minus": ("1.0.2.7.0.255", _POWER), + "active_energy_plus": ("1.0.1.8.0.255", _ENERGY), + "active_energy_minus": ("1.0.2.8.0.255", _ENERGY), + "reactive_energy_plus": ("1.0.3.8.0.255", _ENERGY), + "reactive_energy_minus": ("1.0.4.8.0.255", _ENERGY), + "power_factor": ("1.0.13.7.0.255", _POWER_FACTOR), } -DYNAMIC_SCHEMA = sensor.sensor_schema().extend( + +CONFIG_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), cv.Required(CONF_OBIS_CODE): obis_code, + **{ + cv.Optional(key): cv.invalid( + f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " + f"Add a separate '- platform: dlms_meter' sensor with " + f'obis_code: "{obis}" and {settings}' + ) + for key, (obis, settings) in REMOVED_KEYS.items() + }, } ) -def deprecation_warning(config: ConfigType) -> ConfigType: - _LOGGER.warning( - "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " - "Please update your configuration to use the new schema with 'obis_code'." - ) - return config - - -OLD_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), - } - ).extend(cv.COMPONENT_SCHEMA), - deprecation_warning, -) - - -CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) - - async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - - if obis := config.get(CONF_OBIS_CODE): - var = await sensor.new_sensor(config) - cg.add(hub.register_sensor(obis, var)) - else: - for key, obis_val in NUMERIC_KEYS.items(): - if sensor_config := config.get(key): - sens = await sensor.new_sensor(sensor_config) - cg.add(hub.register_sensor(obis_val, sens)) + var = await sensor.new_sensor(config) + cg.add(hub.register_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index c2ff0779eee..10872d1a50a 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,5 +1,3 @@ -import logging - import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv @@ -7,54 +5,33 @@ from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["dlms_meter"] -TEXT_KEYS = { +# Removed in 2026.11.0 - kept to provide helpful error message +# Remove before 2027.5.0 +REMOVED_KEYS = { "timestamp": "0.0.1.0.0.255", "meternumber": "0.0.96.1.0.255", } -DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( + +CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), cv.Required(CONF_OBIS_CODE): obis_code, + **{ + cv.Optional(key): cv.invalid( + f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " + f"Add a separate '- platform: dlms_meter' text sensor with " + f'obis_code: "{obis}" instead' + ) + for key, obis in REMOVED_KEYS.items() + }, } ) -def deprecation_warning(config: ConfigType) -> ConfigType: - _LOGGER.warning( - "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " - "Please update your configuration to use the new schema with 'obis_code'." - ) - return config - - -OLD_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), - } - ).extend(cv.COMPONENT_SCHEMA), - deprecation_warning, -) - - -CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) - - async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - - if obis := config.get(CONF_OBIS_CODE): - var = await text_sensor.new_text_sensor(config) - cg.add(hub.register_text_sensor(obis, var)) - else: - for key, obis_val in TEXT_KEYS.items(): - if text_sensor_config := config.get(key): - sens = await text_sensor.new_text_sensor(text_sensor_config) - cg.add(hub.register_text_sensor(obis_val, sens)) + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(config[CONF_OBIS_CODE], var)) diff --git a/tests/component_tests/dlms_meter/test_removed_keys.py b/tests/component_tests/dlms_meter/test_removed_keys.py new file mode 100644 index 00000000000..f82ec79bff7 --- /dev/null +++ b/tests/component_tests/dlms_meter/test_removed_keys.py @@ -0,0 +1,33 @@ +"""Tests for the dlms_meter predefined keys removed in 2026.11.0.""" + +from collections.abc import Callable + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.dlms_meter.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA +from esphome.components.dlms_meter.text_sensor import ( + CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA, +) +from esphome.types import ConfigType + + +def _errors(schema: Callable[[ConfigType], ConfigType], config: ConfigType) -> str: + with pytest.raises(Invalid) as exc_info: + schema(config) + errors = ( + exc_info.value.errors + if isinstance(exc_info.value, MultipleInvalid) + else [exc_info.value] + ) + return "\n".join(str(error) for error in errors) + + +def test_removed_sensor_key_names_its_replacement() -> None: + errors = _errors(SENSOR_SCHEMA, {"voltage_l1": {"name": "Voltage"}}) + assert 'obis_code: "1.0.32.7.0.255" and unit_of_measurement: V' in errors + + +def test_removed_text_sensor_key_names_its_replacement() -> None: + errors = _errors(TEXT_SENSOR_SCHEMA, {"timestamp": {"name": "Timestamp"}}) + assert 'obis_code: "0.0.1.0.0.255"' in errors diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 4d32f4e02fe..fa6deff6bec 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -15,57 +15,36 @@ dlms_meter: default_obis: "0.0.1.0.0.255" sensor: - # Old Schema tests - platform: dlms_meter - voltage_l1: - name: "Voltage L1" - voltage_l2: - name: "Voltage L2" - voltage_l3: - name: "Voltage L3" - current_l1: - name: "Current L1" - current_l2: - name: "Current L2" - current_l3: - name: "Current L3" - active_power_plus: - name: "Active power taken from grid" - active_power_minus: - name: "Active power put into grid" - active_energy_plus: - name: "Active energy taken from grid" - active_energy_minus: - name: "Active energy put into grid" - reactive_energy_plus: - name: "Reactive energy taken from grid" - reactive_energy_minus: - name: "Reactive energy put into grid" - power_factor: - name: "Power factor" + obis_code: "1.0.32.7.0.255" + name: "Voltage L1" + unit_of_measurement: V + accuracy_decimals: 1 + device_class: voltage + state_class: measurement + - platform: dlms_meter + obis_code: "1.0.1.8.0.255" + name: "Active energy taken from grid" + unit_of_measurement: Wh + device_class: energy + state_class: total_increasing - # Dynamic Schema tests - platform: dlms_meter dlms_meter_id: dlms_meter_hub obis_code: "1-0:99.99.9" name: "Custom Dynamic Sensor" text_sensor: - # Old Schema tests - platform: dlms_meter - timestamp: - name: "timestamp" - meternumber: - name: "Meter Number" + obis_code: "0.0.1.0.0.255" + name: "timestamp" - # Dynamic Schema tests - platform: dlms_meter dlms_meter_id: dlms_meter_hub obis_code: "0-0:99.99.9" name: "Custom Dynamic Text Sensor" binary_sensor: - # Dynamic Schema tests (Binary sensors only use the dynamic schema) - platform: dlms_meter dlms_meter_id: dlms_meter_hub obis_code: "0-1:2.3.4" From 8416a2986295756b43ac80bfd9298bffcbf72a15 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:03:38 +0200 Subject: [PATCH 159/435] [dlms_meter] Add support for "Landis+Gyr E450 HDLC Cyprus". Adapt the code for new API of dlms_parser (#16942) Co-authored-by: J. Nick Koston --- esphome/components/dlms_meter/__init__.py | 125 +++++-------- .../dlms_meter/binary_sensor/__init__.py | 12 +- esphome/components/dlms_meter/dlms_meter.cpp | 165 +++++++++--------- esphome/components/dlms_meter/dlms_meter.h | 81 ++++----- .../components/dlms_meter/sensor/__init__.py | 12 +- .../dlms_meter/text_sensor/__init__.py | 12 +- esphome/idf_component.yml | 2 +- platformio.ini | 2 +- 8 files changed, 189 insertions(+), 222 deletions(-) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 03aca53711e..eed702b6f1a 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -3,9 +3,6 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart -from esphome.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from esphome.components.sensor import DOMAIN as SENSOR_DOMAIN -from esphome.components.text_sensor import DOMAIN as TEXT_SENSOR_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -15,6 +12,7 @@ from esphome.const import ( CONF_RECEIVE_TIMEOUT, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] @@ -30,19 +28,17 @@ CONF_SKIP_CRC = "skip_crc" CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" +DLMS_PARSER_VERSION = "2.2.0" + dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) +CustomPattern = dlms_meter_component_ns.struct("CustomPattern") +ObisId = cg.global_ns.namespace("dlms_parser").class_("ObisId") -def obis_code(value: Any) -> str: - # Normalize the OBIS code to the strict A.B.C.D.E.F format - bytes_list = parse_obis_code_bytes(value) - return ".".join(str(b) for b in bytes_list) - - -def parse_obis_code_bytes(value: Any) -> list[int]: +def obis_string_to_byte_list(value: Any) -> list[int]: value = cv.string(value) normalized = re.sub(r"[\-\:\*]", ".", value) parts = normalized.split(".") @@ -60,29 +56,48 @@ def parse_obis_code_bytes(value: Any) -> list[int]: return bytes_list +def to_obis_id_struct(value: list[int]) -> cg.Expression: + return ObisId(*value) + + +_request_sensor_slot = cg.slot_counter("DLMS_MAX_SENSORS") +_request_text_sensor_slot = cg.slot_counter("DLMS_MAX_TEXT_SENSORS") +_request_binary_sensor_slot = cg.slot_counter("DLMS_MAX_BINARY_SENSORS") + + +def register_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_sensor_slot(str(hub)) + cg.add(hub.register_sensor(to_obis_id_struct(obis), var)) + + +def register_text_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_text_sensor_slot(str(hub)) + cg.add(hub.register_text_sensor(to_obis_id_struct(obis), var)) + + +def register_binary_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_binary_sensor_slot(str(hub)) + cg.add(hub.register_binary_sensor(to_obis_id_struct(obis), var)) + + def custom_pattern_dict(value: Any) -> ConfigType: if isinstance(value, str): return {CONF_PATTERN: value} return value -def validate_custom_pattern(value: ConfigType) -> ConfigType: - if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: - raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") - return value - - CUSTOM_PATTERN_SCHEMA = cv.All( custom_pattern_dict, cv.Schema( { cv.Required(CONF_PATTERN): cv.string, - cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_NAME, default="CUSTOM"): cv.string, cv.Optional(CONF_PRIORITY, default=0): cv.int_, - cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + cv.Optional( + CONF_DEFAULT_OBIS, default="0.0.0.0.0.0" + ): obis_string_to_byte_list, } ), - validate_custom_pattern, ) CONFIG_SCHEMA = ( @@ -120,74 +135,30 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_ async def to_code(config: ConfigType) -> None: - dec_key_expr = cg.RawExpression("std::nullopt") - if dec_key := config.get(CONF_DECRYPTION_KEY): - key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] - dec_key_expr = cg.RawExpression( - f"std::array{{{', '.join(key_bytes)}}}" + custom_patterns = [ + cg.StructInitializer( + CustomPattern, + (CONF_PATTERN, pattern[CONF_PATTERN]), + (CONF_NAME, pattern[CONF_NAME]), + (CONF_PRIORITY, pattern[CONF_PRIORITY]), + (CONF_DEFAULT_OBIS, to_obis_id_struct(pattern[CONF_DEFAULT_OBIS])), ) - - auth_key_expr = cg.RawExpression("std::nullopt") - if auth_key := config.get(CONF_AUTH_KEY): - key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] - auth_key_expr = cg.RawExpression( - f"std::array{{{', '.join(key_bytes)}}}" - ) - - patterns = [] - if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): - for p in custom_patterns: - name_expr = cg.RawExpression("std::nullopt") - if name_val := p.get(CONF_NAME): - name_expr = name_val - - if obis_vals := p.get(CONF_DEFAULT_OBIS): - obis_expr = cg.RawExpression( - f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" - ) - else: - obis_expr = cg.RawExpression("std::nullopt") - - patterns.append( - cg.ArrayInitializer( - p[CONF_PATTERN], - name_expr, - p[CONF_PRIORITY], - obis_expr, - ) - ) - - patterns_expr = ( - cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") - ) + for pattern in config.get(CONF_CUSTOM_PATTERNS, []) + ] var = cg.new_Pvariable( config[CONF_ID], config[CONF_RECEIVE_TIMEOUT], config[CONF_SKIP_CRC], - dec_key_expr, - auth_key_expr, - patterns_expr, + config.get(CONF_DECRYPTION_KEY, cg.nullptr), + config.get(CONF_AUTH_KEY, cg.nullptr), + cg.ArrayInitializer(*custom_patterns, multiline=True), ) - hub_id = config[CONF_ID].id - - for domain, define in ( - (SENSOR_DOMAIN, "DLMS_MAX_SENSORS"), - (TEXT_SENSOR_DOMAIN, "DLMS_MAX_TEXT_SENSORS"), - (BINARY_SENSOR_DOMAIN, "DLMS_MAX_BINARY_SENSORS"), - ): - count = sum( - 1 - for conf in CORE.config.get(domain, []) - if conf.get("platform") == DOMAIN and conf[CONF_DLMS_METER_ID].id == hub_id - ) - cg.add_define(define, count) - await cg.register_component(var, config) await uart.register_uart_device(var, config) if CORE.is_esp32: - esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + esp32.add_idf_component(name="esphome/dlms_parser", ref=DLMS_PARSER_VERSION) else: - cg.add_library("esphome/dlms_parser", "1.1.0") + cg.add_library("esphome/dlms_parser", DLMS_PARSER_VERSION) diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py index a15e58b9571..2a51ad09909 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -3,14 +3,20 @@ from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_binary_sensor, +) DEPENDENCIES = ["dlms_meter"] CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, } ) @@ -18,4 +24,4 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await binary_sensor.new_binary_sensor(config) - cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) + register_binary_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index c34f7126603..32d1817e0e7 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,6 +1,11 @@ +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + #include "dlms_meter.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include #include namespace esphome::dlms_meter { @@ -31,54 +36,40 @@ static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list a } } -DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, - std::optional> decryption_key, - std::optional> authentication_key, +DlmsMeterComponent::DlmsMeterComponent(const uint32_t receive_timeout_ms, const bool skip_crc_check, + const char *decryption_key, const char *authentication_key, std::vector custom_patterns) : receive_timeout_ms_(receive_timeout_ms), skip_crc_check_(skip_crc_check), custom_patterns_(std::move(custom_patterns)), - parser_(&decryptor_) { + parser_([this](const dlms_parser::AxdrCapture &capture) { this->on_data_(capture); }, &this->decryptor_) { dlms_parser::Logger::set_log_function(log_callback); - if (decryption_key.has_value()) { -#ifdef DLMS_METER_NO_CRYPTO - ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); -#else - auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); - if (opt_key) { - this->parser_.set_decryption_key(*opt_key); + if (decryption_key != nullptr) { + const auto key = dlms_parser::Aes128GcmDecryptionKey::from_hex(decryption_key); + if (key.has_value()) { + this->parser_.set_decryption_key(*key); } else { ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); } -#endif } - if (authentication_key.has_value()) { -#ifdef DLMS_METER_NO_CRYPTO - ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); -#else - auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); - if (opt_key) { - this->parser_.set_authentication_key(*opt_key); + if (authentication_key != nullptr) { + const auto key = dlms_parser::Aes128GcmAuthenticationKey::from_hex(authentication_key); + if (key.has_value()) { + this->parser_.set_authentication_key(*key); } else { ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); } -#endif } this->parser_.set_skip_crc_check(this->skip_crc_check_); this->parser_.load_default_patterns(); - for (const auto &pattern : this->custom_patterns_) { - if (pattern.default_obis.has_value() && pattern.name.has_value()) { - this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, - pattern.default_obis.value()); - } else if (pattern.name.has_value()) { - this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); - } else { - this->parser_.register_pattern(pattern.pattern.c_str()); - } + + for (const auto &custom_pattern : this->custom_patterns_) { + this->parser_.register_pattern(custom_pattern.name, custom_pattern.pattern, custom_pattern.priority, + custom_pattern.default_obis); } } @@ -89,36 +80,36 @@ void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, " Receive Timeout: %" PRIu32 " ms", this->receive_timeout_ms_); ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); - for (const auto &pattern : this->custom_patterns_) { - if (pattern.default_obis.has_value() && pattern.name.has_value()) { - const auto &obis = pattern.default_obis.value(); - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", - pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], - obis[3], obis[4], obis[5]); - } else if (pattern.name.has_value()) { - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), - pattern.name->c_str(), pattern.priority); - } else { - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str()); - } + std::array obis_buf; + + for (const auto &custom_pattern : this->custom_patterns_) { + const auto default_obis = custom_pattern.default_obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %.*s)", custom_pattern.pattern, + custom_pattern.name, custom_pattern.priority, static_cast(default_obis.size()), + default_obis.data()); } #ifdef USE_SENSOR - for (const auto &entry : this->sensors_) { - LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif + #ifdef USE_TEXT_SENSOR - for (const auto &entry : this->text_sensors_) { - LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif + #ifdef USE_BINARY_SENSOR - for (const auto &entry : this->binary_sensors_) { - LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif } @@ -166,71 +157,73 @@ void DlmsMeterComponent::read_rx_buffer_() { void DlmsMeterComponent::process_frame_() { ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { - this->on_data_(obis_code, float_val, str_val, is_numeric); - }; - - this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}); this->bytes_accumulated_ = 0; } -void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { +void DlmsMeterComponent::on_data_(const dlms_parser::AxdrCapture &capture) { int updated_count = 0; + if (capture.is_numeric()) { +#if defined(USE_SENSOR) || defined(USE_BINARY_SENSOR) + const float value = capture.value_as_float_with_scaler_applied(); +#endif #ifdef USE_SENSOR - if (is_numeric) { - for (auto &item : this->sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(float_val); + for (const auto &item : this->sensors_) { + if (item.obis == capture.obis) { + item.sensor->publish_state(value); updated_count++; } } - } #endif - -#ifdef USE_TEXT_SENSOR - if (!is_numeric && str_val != nullptr) { - for (auto &item : this->text_sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(str_val); - updated_count++; - } - } - } -#endif - #ifdef USE_BINARY_SENSOR - if (is_numeric) { - bool state = float_val != 0.0f; - for (auto &item : this->binary_sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(state); + for (const auto &item : this->binary_sensors_) { + if (item.obis == capture.obis) { + item.sensor->publish_state(value != 0.0f); updated_count++; } } - } #endif + } else { +#ifdef USE_TEXT_SENSOR + for (const auto &item : this->text_sensors_) { + if (item.obis == capture.obis) { + std::array value_buf; + const auto value = capture.value_as_string(value_buf); + item.sensor->publish_state(value.data(), value.size()); + updated_count++; + } + } +#endif + } if (updated_count == 0) { - ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + std::array obis_buf; + const auto obis = capture.obis.to_string(obis_buf); + ESP_LOGV(TAG, "Received OBIS %.*s, but no sensors are registered for it.", static_cast(obis.size()), + obis.data()); } } #ifdef USE_SENSOR -void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { - this->sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor) { + this->sensors_.push_back({obis, sensor}); } #endif + #ifdef USE_TEXT_SENSOR -void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { - this->text_sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis, sensor}); } #endif + #ifdef USE_BINARY_SENSOR -void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { - this->binary_sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis, sensor}); } #endif } // namespace esphome::dlms_meter + +#endif diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index fc4721843fd..2d7898f4d3e 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -1,9 +1,13 @@ #pragma once +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + +#include +#include + #include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/application.h" -#include "esphome/core/log.h" #include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" @@ -19,12 +23,6 @@ #include -#include -#include -#include -#include -#include - #if __has_include() #include #elif !defined(USE_ESP8266) && __has_include() @@ -35,9 +33,12 @@ #elif __has_include() #include #else -#define DLMS_METER_NO_CRYPTO +#error "The platform doesn't provide a compatible encryption library for dlms_parser" #endif +namespace esphome::dlms_meter { + +// Entity storage is sized by codegen; a platform with no entities emits no define #ifndef DLMS_MAX_SENSORS static constexpr uint8_t DLMS_MAX_SENSORS = 0; #endif @@ -48,82 +49,64 @@ static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; #endif -namespace esphome::dlms_meter { - -#ifdef DLMS_METER_NO_CRYPTO -// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) -class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { - public: - void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} - bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, - std::span aad, std::span tag) override { - return false; - } -}; -#endif - -#if __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; -#elif !defined(USE_ESP8266) && __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; -#elif __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; -#else -using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; -#endif - #ifdef USE_SENSOR struct SensorItem { - std::string obis_code; + dlms_parser::ObisId obis; sensor::Sensor *sensor; }; #endif #ifdef USE_TEXT_SENSOR struct TextSensorItem { - std::string obis_code; + dlms_parser::ObisId obis; text_sensor::TextSensor *sensor; }; #endif #ifdef USE_BINARY_SENSOR struct BinarySensorItem { - std::string obis_code; + dlms_parser::ObisId obis; binary_sensor::BinarySensor *sensor; }; #endif struct CustomPattern { - std::string pattern; - std::optional name; - int priority{0}; - std::optional> default_obis; + const char *pattern; + const char *name; + int priority; + dlms_parser::ObisId default_obis; }; +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#else +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#endif + class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: - DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, - std::optional> decryption_key, - std::optional> authentication_key, - std::vector custom_patterns); + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, const char *decryption_key, + const char *authentication_key, std::vector custom_patterns); void setup() override; void dump_config() override; void loop() override; #ifdef USE_SENSOR - void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); + void register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor); #endif #ifdef USE_TEXT_SENSOR - void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); + void register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor); #endif #ifdef USE_BINARY_SENSOR - void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); + void register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor); #endif protected: void read_rx_buffer_(); void flush_rx_buffer_(); void process_frame_(); - void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); + void on_data_(const dlms_parser::AxdrCapture &capture); std::array rx_buffer_; size_t bytes_accumulated_{0}; @@ -149,3 +132,5 @@ class DlmsMeterComponent final : public Component, public uart::UARTDevice { }; } // namespace esphome::dlms_meter + +#endif diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 0c895ccefb2..15b9deb012c 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -3,7 +3,13 @@ from esphome.components import sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_sensor, +) DEPENDENCIES = ["dlms_meter"] @@ -40,7 +46,7 @@ REMOVED_KEYS = { CONFIG_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, **{ cv.Optional(key): cv.invalid( f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " @@ -56,4 +62,4 @@ CONFIG_SCHEMA = sensor.sensor_schema().extend( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await sensor.new_sensor(config) - cg.add(hub.register_sensor(config[CONF_OBIS_CODE], var)) + register_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 10872d1a50a..4e2e48487a8 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -3,7 +3,13 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_text_sensor, +) DEPENDENCIES = ["dlms_meter"] @@ -18,7 +24,7 @@ REMOVED_KEYS = { CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, **{ cv.Optional(key): cv.invalid( f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " @@ -34,4 +40,4 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await text_sensor.new_text_sensor(config) - cg.add(hub.register_text_sensor(config[CONF_OBIS_CODE], var)) + register_text_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3d9f1431f67..8ac76cd41fa 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.3" esphome/dlms_parser: - version: 1.1.0 + version: 2.2.0 esphome/esp-audio-libs: version: 4.0.1 esphome/esp-micro-speech-features: diff --git a/platformio.ini b/platformio.ini index 56a12d8b31c..7534b748d1b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -106,7 +106,7 @@ build_unflags = ; esp32 envs (which would double-include them). [common:idf-component-libs] lib_deps = - esphome/dlms_parser@1.1.0 ; dlms_meter + esphome/dlms_parser@2.2.0 ; dlms_meter bblanchon/ArduinoJson@7.4.3 ; json lvgl/lvgl@9.5.0 ; lvgl From a613073ee3d0ac82d73fe1e09eb0dbfa65281be2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:06:10 -1000 Subject: [PATCH 160/435] Bump bundled esphome-device-builder to 1.23.0 (#20446) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0282011f504..9edbd6e4f9b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.22.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 RUN \ platformio settings set enable_telemetry No \ From a4a09043ec1c1b5dd1f35c785125d751d56f2b43 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:32:24 +0200 Subject: [PATCH 161/435] [deep_sleep][gpio][core][power_supply] Prepare for gpio hold (2/3) (#18966) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/const/__init__.py | 1 + .../deep_sleep/deep_sleep_esp32.cpp | 17 ++++++++----- esphome/components/esp32/gpio.h | 8 ++++++ esphome/components/gpio/output/__init__.py | 25 ++++++++++++++++++- .../gpio/output/gpio_binary_output.h | 7 ++++-- .../components/gpio/switch/gpio_switch.cpp | 5 ++-- .../components/power_supply/power_supply.cpp | 14 ++++++++++- .../components/power_supply/power_supply.h | 1 + esphome/core/defines.h | 1 + esphome/core/gpio.h | 9 +++++++ 10 files changed, 76 insertions(+), 12 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6eeab52e297..b54d797f096 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -27,6 +27,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HOLD_STATE = "hold_state" CONF_HOST = "host" CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index eef392ad5cf..b71a4fa1383 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -111,7 +111,17 @@ void DeepSleepComponent::deep_sleep_() { if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); - // Single pin wakeup (ext0) - ESP32, S2, S3 only +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP +#ifdef USE_GPIO_HOLD + // Variants without per-pin deep sleep hold need the global enable + gpio_deep_sleep_hold_en(); +#else + if (this->wakeup_pin_ != nullptr) + gpio_deep_sleep_hold_en(); +#endif +#endif + + // Single pin wakeup (ext0) - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) @@ -124,11 +134,6 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); -#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP - // Some ESP32 variants support holding a single GPIO during deep sleep without this function - // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep - gpio_deep_sleep_hold_en(); -#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index aeff5af51c6..9c661b4d9ea 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -31,6 +31,14 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin { bool is_inverted() const override { return this->pin_flags_.inverted; } gpio_num_t get_pin_num() const { return static_cast(this->pin_); } gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } +#ifdef USE_GPIO_HOLD + bool is_held() const override { + if (!this->get_hold()) + return false; + esp_reset_reason_t reason = esp_reset_reason(); + return reason == ESP_RST_DEEPSLEEP || reason == ESP_RST_SW; + } +#endif protected: void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index ab242c643fc..645dd61f802 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -1,8 +1,10 @@ from esphome import pins import esphome.codegen as cg from esphome.components import output +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_PIN +from esphome.const import CONF_ID, CONF_PIN, CONF_POWER_SUPPLY +import esphome.final_validate as fv from esphome.types import ConfigType from .. import gpio_ns @@ -17,6 +19,27 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) +def _require_hold(ps_config: ConfigType) -> ConfigType: + if not ps_config[CONF_PIN].get(CONF_HOLD_STATE): + raise cv.Invalid( + f"The power supply pin must also set {CONF_HOLD_STATE} " + f"when an output it powers sets it", + [CONF_PIN], + ) + return ps_config + + +def _final_validate(config: ConfigType) -> None: + if not config[CONF_PIN].get(CONF_HOLD_STATE): + return + if (ps_id := config.get(CONF_POWER_SUPPLY)) is None: + return + fv.id_declaration_match_schema(_require_hold)(ps_id) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 496afd131b2..06e36474820 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -11,9 +11,12 @@ class GPIOBinaryOutput final : public output::BinaryOutput, public Component { void set_pin(GPIOPin *pin) { pin_ = pin; } void setup() override { - this->turn_off(); + const bool held = this->pin_->is_held(); + if (!held) + this->turn_off(); this->pin_->setup(); - this->turn_off(); + if (!held) + this->turn_off(); } void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 4daea8d877b..450a97eb190 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -12,8 +12,9 @@ float GPIOSwitch::get_setup_priority() const { return setup_priority::HARDWARE; void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - // write state before setup - this->control(initial_state); + // write state before setup unless the pin kept it across the reset + if (!this->pin_->is_held()) + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs this->control(initial_state); diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 3abffc88e15..8fa1b92c259 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -9,7 +9,12 @@ static constexpr uint32_t POWER_OFF_TIMEOUT_ID = 0; void PowerSupply::setup() { this->pin_->setup(); - this->pin_->digital_write(false); + if (this->pin_->is_held()) { + // Rail stayed on across the reset; drop it unless something asks for it + this->schedule_off_if_idle_(); + } else { + this->pin_->digital_write(false); + } if (this->enable_on_boot_) this->request_high_power(); } @@ -47,6 +52,10 @@ void PowerSupply::unrequest_high_power() { return; } this->active_requests_--; + this->schedule_off_if_idle_(); +} + +void PowerSupply::schedule_off_if_idle_() { if (this->active_requests_ == 0) { this->set_timeout(POWER_OFF_TIMEOUT_ID, this->keep_on_time_, [this]() { ESP_LOGV(TAG, "Disabling"); @@ -54,7 +63,10 @@ void PowerSupply::unrequest_high_power() { }); } } + void PowerSupply::on_powerdown() { + if (this->pin_->get_hold()) + return; this->active_requests_ = 0; this->pin_->digital_write(false); } diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index eaf77af32e9..dc6b368ea8d 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -39,6 +39,7 @@ class PowerSupply final : public Component { uint32_t keep_on_time_; int16_t active_requests_{0}; // use signed integer to make catching negative requests easier. bool enable_on_boot_{false}; + void schedule_off_if_idle_(); }; class PowerSupplyRequester { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 12f61467d65..d4932ae7495 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -474,6 +474,7 @@ #define USE_USB_UART_CP210X #define USE_USB_UART_FT23XX #define USE_USB_UART_PL2303 +#define USE_GPIO_HOLD // USB identity on serial proxy ports needs the usb_host stack #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index 43db3b7c0c5..7daf1917a6d 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -2,6 +2,7 @@ #include #include +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -27,6 +28,7 @@ enum Flags : uint8_t { FLAG_OPEN_DRAIN = 0x04, FLAG_PULLUP = 0x08, FLAG_PULLDOWN = 0x10, + FLAG_HOLD = 0x20, }; class FlagsHelper { @@ -79,6 +81,13 @@ class GPIOPin { virtual size_t dump_summary(char *buffer, size_t len) const; virtual bool is_internal() { return false; } +#ifdef USE_GPIO_HOLD + bool get_hold() const { return this->get_flags() & gpio::FLAG_HOLD; } + virtual bool is_held() const { return false; } +#else + bool get_hold() const { return false; } + bool is_held() const { return false; } +#endif }; /// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) From e5f5ca07c4b661c054e9f14195396fa5eb722505 Mon Sep 17 00:00:00 2001 From: Ali Alaei <49282631+aalaei@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:50:50 +0200 Subject: [PATCH 162/435] [zephyr_mcumgr] Enable MCUMGR_GRP_OS for UART transport, not just BLE (#18319) Co-authored-by: tomaszduda23 --- esphome/components/zephyr_mcumgr/ota/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index d4f84e30f24..ed7069a5403 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -147,14 +147,15 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_MGMT_NOTIFICATION_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_STATUS_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK", True) + # OS management group (reset, MCUMGR_PARAMETERS query, etc.) isn't transport-specific -- + # every mcumgr transport needs it, not just BLE. + zephyr_add_prj_conf("MCUMGR_GRP_OS", True) + zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) transport = config[CONF_TRANSPORT] if transport[CONF_BLE]: zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT", True) zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT_REASSEMBLY", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) - zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: From 4e4056d79095d7b5eb0dd84e68ea30cd37255fae Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:11:48 +0200 Subject: [PATCH 163/435] [uart] Share uart_tcp's write pacing and uart_id matcher (#20464) --- .../cdc_acm_uart/bridge/__init__.py | 15 +---- esphome/components/uart/__init__.py | 19 ++++++ esphome/components/uart/uart_component.cpp | 19 ++++++ esphome/components/uart/uart_component.h | 4 ++ esphome/components/uart_tcp/__init__.py | 14 +---- esphome/components/uart_tcp/uart_tcp.cpp | 14 +---- .../components/uart/uart_paced_write_room.cpp | 62 +++++++++++++++++++ tests/unit_tests/components/test_uart.py | 24 +++++++ 8 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 tests/components/uart/uart_paced_write_room.cpp create mode 100644 tests/unit_tests/components/test_uart.py diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py index cee048df5da..133ea92a483 100644 --- a/esphome/components/cdc_acm_uart/bridge/__init__.py +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -36,19 +36,6 @@ CONFIG_SCHEMA = cv.All( ) -def _subtree_references_uart(node: object, uart_id: str) -> bool: - """Return True if any dict in the subtree has a uart_id entry naming this bus.""" - if isinstance(node, dict): - return any( - (key == CONF_UART_ID and str(value) == uart_id) - or _subtree_references_uart(value, uart_id) - for key, value in node.items() - ) - if isinstance(node, list): - return any(_subtree_references_uart(item, uart_id) for item in node) - return False - - def _reject_debug(uart_conf: ConfigType) -> ConfigType: # The worker tasks use the IDF driver directly, so the uart debugger never sees # bridge traffic and its dummy_receiver would drain RX bytes on the main loop. @@ -86,7 +73,7 @@ def _final_validate(config: ConfigType) -> ConfigType: for domain, domain_conf in full_config.items(): if domain == BRIDGE_DOMAIN: continue - if _subtree_references_uart(domain_conf, owned_id): + if uart.subtree_references_uart(domain_conf, owned_id): raise cv.Invalid( f"The {label} '{owned_id}' is also used by '{domain}'; a bridge " f"requires exclusive use of its {label}.", diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 25fbfb75994..2250b9d5088 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -533,6 +533,25 @@ def final_validate_device_schema( ) +def subtree_references_uart( + node: object, uart_id: str, conf_key: str = CONF_UART_ID +) -> bool: + """Return True if any dict in the subtree has a conf_key entry naming this bus. + + For the final validation of a component that needs a UART for itself. Bare + `id:` references (a uart.write action) and lambdas are not found. + """ + if isinstance(node, dict): + return any( + (key == conf_key and str(value) == uart_id) + or subtree_references_uart(value, uart_id, conf_key) + for key, value in node.items() + ) + if isinstance(node, list): + return any(subtree_references_uart(item, uart_id, conf_key) for item in node) + return False + + async def register_uart_device(var, config): """Register a UART device, setting up all the internal values. diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 65161746351..82bc5927024 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -1,9 +1,15 @@ #include "uart_component.h" +#include "esphome/core/application.h" + +#include namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); +// Keeps the pacing product in 32 bits up to about 10 Mbaud. +static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; + bool UARTComponent::check_read_timeout_(size_t len) { if (this->available() >= len) return true; @@ -19,6 +25,19 @@ bool UARTComponent::check_read_timeout_(size_t len) { return true; } +size_t UARTComponent::paced_write_room(uint32_t last_write_ms) { + size_t room = this->available_for_write(); + if (room != SIZE_MAX) { + return room; + } + // A pass woken early writes little. + uint32_t span = + std::min({App.get_loop_component_start_time() - last_write_ms, App.get_loop_interval(), MAX_PACE_SPAN_MS}); + // 10 bits per byte on the line. + uint32_t paced = this->baud_rate_ / 10 * span / 1000; + return std::max(1, paced); +} + void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { uint8_t bytelength = this->data_bits_ + this->stop_bits_ + 1; if (this->parity_ != UARTParityOptions::UART_CONFIG_PARITY_NONE) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 8f143b79777..2543cb54768 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -86,6 +86,10 @@ class UARTComponent { // Platforms that cannot tell return SIZE_MAX: write_array() takes everything and may block. virtual size_t available_for_write() { return SIZE_MAX; } + // Bytes a write can take now. Where available_for_write() cannot tell (SIZE_MAX), paces to the line time since + // last_write_ms, a loop start time: at most one loop interval and 4 s, at least one byte. + size_t paced_write_room(uint32_t last_write_ms); + // Pure virtual method to block until all bytes have been written to the UART bus. // @return UARTFlushResult indicating whether the flush was confirmed, timed out, failed, or assumed successful. virtual UARTFlushResult flush() = 0; diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 601fdeb327e..9d62d48d7c2 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -71,18 +71,6 @@ CONFIG_SCHEMA = cv.All( ) -def _subtree_references_uart(node: object, uart_id: str) -> bool: - if isinstance(node, dict): - return any( - (key == CONF_UART_ID and str(value) == uart_id) - or _subtree_references_uart(value, uart_id) - for key, value in node.items() - ) - if isinstance(node, list): - return any(_subtree_references_uart(item, uart_id) for item in node) - return False - - def _reject_dummy_receiver(uart_conf: ConfigType) -> ConfigType: debug = uart_conf.get(CONF_DEBUG) if isinstance(debug, dict) and debug.get(CONF_DUMMY_RECEIVER): @@ -111,7 +99,7 @@ def _final_validate(config: ConfigType) -> ConfigType: # Bare `id:` references (a uart.write action) and lambdas are not caught. if not CORE.testing_mode: for domain, domain_conf in full_config.items(): - if domain != DOMAIN and _subtree_references_uart(domain_conf, uart_id): + if domain != DOMAIN and uart.subtree_references_uart(domain_conf, uart_id): raise cv.Invalid( f"The UART '{uart_id}' is also used by '{domain}'. " "uart_tcp requires exclusive use of that UART.", diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index bc75eb04c77..d634ac579f2 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -11,9 +11,6 @@ namespace esphome::uart_tcp { ESPHOME_LOG_TAG(TAG, "uart_tcp"); -// Keeps the pacing product in 32 bits up to about 10 Mbaud. -static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; - void UartTcp::setup() { this->link_.begin(TAG); #ifdef USE_SOCKET_TCP_LISTENER @@ -75,16 +72,7 @@ void UartTcp::sync_link_() { void UartTcp::read_socket_() { // A hardware write blocks until the driver takes every byte. Leave what does // not fit in the socket, so TCP flow control throttles the peer. - size_t room = this->parent_->available_for_write(); - if (room == SIZE_MAX) { - // Capacity unknown on this platform; pace to the UART time since the last write, - // at most one loop interval and 4 s, so a pass woken early by the socket writes little. - uint32_t span = std::min( - {App.get_loop_component_start_time() - this->last_write_ms_, App.get_loop_interval(), MAX_PACE_SPAN_MS}); - // 10 bits per byte on the line. - uint32_t paced = this->parent_->get_baud_rate() / 10 * span / 1000; - room = std::max(1, paced); - } + size_t room = this->parent_->paced_write_room(this->last_write_ms_); if (room == 0) { this->rx_pending_ = true; return; diff --git a/tests/components/uart/uart_paced_write_room.cpp b/tests/components/uart/uart_paced_write_room.cpp new file mode 100644 index 00000000000..ed64695dd03 --- /dev/null +++ b/tests/components/uart/uart_paced_write_room.cpp @@ -0,0 +1,62 @@ +#include + +#include "common.h" +#include "esphome/core/application.h" + +namespace esphome::uart::testing { + +class RoomUART : public MockUARTComponent { + public: + size_t available_for_write() override { return this->room; } + size_t room{SIZE_MAX}; +}; + +class PacedWriteRoom : public ::testing::Test { + protected: + void SetUp() override { + App.set_loop_interval(16); + this->uart_.set_baud_rate(9600); + } + void TearDown() override { + App.set_loop_interval(16); + this->at_(0); + } + // Publishes now_ms as the loop start time, as Application::loop() does. + void at_(uint32_t now_ms) { LoopBlockingGuard dispatch{nullptr, nullptr, now_ms}; } + + RoomUART uart_; +}; + +TEST_F(PacedWriteRoom, KnownRoomIsReturned) { + this->uart_.room = 7; + this->at_(1000); + EXPECT_EQ(this->uart_.paced_write_room(0), 7u); + this->uart_.room = 0; + EXPECT_EQ(this->uart_.paced_write_room(0), 0u); +} + +TEST_F(PacedWriteRoom, UnknownRoomTakesOneLoopInterval) { + this->at_(1000); + // 9600 baud at 10 bits per byte for 16 ms. + EXPECT_EQ(this->uart_.paced_write_room(0), 15u); +} + +TEST_F(PacedWriteRoom, EarlyPassTakesTheTimeSinceTheLastWrite) { + this->at_(1005); + EXPECT_EQ(this->uart_.paced_write_room(1000), 4u); +} + +TEST_F(PacedWriteRoom, AtLeastOneByte) { + this->at_(1000); + EXPECT_EQ(this->uart_.paced_write_room(1000), 1u); +} + +TEST_F(PacedWriteRoom, SpanCapsAtFourSeconds) { + App.set_loop_interval(10000); + this->uart_.set_baud_rate(300); + this->at_(6000); + // 30 bytes/s for 4 s. + EXPECT_EQ(this->uart_.paced_write_room(0), 120u); +} + +} // namespace esphome::uart::testing diff --git a/tests/unit_tests/components/test_uart.py b/tests/unit_tests/components/test_uart.py new file mode 100644 index 00000000000..4b062b4729e --- /dev/null +++ b/tests/unit_tests/components/test_uart.py @@ -0,0 +1,24 @@ +"""Tests for the uart helpers shared by components that own a UART.""" + +from esphome.components.uart import subtree_references_uart +from esphome.core import ID + + +def test_finds_a_nested_uart_id() -> None: + config = {"modbus": [{"id": ID("hub"), "uart_id": ID("bus")}]} + assert subtree_references_uart(config, "bus") + + +def test_ignores_other_uarts_and_other_keys() -> None: + config = {"modbus": [{"uart_id": ID("other")}], "sensor": [{"id": ID("bus")}]} + assert not subtree_references_uart(config, "bus") + + +def test_finds_another_key() -> None: + config = {"modbus_tcp_uart": [{"tcp_uart_id": ID("bus")}]} + assert subtree_references_uart(config, "bus", "tcp_uart_id") + assert not subtree_references_uart(config, "bus") + + +def test_handles_scalars() -> None: + assert not subtree_references_uart("bus", "bus") From 1b49e79bf8da3f10dc19c31b33f3ff268f7ee763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:35:38 -1000 Subject: [PATCH 164/435] [microphone] Build on_data with build_callback_automation (#20451) --- esphome/components/microphone/__init__.py | 18 ++++-------------- esphome/components/microphone/automation.h | 17 ----------------- 2 files changed, 4 insertions(+), 31 deletions(-) delete mode 100644 esphome/components/microphone/automation.h diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 6fa14cdd8b5..a83827ef5d7 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -12,7 +12,6 @@ from esphome.const import ( CONF_ID, CONF_MICROPHONE, CONF_ON_DATA, - CONF_TRIGGER_ID, ) from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -30,17 +29,12 @@ microphone_ns = cg.esphome_ns.namespace("microphone") Microphone = microphone_ns.class_("Microphone") MicrophoneSource = microphone_ns.class_("MicrophoneSource") -DataTrigger = microphone_ns.class_( - "DataTrigger", - automation.Trigger.template(cg.std_vector.template(cg.uint8).operator("ref")), -) - async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_data_callback", [(cg.std_vector.template(cg.uint8).operator("ref").operator("const"), "x")], conf, ) @@ -54,11 +48,7 @@ async def register_microphone(var: MockObj, config: ConfigType) -> None: MICROPHONE_SCHEMA = cv.Schema.extend(audio.AUDIO_COMPONENT_SCHEMA).extend( { - cv.Optional(CONF_ON_DATA): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DataTrigger), - } - ), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), } ) diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h deleted file mode 100644 index 0fac18d8283..00000000000 --- a/esphome/components/microphone/automation.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "microphone.h" - -#include - -namespace esphome::microphone { - -class DataTrigger final : public Trigger &> { - public: - explicit DataTrigger(Microphone *mic) { - mic->add_data_callback([this](const std::vector &data) { this->trigger(data); }); - } -}; - -} // namespace esphome::microphone From 5d2433f69ad99b3b95d1457bb4cb23ede78409fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:36:57 -1000 Subject: [PATCH 165/435] [graphical_display_menu] Build on_redraw with build_callback_automation (#20452) --- .../graphical_display_menu/__init__.py | 17 +++---------- .../graphical_display_menu.cpp | 2 +- .../graphical_display_menu.h | 12 +--------- .../common-triggers.yaml | 24 +++++++++++++++++++ .../test-triggers.esp32-idf.yaml | 6 +++++ 5 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 tests/components/graphical_display_menu/common-triggers.yaml create mode 100644 tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml diff --git a/esphome/components/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index 59e8f66c501..a398aea6192 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FONT, CONF_FOREGROUND_COLOR, CONF_ID, - CONF_TRIGGER_ID, ) from esphome.types import ConfigType @@ -29,9 +28,6 @@ MenuItemValueArguments = graphical_display_menu_ns.struct("MenuItemValueArgument MenuItemValueArgumentsConstPtr = MenuItemValueArguments.operator("ptr").operator( "const" ) -GraphicalDisplayMenuOnRedrawTrigger = graphical_display_menu_ns.class_( - "GraphicalDisplayMenuOnRedrawTrigger", automation.Trigger -) CODEOWNERS = ["@MrMDavidson"] DOMAIN = "graphical_display_menu" @@ -49,13 +45,7 @@ CONFIG_SCHEMA = DISPLAY_MENU_BASE_SCHEMA.extend( cv.Optional(CONF_MENU_ITEM_VALUE): cv.templatable(cv.string), cv.Optional(CONF_FOREGROUND_COLOR): cv.use_id(color.ColorStruct), cv.Optional(CONF_BACKGROUND_COLOR): cv.use_id(color.ColorStruct), - cv.Optional(CONF_ON_REDRAW): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - GraphicalDisplayMenuOnRedrawTrigger - ) - } - ), + cv.Optional(CONF_ON_REDRAW): automation.validate_automation({}), } ) ) @@ -92,9 +82,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_background_color(background_color)) for conf in config.get(CONF_ON_REDRAW, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(GraphicalDisplayMenuConstPtr, "it")], conf + await automation.build_callback_automation( + var, "add_on_redraw_callback", [(GraphicalDisplayMenuConstPtr, "it")], conf ) await display_menu_to_code(var, config) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index 8761e5fb4cc..5f5b3e8d425 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -241,6 +241,6 @@ void GraphicalDisplayMenu::draw_item(const display_menu_base::MenuItem *item, co "draw_item should be called."); } -void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(); } +void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(this); } } // namespace esphome::graphical_display_menu diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index 13c0f9d73f8..1f1d8ff3fc5 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -70,17 +70,7 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen Color foreground_color_{COLOR_ON}; Color background_color_{COLOR_OFF}; - CallbackManager on_redraw_callbacks_{}; -}; - -class GraphicalDisplayMenuOnRedrawTrigger final : public Trigger { - public: - explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) : parent_(parent) { - parent->add_on_redraw_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - GraphicalDisplayMenu *parent_; + LazyCallbackManager on_redraw_callbacks_{}; }; } // namespace graphical_display_menu diff --git a/tests/components/graphical_display_menu/common-triggers.yaml b/tests/components/graphical_display_menu/common-triggers.yaml new file mode 100644 index 00000000000..ea4daeb1629 --- /dev/null +++ b/tests/components/graphical_display_menu/common-triggers.yaml @@ -0,0 +1,24 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: graphical_display_menu_triggers_display + model: SSD1306_128X64 + reset_pin: ${reset_pin} + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +font: + - file: "gfonts://Roboto" + id: graphical_display_menu_triggers_font + size: 20 + +graphical_display_menu: + id: graphical_display_menu_triggers_menu + display: graphical_display_menu_triggers_display + font: graphical_display_menu_triggers_font + on_redraw: + then: + lambda: 'ESP_LOGI("graphical_display_menu", "redraw, active: %d", it->is_active());' + items: + - type: label + text: "Label" diff --git a/tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml b/tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..2adb772294c --- /dev/null +++ b/tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + reset_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + graphical_display_menu: !include common-triggers.yaml From 79fd5edc7f44cc9a93b7a8be3dcea96c54b14f15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:37:10 -1000 Subject: [PATCH 166/435] [core] Bind InternalGPIOPin to the platform pin class instead of a virtual base (#20455) --- esphome/components/esp32/__init__.py | 8 - esphome/components/esp32/gpio.cpp | 11 +- esphome/components/esp32/gpio.h | 19 +- esphome/components/esp32/gpio.py | 1 - esphome/components/esp8266/gpio.cpp | 3 +- esphome/components/esp8266/gpio.h | 18 +- esphome/components/host/gpio.cpp | 3 +- esphome/components/host/gpio.h | 18 +- esphome/components/libretiny/gpio_arduino.cpp | 3 +- esphome/components/libretiny/gpio_arduino.h | 18 +- esphome/components/rp2/gpio.cpp | 3 +- esphome/components/rp2/gpio.h | 18 +- esphome/components/zephyr/gpio.cpp | 3 +- esphome/components/zephyr/gpio.h | 21 +- esphome/core/defines.h | 1 - esphome/core/gpio.h | 184 ++++-------------- esphome/core/gpio_pin.h | 147 ++++++++++++++ .../deep_sleep/test-timer-only.esp32-idf.yaml | 4 + .../power_supply/test_setup_priority.cpp | 25 +-- 19 files changed, 278 insertions(+), 230 deletions(-) create mode 100644 esphome/core/gpio_pin.h create mode 100644 tests/components/deep_sleep/test-timer-only.esp32-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index cfa79e3cf1f..aa4f78de69d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -15,7 +15,6 @@ from esphome.components.const import ( CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, CONF_IGNORE_NOT_FOUND, ) -from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -3967,10 +3966,3 @@ def process_stacktrace(config, line, backtrace_state): _decode_pc(config, addr.group()) return backtrace_state - - -# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which -# are instantiated solely by the pin schema codegen (esp32_pin_to_code) -FILTER_SOURCE_FILES = filter_source_files_from_defines( - {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} -) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 6e4de6aa515..d9c9b861858 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -1,9 +1,10 @@ #include "esphome/core/defines.h" -// Also defines the core ISRInternalGPIOPin methods; those are only reachable -// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely. -#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO) +// Always built: InternalGPIOPin is this class, so components that merely hold a +// pointer to it reference these methods even in configs without a pin. +#ifdef USE_ESP32 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "driver/gpio.h" #include "driver/rtc_io.h" @@ -68,7 +69,7 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ESP32InternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { gpio_int_type_t idf_type = GPIO_INTR_ANYEDGE; switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -207,4 +208,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO +#endif // USE_ESP32 diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 9c661b4d9ea..954b213d7f8 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -1,7 +1,8 @@ #pragma once #ifdef USE_ESP32 -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" +#include #include namespace esphome::esp32 { @@ -10,7 +11,7 @@ namespace esphome::esp32 { static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); -class ESP32InternalGPIOPin final : public InternalGPIOPin { +class ESP32InternalGPIOPin final : public GPIOPin { public: void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } @@ -24,11 +25,15 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return this->pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return this->pin_; } gpio::Flags get_flags() const override { return this->flags_; } - bool is_inverted() const override { return this->pin_flags_.inverted; } + bool is_inverted() const { return this->pin_flags_.inverted; } + bool is_internal() override { return true; } gpio_num_t get_pin_num() const { return static_cast(this->pin_); } gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } #ifdef USE_GPIO_HOLD @@ -41,7 +46,7 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin { #endif protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; // Memory layout: 8 bytes total on 32-bit systems // - 3 bytes for members below diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 98aac209ec4..321dd3d498f 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -257,7 +257,6 @@ ESP32_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA) async def esp32_pin_to_code(config): - cg.add_define("USE_ESP32_INTERNAL_GPIO") var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index ae09bdb91c3..1d97b2c1e56 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP8266 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::esp8266 { @@ -74,7 +75,7 @@ ISRInternalGPIOPin ESP8266GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ESP8266GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ESP8266GPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { uint8_t arduino_mode = 0; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index 57ef06106a2..84968d97527 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -2,12 +2,12 @@ #ifdef USE_ESP8266 -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" #include namespace esphome::esp8266 { -class ESP8266GPIOPin final : public InternalGPIOPin { +class ESP8266GPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -18,14 +18,18 @@ class ESP8266GPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/host/gpio.cpp b/esphome/components/host/gpio.cpp index c1fd56e945a..b57bd411883 100644 --- a/esphome/components/host/gpio.cpp +++ b/esphome/components/host/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_HOST #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome { @@ -20,7 +21,7 @@ ISRInternalGPIOPin HostGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void HostGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void HostGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { ESP_LOGD(TAG, "Attaching interrupt %p to pin %d and mode %d", func, pin_, (uint32_t) type); } void HostGPIOPin::pin_mode(gpio::Flags flags) { ESP_LOGD(TAG, "Setting pin %d mode to %02X", pin_, (uint32_t) flags); } diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index bd2d09257bb..fd3ebefe1f6 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -2,11 +2,11 @@ #ifdef USE_HOST -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::host { -class HostGPIOPin final : public InternalGPIOPin { +class HostGPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -17,14 +17,18 @@ class HostGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index ff333aacef8..9884c37b85c 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -1,6 +1,7 @@ #ifdef USE_LIBRETINY #include "gpio_arduino.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::libretiny { @@ -37,7 +38,7 @@ ISRInternalGPIOPin ArduinoInternalGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ArduinoInternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ArduinoInternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = (PinStatus) 255; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index da477fde363..b5ccf544ba3 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -1,11 +1,11 @@ #pragma once #ifdef USE_LIBRETINY -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::libretiny { -class ArduinoInternalGPIOPin final : public InternalGPIOPin { +class ArduinoInternalGPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -16,14 +16,18 @@ class ArduinoInternalGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/rp2/gpio.cpp b/esphome/components/rp2/gpio.cpp index 93bdba1fca6..2eeb460e954 100644 --- a/esphome/components/rp2/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_RP2 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome { @@ -40,7 +41,7 @@ ISRInternalGPIOPin RP2GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void RP2GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void RP2GPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = LOW; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/rp2/gpio.h b/esphome/components/rp2/gpio.h index 538fef619a2..b1d4c9a8f61 100644 --- a/esphome/components/rp2/gpio.h +++ b/esphome/components/rp2/gpio.h @@ -3,11 +3,11 @@ #ifdef USE_RP2 #include -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::rp2 { -class RP2GPIOPin final : public InternalGPIOPin { +class RP2GPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -18,14 +18,18 @@ class RP2GPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 4400f0a0c5c..57a31e302e5 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -1,5 +1,6 @@ #ifdef USE_ZEPHYR #include "gpio.h" +#include "esphome/core/hal.h" #include #include #include "esphome/core/log.h" @@ -79,7 +80,7 @@ ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ZephyrGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { if (!device_is_ready(this->gpio_)) { ESP_LOGE(TAG, "Cannot attach interrupt: GPIO device not ready"); return; diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 71d1620a677..dd26e34eb8b 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -1,7 +1,7 @@ #pragma once #ifdef USE_ZEPHYR -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" #include #include namespace esphome::zephyr { @@ -16,7 +16,7 @@ struct ZephyrGPIOInterrupt { void *arg{nullptr}; }; -class ZephyrGPIOPin final : public InternalGPIOPin { +class ZephyrGPIOPin final : public GPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { this->gpio_ = gpio; @@ -32,14 +32,18 @@ class ZephyrGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return this->pin_; } - bool is_inverted() const override { return this->inverted_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return this->pin_; } + bool is_inverted() const { return this->inverted_; } + bool is_internal() override { return true; } gpio::Flags get_flags() const override { return flags_; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; const device *gpio_{nullptr}; const char *pin_name_prefix_{nullptr}; gpio::Flags flags_{}; @@ -48,8 +52,7 @@ class ZephyrGPIOPin final : public InternalGPIOPin { bool inverted_{}; bool value_{false}; - // attach_interrupt()/detach_interrupt() are const (matching the base class), so - // the interrupt state they manage has to be mutable. + // attach_interrupt_()/detach_interrupt() are const, so their interrupt state is mutable. mutable ZephyrGPIOInterrupt interrupt_{}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d4932ae7495..731c0d20171 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -364,7 +364,6 @@ #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER #define USE_ESP32_VASPRINTF_STUB -#define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index 7daf1917a6d..8ed0d70f6be 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,150 +1,44 @@ #pragma once -#include -#include +#include "esphome/core/gpio_pin.h" -#include "esphome/core/defines.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" +// Each platform has exactly one internal pin class, so InternalGPIOPin is an alias for +// it instead of an abstract base class: calls through the alias are direct and can be inlined. +#if defined(USE_ESP32) +#include "esphome/components/esp32/gpio.h" +namespace esphome { +using InternalGPIOPin = esp32::ESP32InternalGPIOPin; +} +#elif defined(USE_ESP8266) +#include "esphome/components/esp8266/gpio.h" +namespace esphome { +using InternalGPIOPin = esp8266::ESP8266GPIOPin; +} +#elif defined(USE_LIBRETINY) +#include "esphome/components/libretiny/gpio_arduino.h" +namespace esphome { +using InternalGPIOPin = libretiny::ArduinoInternalGPIOPin; +} +#elif defined(USE_RP2) +#include "esphome/components/rp2/gpio.h" +namespace esphome { +using InternalGPIOPin = rp2::RP2GPIOPin; +} +#elif defined(USE_HOST) +#include "esphome/components/host/gpio.h" +namespace esphome { +using InternalGPIOPin = host::HostGPIOPin; +} +#elif defined(USE_ZEPHYR) +#include "esphome/components/zephyr/gpio.h" +namespace esphome { +using InternalGPIOPin = zephyr::ZephyrGPIOPin; +} +#else +#error "gpio.h: no internal pin class for this platform" +#endif namespace esphome { - -/// Maximum buffer size for dump_summary output -inline constexpr size_t GPIO_SUMMARY_MAX_LEN = 48; - -#ifdef USE_ESP8266 -#define LOG_PIN(prefix, pin) log_pin(TAG, F(prefix), pin) -#else -#define LOG_PIN(prefix, pin) log_pin(TAG, prefix, pin) -#endif - -// put GPIO flags in a namespace to not pollute esphome namespace -namespace gpio { - -enum Flags : uint8_t { - // Can't name these just INPUT because of Arduino defines :( - FLAG_NONE = 0x00, - FLAG_INPUT = 0x01, - FLAG_OUTPUT = 0x02, - FLAG_OPEN_DRAIN = 0x04, - FLAG_PULLUP = 0x08, - FLAG_PULLDOWN = 0x10, - FLAG_HOLD = 0x20, -}; - -class FlagsHelper { - public: - constexpr FlagsHelper(Flags val) : val_(val) {} - constexpr operator Flags() const { return val_; } - - protected: - Flags val_; -}; -constexpr FlagsHelper operator&(Flags lhs, Flags rhs) { - return static_cast(static_cast(lhs) & static_cast(rhs)); -} -constexpr FlagsHelper operator|(Flags lhs, Flags rhs) { - return static_cast(static_cast(lhs) | static_cast(rhs)); -} - -enum InterruptType : uint8_t { - INTERRUPT_RISING_EDGE = 1, - INTERRUPT_FALLING_EDGE = 2, - INTERRUPT_ANY_EDGE = 3, - INTERRUPT_LOW_LEVEL = 4, - INTERRUPT_HIGH_LEVEL = 5, -}; - -} // namespace gpio - -class GPIOPin { - public: - virtual void setup() = 0; - - virtual void pin_mode(gpio::Flags flags) = 0; - - /** - * @brief Retrieve GPIO pin flags. - * - * @return The GPIO flags describing the pin mode and properties. - */ - virtual gpio::Flags get_flags() const = 0; - - virtual bool digital_read() = 0; - - virtual void digital_write(bool value) = 0; - - /// Write a summary of this pin to the provided buffer. - /// @param buffer The buffer to write to - /// @param len The size of the buffer (must be > 0) - /// @return The number of characters that would be written (excluding null terminator), - /// which may exceed len-1 if truncation occurred (snprintf semantics) - virtual size_t dump_summary(char *buffer, size_t len) const; - - virtual bool is_internal() { return false; } -#ifdef USE_GPIO_HOLD - bool get_hold() const { return this->get_flags() & gpio::FLAG_HOLD; } - virtual bool is_held() const { return false; } -#else - bool get_hold() const { return false; } - bool is_held() const { return false; } -#endif -}; - -/// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) -class ISRInternalGPIOPin { - public: - ISRInternalGPIOPin() = default; - ISRInternalGPIOPin(void *arg) : arg_(arg) {} - bool digital_read(); - void digital_write(bool value); - void clear_interrupt(); - void pin_mode(gpio::Flags flags); - - protected: - void *arg_{nullptr}; -}; - -class InternalGPIOPin : public GPIOPin { - public: - template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { - this->attach_interrupt(reinterpret_cast(func), arg, type); - } - - virtual void detach_interrupt() const = 0; - - virtual ISRInternalGPIOPin to_isr() const = 0; - - virtual uint8_t get_pin() const = 0; - - bool is_internal() override { return true; } - - virtual bool is_inverted() const = 0; - - protected: - virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; -}; - -// Inline default implementation for GPIOPin::dump_summary. -// Writes an empty summary; subclasses override to provide pin details. -inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len > 0) - buffer[0] = '\0'; - return 0; -} - -// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp -inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { - char buffer[GPIO_SUMMARY_MAX_LEN]; - size_t len = pin->dump_summary(buffer, sizeof(buffer)); - len = std::min(len, sizeof(buffer) - 1); - esp_log_printf_(ESPHOME_LOG_LEVEL_CONFIG, tag, __LINE__, "%s%.*s", prefix, (int) len, buffer); -} - -// log_pin function declarations - implementation in gpio.cpp -#ifdef USE_ESP8266 -void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin); -#else -void log_pin(const char *tag, const char *prefix, GPIOPin *pin); -#endif - +static_assert(InternalGPIOPinContract, + "The platform's internal pin class is missing part of the InternalGPIOPin surface " + "(esphome/core/gpio_pin.h)"); } // namespace esphome diff --git a/esphome/core/gpio_pin.h b/esphome/core/gpio_pin.h new file mode 100644 index 00000000000..6c9acfa8d6a --- /dev/null +++ b/esphome/core/gpio_pin.h @@ -0,0 +1,147 @@ +#pragma once +#include +#include +#include +#include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { + +/// Maximum buffer size for dump_summary output +inline constexpr size_t GPIO_SUMMARY_MAX_LEN = 48; + +#ifdef USE_ESP8266 +#define LOG_PIN(prefix, pin) log_pin(TAG, F(prefix), pin) +#else +#define LOG_PIN(prefix, pin) log_pin(TAG, prefix, pin) +#endif + +// put GPIO flags in a namespace to not pollute esphome namespace +namespace gpio { + +enum Flags : uint8_t { + // Can't name these just INPUT because of Arduino defines :( + FLAG_NONE = 0x00, + FLAG_INPUT = 0x01, + FLAG_OUTPUT = 0x02, + FLAG_OPEN_DRAIN = 0x04, + FLAG_PULLUP = 0x08, + FLAG_PULLDOWN = 0x10, + FLAG_HOLD = 0x20, +}; + +class FlagsHelper { + public: + constexpr FlagsHelper(Flags val) : val_(val) {} + constexpr operator Flags() const { return val_; } + + protected: + Flags val_; +}; +constexpr FlagsHelper operator&(Flags lhs, Flags rhs) { + return static_cast(static_cast(lhs) & static_cast(rhs)); +} +constexpr FlagsHelper operator|(Flags lhs, Flags rhs) { + return static_cast(static_cast(lhs) | static_cast(rhs)); +} + +enum InterruptType : uint8_t { + INTERRUPT_RISING_EDGE = 1, + INTERRUPT_FALLING_EDGE = 2, + INTERRUPT_ANY_EDGE = 3, + INTERRUPT_LOW_LEVEL = 4, + INTERRUPT_HIGH_LEVEL = 5, +}; + +} // namespace gpio + +class GPIOPin { + public: + virtual void setup() = 0; + + virtual void pin_mode(gpio::Flags flags) = 0; + + /** + * @brief Retrieve GPIO pin flags. + * + * @return The GPIO flags describing the pin mode and properties. + */ + virtual gpio::Flags get_flags() const = 0; + + virtual bool digital_read() = 0; + + virtual void digital_write(bool value) = 0; + + /// Write a summary of this pin to the provided buffer. + /// @param buffer The buffer to write to + /// @param len The size of the buffer (must be > 0) + /// @return The number of characters that would be written (excluding null terminator), + /// which may exceed len-1 if truncation occurred (snprintf semantics) + virtual size_t dump_summary(char *buffer, size_t len) const; + + /// Only the platform pin class behind the InternalGPIOPin alias may return true: + /// callers static_cast to InternalGPIOPin on it. + virtual bool is_internal() { return false; } +#ifdef USE_GPIO_HOLD + bool get_hold() const { return this->get_flags() & gpio::FLAG_HOLD; } + virtual bool is_held() const { return false; } +#else + bool get_hold() const { return false; } + bool is_held() const { return false; } +#endif +}; + +/// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) +class ISRInternalGPIOPin { + public: + ISRInternalGPIOPin() = default; + ISRInternalGPIOPin(void *arg) : arg_(arg) {} + bool digital_read(); + void digital_write(bool value); + void clear_interrupt(); + void pin_mode(gpio::Flags flags); + + protected: + void *arg_{nullptr}; +}; + +// What callers of the InternalGPIOPin alias (bound in esphome/core/gpio.h) rely on beyond +// GPIOPin. The class must be final so calls to the GPIOPin virtuals through the alias are direct. +// int is arbitrary: any T * exercises the public attach_interrupt template. +template +concept InternalGPIOPinContract = std::derived_from && std::is_final_v && + requires(const T pin, void (*func)(int *), int *arg, gpio::InterruptType type) { + { pin.get_pin() } -> std::same_as; + { pin.is_inverted() } -> std::same_as; + { pin.to_isr() } -> std::same_as; + { pin.detach_interrupt() } -> std::same_as; + { pin.attach_interrupt(func, arg, type) } -> std::same_as; +}; + +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. +inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { + if (len > 0) + buffer[0] = '\0'; + return 0; +} + +// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp +inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { + char buffer[GPIO_SUMMARY_MAX_LEN]; + size_t len = pin->dump_summary(buffer, sizeof(buffer)); + len = std::min(len, sizeof(buffer) - 1); + esp_log_printf_(ESPHOME_LOG_LEVEL_CONFIG, tag, __LINE__, "%s%.*s", prefix, (int) len, buffer); +} + +// log_pin function declarations - implementation in gpio.cpp +#ifdef USE_ESP8266 +void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin); +#else +void log_pin(const char *tag, const char *prefix, GPIOPin *pin); +#endif + +} // namespace esphome diff --git a/tests/components/deep_sleep/test-timer-only.esp32-idf.yaml b/tests/components/deep_sleep/test-timer-only.esp32-idf.yaml new file mode 100644 index 00000000000..ad93ea225fd --- /dev/null +++ b/tests/components/deep_sleep/test-timer-only.esp32-idf.yaml @@ -0,0 +1,4 @@ +# No pin anywhere in the config: the pin class methods deep_sleep calls must still link +deep_sleep: + run_duration: 10s + sleep_duration: 50s diff --git a/tests/components/power_supply/test_setup_priority.cpp b/tests/components/power_supply/test_setup_priority.cpp index 401fc72654e..736fdf99b0c 100644 --- a/tests/components/power_supply/test_setup_priority.cpp +++ b/tests/components/power_supply/test_setup_priority.cpp @@ -1,3 +1,4 @@ +#ifdef USE_HOST #include #include "esphome/components/power_supply/power_supply.h" @@ -6,28 +7,9 @@ namespace esphome::power_supply::testing { -// Minimal dummy internal GPIO pin implementation for testing -class DummyInternalPin : public InternalGPIOPin { - public: - DummyInternalPin() = default; - void setup() override {} - void pin_mode(esphome::gpio::Flags) override {} - esphome::gpio::Flags get_flags() const override { return esphome::gpio::FLAG_NONE; } - bool digital_read() override { return false; } - void digital_write(bool) override {} - void detach_interrupt() const override {} - ISRInternalGPIOPin to_isr() const override { return ISRInternalGPIOPin(); } - uint8_t get_pin() const override { return 0; } - bool is_inverted() const override { return false; } - - protected: - // Implement protected attach_interrupt required by InternalGPIOPin - void attach_interrupt(void (*func)(void *), void *arg, esphome::gpio::InterruptType type) const override {} -}; - TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { power_supply::PowerSupply ps; - DummyInternalPin pin; + InternalGPIOPin pin; ps.set_pin(&pin); ps.set_enable_on_boot(true); @@ -37,7 +19,7 @@ TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { power_supply::PowerSupply ps; - DummyInternalPin pin; + InternalGPIOPin pin; ps.set_pin(&pin); ps.set_enable_on_boot(false); @@ -45,3 +27,4 @@ TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { } } // namespace esphome::power_supply::testing +#endif // USE_HOST From 59963dd246715a999feb13b7943beb64b6f40770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:38:16 -1000 Subject: [PATCH 167/435] [espidf] Declare the map byproduct only when IDF has not (#20462) --- esphome/build_gen/espidf.py | 17 +++++++++++++---- tests/unit_tests/build_gen/test_espidf.py | 13 +++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 759d9a0bef4..a826a2b6283 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -425,15 +425,24 @@ project({CORE.name}) # Emit per-memory-type JSON size data for ESPHome to read post-build. # json2 stays small; raw dumps every symbol (~2s on a large map) and # this command runs inside the link edge, blocking everything downstream. -# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size -# target depends on the map and can then be built in the same run as all. -# IDF's cmakev2 declares the map itself, so drop this line on that switch. +# IDF's size target depends on the map, so ninja has to know the link +# writes it for size to build in the same run as all. IDF declares the +# map itself since espressif/esp-idf#19201 (and in cmakev2), and ninja +# rejects two rules for one output, so declare it here only when IDF has +# not. add_custom_command(OUTPUT) marks its outputs GENERATED. +get_source_file_property(esphome_map_declared + ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map GENERATED) +if(esphome_map_declared) + set(esphome_map_byproducts) +else() + set(esphome_map_byproducts BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map) +endif() add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2 -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map - BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map + ${{esphome_map_byproducts}} WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} VERBATIM ) diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index c439b87d163..9d92988da0f 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -242,9 +242,18 @@ def test_get_project_cmakelists_drops_empty_lwip_sources() -> None: def test_get_project_cmakelists_declares_map_as_link_byproduct() -> None: - """The link declares the map so size can build in the same ninja run.""" + """The map is a link byproduct only when IDF has not declared it itself.""" content = _render() - assert "BYPRODUCTS ${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map" in content + assert ( + "get_source_file_property(esphome_map_declared\n" + " ${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map GENERATED)" + ) in content + assert ( + "set(esphome_map_byproducts BYPRODUCTS " + "${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map)" + ) in content + assert " ${esphome_map_byproducts}\n WORKING_DIRECTORY" in content + assert "BYPRODUCTS ${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map\n" not in content def test_get_project_cmakelists_uses_supplied_builtin_components() -> None: From 95dc9a749b07a0250f8a1c34448e020efd888208 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:39:00 -1000 Subject: [PATCH 168/435] [display_menu_base] Build the menu item triggers with build_parent_callback_automation (#20443) --- .../components/display_menu_base/__init__.py | 128 ++++-------------- .../components/display_menu_base/automation.h | 58 -------- 2 files changed, 30 insertions(+), 156 deletions(-) delete mode 100644 esphome/components/display_menu_base/automation.h diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9d046dbc19a..791242cda12 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -20,9 +20,10 @@ from esphome.const import ( CONF_ON_VALUE, CONF_SWITCH, CONF_TEXT, - CONF_TRIGGER_ID, CONF_TYPE, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@numo68"] DOMAIN = "display_menu_base" @@ -86,26 +87,6 @@ MENU_MODES = { CONF_JOYSTICK: MenuMode.MENU_MODE_JOYSTICK, } -DisplayMenuOnEnterTrigger = display_menu_base_ns.class_( - "DisplayMenuOnEnterTrigger", automation.Trigger -) - -DisplayMenuOnLeaveTrigger = display_menu_base_ns.class_( - "DisplayMenuOnLeaveTrigger", automation.Trigger -) - -DisplayMenuOnValueTrigger = display_menu_base_ns.class_( - "DisplayMenuOnValueTrigger", automation.Trigger -) - -DisplayMenuOnNextTrigger = display_menu_base_ns.class_( - "DisplayMenuOnNextTrigger", automation.Trigger -) - -DisplayMenuOnPrevTrigger = display_menu_base_ns.class_( - "DisplayMenuOnPrevTrigger", automation.Trigger -) - def validate_format(format): if re.search(r"^%[+-]*(\d+)?(\.\d+)?[fg]$", format) is None: @@ -129,44 +110,20 @@ MENU_ITEM_COMMON_SCHEMA = cv.Schema( MENU_ITEM_ENTER_LEAVE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( { - cv.Optional(CONF_ON_ENTER): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnEnterTrigger - ), - } - ), - cv.Optional(CONF_ON_LEAVE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnLeaveTrigger - ), - } - ), + cv.Optional(CONF_ON_ENTER): automation.validate_automation({}), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}), } ) MENU_ITEM_VALUE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnValueTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), } ) MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA = MENU_ITEM_ENTER_LEAVE_SCHEMA.extend( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnValueTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), } ) @@ -230,20 +187,8 @@ MENU_ITEM_SCHEMA = cv.typed_schema( cv.GenerateID(CONF_ID): cv.declare_id(MenuItemCustom), cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean, cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_ON_NEXT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnNextTrigger - ), - } - ), - cv.Optional(CONF_ON_PREV): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnPrevTrigger - ), - } - ), + cv.Optional(CONF_ON_NEXT): automation.validate_automation({}), + cv.Optional(CONF_ON_PREV): automation.validate_automation({}), } ), }, @@ -256,20 +201,8 @@ DISPLAY_MENU_BASE_SCHEMA = cv.Schema( cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.GenerateID(CONF_ROOT_ITEM_ID): cv.declare_id(MenuItemMenu), cv.Optional(CONF_MODE, default=CONF_ROTARY): cv.enum(MENU_MODES), - cv.Optional(CONF_ON_ENTER): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnEnterTrigger - ), - } - ), - cv.Optional(CONF_ON_LEAVE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnLeaveTrigger - ), - } - ), + cv.Optional(CONF_ON_ENTER): automation.validate_automation({}), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}), cv.Required(CONF_ITEMS): cv.All( cv.ensure_list(MENU_ITEM_SCHEMA), cv.Length(min=1) ), @@ -302,6 +235,24 @@ automation.register_apply_condition( ) +# Each item callback carries nothing; the automation receives the item itself. +_ITEM_CALLBACKS = ( + (CONF_ON_ENTER, "add_on_enter_callback"), + (CONF_ON_LEAVE, "add_on_leave_callback"), + (CONF_ON_VALUE, "add_on_value_callback"), + (CONF_ON_NEXT, "add_on_next_callback"), + (CONF_ON_PREV, "add_on_prev_callback"), +) + + +async def _build_item_automations(item: MockObj, config: ConfigType) -> None: + for conf_key, callback_method in _ITEM_CALLBACKS: + for conf in config.get(conf_key, []): + await automation.build_parent_callback_automation( + item, callback_method, (MenuItemConstPtr, "it"), conf + ) + + async def menu_item_to_code(menu, config, parent): if config[CONF_TYPE] in MENU_ITEMS_WITH_SPECIALIZED_CLASSES: item = cg.new_Pvariable(config[CONF_ID]) @@ -340,21 +291,7 @@ async def menu_item_to_code(menu, config, parent): cg.add(item.set_switch_variable(var)) cg.add(item.set_on_text(config[CONF_ON_TEXT])) cg.add(item.set_off_text(config[CONF_OFF_TEXT])) - for conf in config.get(CONF_ON_ENTER, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_LEAVE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_NEXT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_PREV, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + await _build_item_automations(item, config) async def display_menu_to_code(menu, config): @@ -364,9 +301,4 @@ async def display_menu_to_code(menu, config): cg.add(menu.set_mode(config[CONF_MODE])) for c in config[CONF_ITEMS]: await menu_item_to_code(menu, c, root_item) - for conf in config.get(CONF_ON_ENTER, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_LEAVE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + await _build_item_automations(root_item, config) diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h deleted file mode 100644 index 477a971d479..00000000000 --- a/esphome/components/display_menu_base/automation.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "display_menu_base.h" - -namespace esphome::display_menu_base { - -class DisplayMenuOnEnterTrigger final : public Trigger { - public: - explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_enter_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnLeaveTrigger final : public Trigger { - public: - explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_leave_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnValueTrigger final : public Trigger { - public: - explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_value_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnNextTrigger final : public Trigger { - public: - explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) { - parent->add_on_next_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItemCustom *parent_; -}; - -class DisplayMenuOnPrevTrigger final : public Trigger { - public: - explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) { - parent->add_on_prev_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItemCustom *parent_; -}; - -} // namespace esphome::display_menu_base From f1a23a02c24203c306c3a7c9f970abe71ee5e1cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:39:06 -1000 Subject: [PATCH 169/435] [time] Build on_time_sync with build_callback_automation (#20441) --- esphome/components/time/__init__.py | 23 +++++++------------ esphome/components/time/automation.cpp | 4 ---- esphome/components/time/automation.h | 8 ------- tests/component_tests/time/test_triggers.py | 6 ++--- .../time/test-sync.esp8266-ard.yaml | 8 +++++++ 5 files changed, 19 insertions(+), 30 deletions(-) create mode 100644 tests/components/time/test-sync.esp8266-ard.yaml diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index cedb9b3c9b5..a017109e7d3 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -46,7 +46,6 @@ DOMAIN = "time" time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component) -SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component) # C++ types for pre-parsed timezone struct generation DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) @@ -357,11 +356,7 @@ TIME_SCHEMA = cv.Schema( }, validate_cron_keys, ), - cv.Optional(CONF_ON_TIME_SYNC): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SyncTrigger), - } - ), + cv.Optional(CONF_ON_TIME_SYNC): automation.validate_automation({}), } ).extend( # ``visibility=ADVANCED`` flags the inherited ``update_interval`` @@ -424,8 +419,7 @@ async def setup_time_core_(time_var, config): _emit_parsed_timezone_fields(parsed) on_time = config.get(CONF_ON_TIME, []) - on_time_sync = config.get(CONF_ON_TIME_SYNC, []) - if on_time or on_time_sync: + if on_time: cg.add_define("USE_TIME_TRIGGERS") for conf in on_time: @@ -447,11 +441,10 @@ async def setup_time_core_(time_var, config): await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) - for conf in on_time_sync: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) - - await cg.register_component(trigger, conf) - await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_TIME_SYNC, []): + await automation.build_callback_automation( + time_var, "add_on_time_sync_callback", [], conf + ) async def register_time(time_var, config): @@ -478,8 +471,8 @@ automation.register_apply_condition( # posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a -# timezone is configured or detected; automation.cpp holds the on_time and -# on_time_sync triggers and is #ifdef'd on USE_TIME_TRIGGERS. +# timezone is configured or detected; automation.cpp holds the on_time +# trigger and is #ifdef'd on USE_TIME_TRIGGERS. FILTER_SOURCE_FILES = filter_source_files_from_defines( { "posix_tz.cpp": "USE_TIME_TIMEZONE", diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index 26956703e7f..1c3d4625667 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -94,10 +94,6 @@ void CronTrigger::add_days_of_week(const std::vector &days_of_week) { } float CronTrigger::get_setup_priority() const { return setup_priority::HARDWARE; } -SyncTrigger::SyncTrigger(RealTimeClock *rtc) : rtc_(rtc) { - rtc->add_on_time_sync_callback([this]() { this->trigger(); }); -} - } // namespace esphome::time #endif // USE_TIME_TRIGGERS diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 5f980690a41..5e70000ac3f 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -43,14 +43,6 @@ class CronTrigger final : public Trigger<>, public Component { RealTimeClock *rtc_; optional last_check_; }; - -class SyncTrigger final : public Trigger<>, public Component { - public: - explicit SyncTrigger(RealTimeClock *rtc); - - protected: - RealTimeClock *rtc_; -}; } // namespace esphome::time #endif // USE_TIME_TRIGGERS diff --git a/tests/component_tests/time/test_triggers.py b/tests/component_tests/time/test_triggers.py index 34fd5a31578..d6c3eed584f 100644 --- a/tests/component_tests/time/test_triggers.py +++ b/tests/component_tests/time/test_triggers.py @@ -1,5 +1,5 @@ -"""automation.cpp (CronTrigger and SyncTrigger) is only compiled when an -on_time or on_time_sync automation exists, so the define must follow them.""" +"""automation.cpp (CronTrigger) is only compiled when an on_time automation +exists, so the define must follow it; on_time_sync needs no class.""" from collections.abc import Callable from pathlib import Path @@ -14,7 +14,7 @@ from esphome.core import CORE [ ("no_triggers.yaml", False), ("on_time.yaml", True), - ("on_time_sync.yaml", True), + ("on_time_sync.yaml", False), ], ) def test_triggers_define_follows_automations( diff --git a/tests/components/time/test-sync.esp8266-ard.yaml b/tests/components/time/test-sync.esp8266-ard.yaml new file mode 100644 index 00000000000..6240fcd73e7 --- /dev/null +++ b/tests/components/time/test-sync.esp8266-ard.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +time: + - platform: sntp + on_time_sync: + - logger.log: Time synchronized From e8943975035e93923830820428b06b3677bab8f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:39:25 -1000 Subject: [PATCH 170/435] [datetime] Build on_value with build_callback_automation (#20445) --- esphome/components/datetime/__init__.py | 20 +++++++++---------- esphome/components/datetime/datetime_base.h | 10 ---------- .../components/datetime/common-triggers.yaml | 10 ++++++++++ .../datetime/test-triggers.esp8266-ard.yaml | 3 +++ 4 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 tests/components/datetime/common-triggers.yaml create mode 100644 tests/components/datetime/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index de79c05a9f0..20fd258cb00 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -43,9 +43,6 @@ DateEntity = datetime_ns.class_("DateEntity", DateTimeBase) TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase) DateTimeEntity = datetime_ns.class_("DateTimeEntity", DateTimeBase) -DateTimeStateTrigger = datetime_ns.class_( - "DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime) -) OnTimeTrigger = datetime_ns.class_( "OnTimeTrigger", automation.Trigger, cg.Component, cg.Parented.template(TimeEntity) @@ -75,11 +72,7 @@ def _validate_time_present(config: ConfigType) -> ConfigType: _DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( cv.Schema( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DateTimeStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), } ) @@ -144,9 +137,16 @@ async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: await mqtt.register_mqtt_component(mqtt_, config) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) + # The state callback carries nothing; the automation gets the value as an ESPTime. for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.ESPTime, "x")], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.ESPTime, "x")], + conf, + params=[], + forward=[automation.parent_ref(var).state_as_esptime()], + ) if CONF_TIME_ID in config: rtc = await cg.get_variable(config[CONF_TIME_ID]) diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index f99debb692d..fdbb1930068 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -31,14 +31,4 @@ class DateTimeBase : public EntityBase { #endif }; -class DateTimeStateTrigger final : public Trigger { - public: - explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) { - parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); }); - } - - protected: - DateTimeBase *parent_; -}; - } // namespace esphome::datetime diff --git a/tests/components/datetime/common-triggers.yaml b/tests/components/datetime/common-triggers.yaml new file mode 100644 index 00000000000..e93e8b2b514 --- /dev/null +++ b/tests/components/datetime/common-triggers.yaml @@ -0,0 +1,10 @@ +datetime: + - platform: template + name: Datetime Trigger Test + id: datetime_trigger_test + type: datetime + optimistic: true + on_value: + - logger.log: + format: "New value %04d-%02d-%02d" + args: [x.year, x.month, x.day_of_month] diff --git a/tests/components/datetime/test-triggers.esp8266-ard.yaml b/tests/components/datetime/test-triggers.esp8266-ard.yaml new file mode 100644 index 00000000000..3829bedb1af --- /dev/null +++ b/tests/components/datetime/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + datetime: !include common.yaml + triggers: !include common-triggers.yaml From bc44e8b5504fc01775355d443bea0d5be5cc7b70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:40:40 -1000 Subject: [PATCH 171/435] [display] Test on_page_change with each from and to combination (#20457) --- .../display/common-page-change.yaml | 26 +++++++++++++++++++ .../display/test-page-change.esp32-idf.yaml | 6 +++++ 2 files changed, 32 insertions(+) create mode 100644 tests/components/display/common-page-change.yaml create mode 100644 tests/components/display/test-page-change.esp32-idf.yaml diff --git a/tests/components/display/common-page-change.yaml b/tests/components/display/common-page-change.yaml new file mode 100644 index 00000000000..649cad767be --- /dev/null +++ b/tests/components/display/common-page-change.yaml @@ -0,0 +1,26 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + model: SSD1306_128X64 + reset_pin: ${reset_pin} + id: display_page_change_display + pages: + - id: display_page_change_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: display_page_change_page2 + lambda: |- + it.rectangle(0, 0, 10, 10); + on_page_change: + - then: + lambda: 'ESP_LOGD("display", "any change, %p %p", from, to);' + - from: display_page_change_page1 + then: + lambda: 'ESP_LOGD("display", "left page 1, %p %p", from, to);' + - to: display_page_change_page1 + then: + lambda: 'ESP_LOGD("display", "entered page 1, %p %p", from, to);' + - from: display_page_change_page1 + to: display_page_change_page2 + then: + lambda: 'ESP_LOGD("display", "1 -> 2, %p %p", from, to);' diff --git a/tests/components/display/test-page-change.esp32-idf.yaml b/tests/components/display/test-page-change.esp32-idf.yaml new file mode 100644 index 00000000000..b44cc076b29 --- /dev/null +++ b/tests/components/display/test-page-change.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + reset_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + display: !include common-page-change.yaml From fdc163a30d764045ce16d0a5f7cbb88f29bbeaf3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:42:43 -1000 Subject: [PATCH 172/435] [tuya] Build on_datapoint_update with build_trigger_callback (#20453) --- esphome/components/tuya/__init__.py | 85 +++++++------------------- esphome/components/tuya/automation.cpp | 65 -------------------- esphome/components/tuya/automation.h | 53 ---------------- esphome/components/tuya/tuya.cpp | 19 ++++-- esphome/components/tuya/tuya.h | 10 ++- tests/components/tuya/common.yaml | 45 +++++++++++++- 6 files changed, 87 insertions(+), 190 deletions(-) delete mode 100644 esphome/components/tuya/automation.cpp delete mode 100644 esphome/components/tuya/automation.h diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 6957b57e0e0..bf392c78268 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -2,7 +2,8 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID +from esphome.cpp_generator import MockObj DEPENDENCIES = ["uart"] DOMAIN = "tuya" @@ -17,6 +18,8 @@ tuya_ns = cg.esphome_ns.namespace("tuya") TuyaDatapointType = tuya_ns.enum("TuyaDatapointType", is_class=True) Tuya = tuya_ns.class_("Tuya", cg.Component, uart.UARTDevice) +TuyaDatapoint = tuya_ns.struct("TuyaDatapoint") + DPTYPE_ANY = "any" DPTYPE_RAW = "raw" DPTYPE_BOOL = "bool" @@ -26,60 +29,18 @@ DPTYPE_STRING = "string" DPTYPE_ENUM = "enum" DPTYPE_BITMASK = "bitmask" +# Automation argument type, plus the expected TuyaDatapointType and the field forwarded DATAPOINT_TYPES = { - DPTYPE_ANY: tuya_ns.struct("TuyaDatapoint"), - DPTYPE_RAW: cg.std_vector.template(cg.uint8), - DPTYPE_BOOL: cg.bool_, - DPTYPE_INT: cg.int_, - DPTYPE_UINT: cg.uint32, - DPTYPE_STRING: cg.std_string, - DPTYPE_ENUM: cg.uint8, - DPTYPE_BITMASK: cg.uint32, + DPTYPE_ANY: (TuyaDatapoint, None, None), + DPTYPE_RAW: (cg.std_vector.template(cg.uint8), TuyaDatapointType.RAW, "value_raw"), + DPTYPE_BOOL: (cg.bool_, TuyaDatapointType.BOOLEAN, "value_bool"), + DPTYPE_INT: (cg.int_, TuyaDatapointType.INTEGER, "value_int"), + DPTYPE_UINT: (cg.uint32, TuyaDatapointType.INTEGER, "value_uint"), + DPTYPE_STRING: (cg.std_string, TuyaDatapointType.STRING, "value_string"), + DPTYPE_ENUM: (cg.uint8, TuyaDatapointType.ENUM, "value_enum"), + DPTYPE_BITMASK: (cg.uint32, TuyaDatapointType.BITMASK, "value_bitmask"), } -DATAPOINT_TRIGGERS = { - DPTYPE_ANY: tuya_ns.class_( - "TuyaDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_ANY]), - ), - DPTYPE_RAW: tuya_ns.class_( - "TuyaRawDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_RAW]), - ), - DPTYPE_BOOL: tuya_ns.class_( - "TuyaBoolDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_BOOL]), - ), - DPTYPE_INT: tuya_ns.class_( - "TuyaIntDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_INT]), - ), - DPTYPE_UINT: tuya_ns.class_( - "TuyaUIntDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_UINT]), - ), - DPTYPE_STRING: tuya_ns.class_( - "TuyaStringDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_STRING]), - ), - DPTYPE_ENUM: tuya_ns.class_( - "TuyaEnumDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_ENUM]), - ), - DPTYPE_BITMASK: tuya_ns.class_( - "TuyaBitmaskDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_BITMASK]), - ), -} - - -def assign_declare_id(value): - value = value.copy() - value[CONF_TRIGGER_ID] = cv.declare_id( - DATAPOINT_TRIGGERS[value[CONF_DATAPOINT_TYPE]] - )(value[CONF_TRIGGER_ID].id) - return value - CONF_TUYA_ID = "tuya_id" CONFIG_SCHEMA = ( @@ -93,15 +54,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_STATUS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_ON_DATAPOINT_UPDATE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DATAPOINT_TRIGGERS[DPTYPE_ANY] - ), cv.Required(CONF_SENSOR_DATAPOINT): cv.uint8_t, cv.Optional(CONF_DATAPOINT_TYPE, default=DPTYPE_ANY): cv.one_of( - *DATAPOINT_TRIGGERS, lower=True + *DATAPOINT_TYPES, lower=True ), - }, - extra_validators=assign_declare_id, + } ), } ) @@ -124,9 +81,11 @@ async def to_code(config): for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) for conf in config.get(CONF_ON_DATAPOINT_UPDATE, []): - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], var, conf[CONF_SENSOR_DATAPOINT] - ) - await automation.build_automation( - trigger, [(DATAPOINT_TYPES[conf[CONF_DATAPOINT_TYPE]], "x")], conf + type_, expected, field = DATAPOINT_TYPES[conf[CONF_DATAPOINT_TYPE]] + forward = None + if expected is not None: + forward = [getattr(MockObj("x", ".").expect_type(expected), field)] + callback = await automation.build_trigger_callback( + [(type_, "x")], conf, params=[(TuyaDatapoint, "x")], forward=forward ) + cg.add(var.register_listener(conf[CONF_SENSOR_DATAPOINT], callback)) diff --git a/esphome/components/tuya/automation.cpp b/esphome/components/tuya/automation.cpp deleted file mode 100644 index a7eacf8e9a3..00000000000 --- a/esphome/components/tuya/automation.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "esphome/core/log.h" - -#include "automation.h" - -ESPHOME_LOG_TAG(TAG, "tuya.automation"); - -namespace esphome::tuya { - -void check_expected_datapoint(const TuyaDatapoint &dp, TuyaDatapointType expected) { - if (dp.type != expected) { - ESP_LOGW(TAG, "Tuya sensor %u expected datapoint type %#02hhX but got %#02hhX", dp.id, - static_cast(expected), static_cast(dp.type)); - } -} - -TuyaRawDatapointUpdateTrigger::TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::RAW); - this->trigger(dp.value_raw); - }); -} - -TuyaBoolDatapointUpdateTrigger::TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::BOOLEAN); - this->trigger(dp.value_bool); - }); -} - -TuyaIntDatapointUpdateTrigger::TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::INTEGER); - this->trigger(dp.value_int); - }); -} - -TuyaUIntDatapointUpdateTrigger::TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::INTEGER); - this->trigger(dp.value_uint); - }); -} - -TuyaStringDatapointUpdateTrigger::TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::STRING); - this->trigger(dp.value_string); - }); -} - -TuyaEnumDatapointUpdateTrigger::TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::ENUM); - this->trigger(dp.value_enum); - }); -} - -TuyaBitmaskDatapointUpdateTrigger::TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::BITMASK); - this->trigger(dp.value_bitmask); - }); -} - -} // namespace esphome::tuya diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h deleted file mode 100644 index 0cd63a76be7..00000000000 --- a/esphome/components/tuya/automation.h +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "tuya.h" - -#include - -namespace esphome::tuya { - -class TuyaDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { this->trigger(dp); }); - } -}; - -class TuyaRawDatapointUpdateTrigger final : public Trigger> { - public: - explicit TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaBoolDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaIntDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaUIntDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaStringDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaEnumDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaBitmaskDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -} // namespace esphome::tuya diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 784aea5c50b..b23dcc946bd 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -482,7 +482,7 @@ void Tuya::handle_datapoints_(const uint8_t *buffer, size_t len) { // Run through listeners for (auto &listener : this->listeners_) { if (listener.datapoint_id == datapoint.id) - listener.on_datapoint(datapoint); + listener.on_datapoint.call(datapoint); } } } @@ -796,20 +796,27 @@ void Tuya::send_datapoint_command_(uint8_t datapoint_id, TuyaDatapointType datap this->send_command_(TuyaCommand{.cmd = TuyaCommandType::DATAPOINT_DELIVER, .payload = buffer}); } -void Tuya::register_listener(uint8_t datapoint_id, const std::function &func) { - auto listener = TuyaDatapointListener{ +void Tuya::register_listener_(uint8_t datapoint_id, Callback func) { + this->listeners_.push_back(TuyaDatapointListener{ .datapoint_id = datapoint_id, .on_datapoint = func, - }; - this->listeners_.push_back(listener); + }); // Run through existing datapoints for (auto &datapoint : this->datapoints_) { if (datapoint.id == datapoint_id) - func(datapoint); + func.call(datapoint); } } +const TuyaDatapoint &TuyaDatapoint::expect_type(TuyaDatapointType expected) const { + if (this->type != expected) { + ESP_LOGW(TAG, "Tuya sensor %u expected datapoint type %#02hhX but got %#02hhX", this->id, + static_cast(expected), static_cast(this->type)); + } + return *this; +} + TuyaInitState Tuya::get_init_state() { return this->init_state_; } } // namespace esphome::tuya diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index b8bf4e0ab18..f074d301629 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -37,11 +37,14 @@ struct TuyaDatapoint { }; std::string value_string; std::vector value_raw; + + /// Log a warning when this datapoint is not of type `expected`, and return it. + const TuyaDatapoint &expect_type(TuyaDatapointType expected) const; }; struct TuyaDatapointListener { uint8_t datapoint_id; - std::function on_datapoint; + Callback on_datapoint; }; enum class TuyaCommandType : uint8_t { @@ -91,7 +94,9 @@ class Tuya final : public Component, public uart::UARTDevice { void setup() override; void loop() override; void dump_config() override; - void register_listener(uint8_t datapoint_id, const std::function &func); + template void register_listener(uint8_t datapoint_id, F &&func) { + this->register_listener_(datapoint_id, Callback::create(std::forward(func))); + } void set_raw_datapoint_value(uint8_t datapoint_id, const std::vector &value); void set_boolean_datapoint_value(uint8_t datapoint_id, bool value); void set_integer_datapoint_value(uint8_t datapoint_id, uint32_t value); @@ -117,6 +122,7 @@ class Tuya final : public Component, public uart::UARTDevice { } protected: + void register_listener_(uint8_t datapoint_id, Callback func); void handle_char_(uint8_t c); void handle_datapoints_(const uint8_t *buffer, size_t len); optional get_datapoint_(uint8_t datapoint_id); diff --git a/tests/components/tuya/common.yaml b/tests/components/tuya/common.yaml index f52d47e7a00..1d0fc473acc 100644 --- a/tests/components/tuya/common.yaml +++ b/tests/components/tuya/common.yaml @@ -10,7 +10,50 @@ tuya: - sensor_datapoint: 6 datapoint_type: raw then: - - logger.log: Datapoint 6 updated + - logger.log: + format: "Datapoint 6 updated, %u bytes" + args: [x.size()] + - sensor_datapoint: 1 + then: + - logger.log: + format: "Datapoint %u updated" + args: [x.id] + - sensor_datapoint: 1 + datapoint_type: bool + then: + - logger.log: + format: "Datapoint 1 is %s" + args: ['x ? "on" : "off"'] + - sensor_datapoint: 3 + datapoint_type: int + then: + - logger.log: + format: "Datapoint 3 is %d" + args: [x] + - sensor_datapoint: 3 + datapoint_type: uint + then: + - logger.log: + format: "Datapoint 3 is %u" + args: [x] + - sensor_datapoint: 7 + datapoint_type: string + then: + - logger.log: + format: "Datapoint 7 is %s" + args: [x.c_str()] + - sensor_datapoint: 8 + datapoint_type: enum + then: + - logger.log: + format: "Datapoint 8 is %u" + args: [x] + - sensor_datapoint: 9 + datapoint_type: bitmask + then: + - logger.log: + format: "Datapoint 9 is %u" + args: [x] binary_sensor: - platform: tuya From 02bfa0781961e2ff1b0a78bc2dd9ea2a8907edae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:42:55 -1000 Subject: [PATCH 173/435] [opentherm] Build before_send and before_process_response with build_callback_automation (#20450) --- esphome/components/opentherm/__init__.py | 44 ++++++++--------------- esphome/components/opentherm/automation.h | 23 ------------ tests/components/opentherm/common.yaml | 2 ++ 3 files changed, 16 insertions(+), 53 deletions(-) delete mode 100644 esphome/components/opentherm/automation.h diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 439ec032457..cfcdfe77833 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TRIGGER_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 from esphome.core import CORE from . import const, generate, schema, validate @@ -27,14 +27,16 @@ CONF_SYNC_MODE = "sync_mode" CONF_BEFORE_SEND = "before_send" CONF_BEFORE_PROCESS_RESPONSE = "before_process_response" -# Triggers -BeforeSendTrigger = generate.opentherm_ns.class_( - "BeforeSendTrigger", - automation.Trigger.template(generate.OpenthermData.operator("ref")), -) -BeforeProcessResponseTrigger = generate.opentherm_ns.class_( - "BeforeProcessResponseTrigger", - automation.Trigger.template(generate.OpenthermData.operator("ref")), +_DATA_ARGS = [(generate.OpenthermData.operator("ref"), "x")] +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_BEFORE_SEND, "add_on_before_send_callback", _DATA_ARGS + ), + automation.CallbackAutomation( + CONF_BEFORE_PROCESS_RESPONSE, + "add_on_before_process_response_callback", + _DATA_ARGS, + ), ) CONFIG_SCHEMA = cv.All( @@ -51,17 +53,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SUMMER_MODE_ACTIVE, False): cv.boolean, cv.Optional(CONF_DHW_BLOCK, False): cv.boolean, cv.Optional(CONF_SYNC_MODE, False): cv.boolean, - cv.Optional(CONF_BEFORE_SEND): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BeforeSendTrigger), - } - ), + cv.Optional(CONF_BEFORE_SEND): automation.validate_automation({}), cv.Optional(CONF_BEFORE_PROCESS_RESPONSE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - BeforeProcessResponseTrigger - ), - } + {} ), } ) @@ -132,14 +126,4 @@ async def to_code(config: dict[str, Any]) -> None: generate.define_setting_readers(const.SETTING, settings) generate.add_messages(var, settings, schema.SETTINGS) - for conf in config.get(CONF_BEFORE_SEND, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(generate.OpenthermData.operator("ref"), "x")], conf - ) - - for conf in config.get(CONF_BEFORE_PROCESS_RESPONSE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(generate.OpenthermData.operator("ref"), "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h deleted file mode 100644 index 365992b2805..00000000000 --- a/esphome/components/opentherm/automation.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "hub.h" -#include "opentherm.h" - -namespace esphome::opentherm { - -class BeforeSendTrigger final : public Trigger { - public: - BeforeSendTrigger(OpenthermHub *hub) { - hub->add_on_before_send_callback([this](OpenthermData &x) { this->trigger(x); }); - } -}; - -class BeforeProcessResponseTrigger final : public Trigger { - public: - BeforeProcessResponseTrigger(OpenthermHub *hub) { - hub->add_on_before_process_response_callback([this](OpenthermData &x) { this->trigger(x); }); - } -}; - -} // namespace esphome::opentherm diff --git a/tests/components/opentherm/common.yaml b/tests/components/opentherm/common.yaml index fb5fb39eb84..1ae9ea4a4dd 100644 --- a/tests/components/opentherm/common.yaml +++ b/tests/components/opentherm/common.yaml @@ -25,6 +25,8 @@ opentherm: then: - lambda: |- ESP_LOGW("OT", ">> Sending message %d", x.id); + if (x.id == 0) + x.valueHB |= 0x01; before_process_response: then: - lambda: |- From 073394b1eb50e265df13ba7bc8fac5aba54daa1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:43:22 -1000 Subject: [PATCH 174/435] [core] Let a trigger filter name its parent with `{parent}` (#20448) --- esphome/automation.py | 39 +++++++++----- tests/unit_tests/test_automation.py | 83 ++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 859ec2f1f3c..6b6af4ed72e 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -264,13 +264,17 @@ def string_ref_literal(config: ConfigType, value: str) -> str: return f"StringRef({literal_with_length(config, value)})" +def _names_parent(text: str) -> bool: + return any(f == "parent" for _, f, _, _ in string.Formatter().parse(text)) + + @dataclass(frozen=True) class ApplyCall: """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a - path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use - ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an + path into nested sections. ``target`` and a plain ``str`` ``type_`` (raw C++ type text) may + name the parent object as ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an id bypasses it. The statement is skipped when none of its keys is set, always emitted when it has no keys, and a partial set is a config error. """ @@ -282,13 +286,13 @@ class ApplyCall: fields = [ f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None ] - if any(fields): + if any(f not in ("", "parent") for f in fields): raise ValueError( - f"apply target {self.target!r}: only bare {{}} placeholders" + f"apply target {self.target!r}: only {{}} and {{parent}} placeholders" ) - if len(fields) != len(self.args): + if (count := fields.count("")) != len(self.args): raise ValueError( - f"apply target {self.target!r} has {len(fields)} " + f"apply target {self.target!r} has {count} " f"placeholder(s) for {len(self.args)} config key(s)" ) if any(len(arg) not in (2, 3) for arg in self.args): @@ -296,6 +300,12 @@ class ApplyCall: f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" ) + @property + def names_parent(self) -> bool: + return _names_parent(self.target) or any( + isinstance(arg[1], str) and _names_parent(arg[1]) for arg in self.args + ) + @property def members(self) -> list[tuple[Any, Any, Any]]: """Each arg as ``(conf_key, type_, const_fn or None)``.""" @@ -494,7 +504,7 @@ def register_apply_action( exprs = await _render_values( name, target, members, values, config, parent, lambda_args ) - statements.append(f"{receiver}{target.format(*exprs)};") + statements.append(f"{receiver}{target.format(*exprs, parent=parent)};") if call: statements = [ f"auto apply_call = {parent}->{call}();", @@ -526,7 +536,7 @@ async def _render_check( exprs = await _render_values( name, target, members, values, config, parent, lambda_args, compare=True ) - return target.format(*exprs) + return target.format(*exprs, parent=parent) def register_apply_condition( @@ -1061,21 +1071,22 @@ async def build_trigger_callback( params: TemplateArgsType, forward: Sequence[str | Expression] | None = None, when: str | ApplyCall | None = None, + parent: MockObj | None = None, ) -> LambdaExpression: """Build the Automation for ``config`` and return a stateless callback that triggers it. ``params`` are the parent callback's parameters, ``forward`` the expressions passed to ``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``), ``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none - of its keys is set. + of its keys is set. ``when`` may name ``parent`` as ``{parent}``, e.g. + ``"{parent}->is_fully_open()"``. """ members: list[tuple[Any, Any, Any]] = [] if when is not None: call = when if isinstance(when, ApplyCall) else ApplyCall(when) members = call.members - # A trigger callback has no parent for a str type to name. - if any(isinstance(t, str) and "{parent}" in t for _, t, _ in members): - raise ValueError(f"trigger filter {call.target!r}: a type names {{parent}}") + if parent is None and call.names_parent: + raise ValueError(f"trigger filter {call.target!r} names {{parent}}") obj = await _new_automation(args, config) lambda_args = _apply_lambda_args(params) statements: list[str] = [] @@ -1088,7 +1099,7 @@ async def build_trigger_callback( members, values, config, - None, + None if parent is None else str(parent_ref(parent)), lambda_args, ) statements.append(f"if (!({check}))\n return;") @@ -1137,7 +1148,7 @@ async def build_callback_automation( "forwarder cannot be combined with params, forward or when" ) callback = await build_trigger_callback( - args, config, args if params is None else params, forward, when + args, config, args if params is None else params, forward, when, parent ) cg.add(getattr(parent, callback_method)(callback)) return diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index ddd777fdb07..3cd70c082d5 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -943,6 +943,30 @@ async def test_apply_action_call_shape( assert positions == sorted(positions) +@pytest.mark.asyncio +async def test_apply_target_names_parent( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """{parent} in a target is the global-scoped parent, also beside a call object.""" + fields = (ApplyCall("set_peer({parent}->id(), {})", (("level", cg.float_),)),) + await _run_apply_action(registries, fields, {"level": 0.5}) + assert ( + f"::{PARENT_OBJ}->set_peer(::{PARENT_OBJ}->id(), 0.5f);" + in _apply_definition(mock_cg) + ) + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields, {"level": 0.5}, call="make_call") + assert f"apply_call.set_peer(::{PARENT_OBJ}->id(), 0.5f);" in _apply_definition( + mock_cg + ) + mock_cg.new_pvariable.reset_mock() + await _run_apply_condition(registries, "is_peer({parent}->id())", {}) + assert ( + f"return ::{PARENT_OBJ}->is_peer(::{PARENT_OBJ}->id());" + in _apply_definition(mock_cg) + ) + + @pytest.mark.asyncio async def test_apply_field_nested_key_const_fn_and_type_string( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -979,8 +1003,10 @@ async def test_apply_field_nested_key_const_fn_and_type_string( def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None: with pytest.raises(ValueError, match="2 placeholder"): ApplyCall("set_range({}, {})", (("low", cg.float_),)) - with pytest.raises(ValueError, match="only bare"): - ApplyCall("if ({}) {parent}->reset()", (("reset", cg.bool_),)) + with pytest.raises(ValueError, match="only {} and {parent}"): + ApplyCall("if ({}) {other}->reset()", (("reset", cg.bool_),)) + assert ApplyCall("if ({}) {parent}->reset()", (("reset", cg.bool_),)).names_parent + assert not ApplyCall("set_flags({{parent}})").names_parent ApplyCall("set_flags({{{}}})", (("flags", cg.int_),)) with pytest.raises(ValueError, match="each arg is"): ApplyCall("set_kp({})", (("kp", cg.float_, None, "extra"),)) @@ -1242,12 +1268,57 @@ async def test_trigger_callback_reshapes_and_filters(mock_cg: MockCodegen) -> No @pytest.mark.asyncio async def test_trigger_callback_filter_has_no_parent(mock_cg: MockCodegen) -> None: - """A filter type that names {parent} is rejected up front, a trigger callback has none.""" - when = ApplyCall("mode == {}", (("mode", "{parent}::Mode"),)) - with pytest.raises(ValueError, match="names {parent}"): + """A filter that names {parent} is rejected up front when no parent is given.""" + for when in ( + ApplyCall("mode == {}", (("mode", "{parent}::Mode"),)), + "{parent}->is_failed() == false", + ): + with pytest.raises(ValueError, match="names {parent}"): + await build_trigger_callback( + [], + {**TRIGGER_CONF, "mode": 1}, + [(cg.int_, "mode")], + forward=[], + when=when, + ) + + +@pytest.mark.asyncio +async def test_trigger_callback_filter_names_parent(mock_cg: MockCodegen) -> None: + """With the parent given, {parent} in a filter is the global-scoped parent.""" + text = _squash( await build_trigger_callback( - [], {**TRIGGER_CONF, "mode": 1}, [(cg.int_, "mode")], forward=[], when=when + [], + TRIGGER_CONF, + [(cg.int_, "state")], + forward=[], + when="state == 1 && {parent}->is_failed() == false", + parent=MockObj("improv", "->"), ) + ) + assert ( + "if (!(state == 1 && ::improv->is_failed() == false)) return; " + f"::{NEW_OBJ}->trigger();" + ) in text + + +@pytest.mark.asyncio +async def test_build_callback_automations_when_names_parent( + mock_cg: MockCodegen, +) -> None: + """A table entry names the parent through {parent}, so it can live at module level.""" + entries = ( + CallbackAutomation( + "on_open", "add_on_state_callback", when="{parent}->is_fully_open()" + ), + ) + await build_callback_automations( + MockObj("valve", "->"), {"on_open": [TRIGGER_CONF]}, entries + ) + assert _squash(mock_cg.add.call_args.args[0]) == ( + "valve->add_on_state_callback([]() -> void { " + f"if (!(::valve->is_fully_open())) return; ::{NEW_OBJ}->trigger(); }})" + ) @pytest.mark.asyncio From 4d187a2bb70e832022eb93c531c29bec532003a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:43:22 -1000 Subject: [PATCH 175/435] [valve] Build on_open and on_closed with build_callback_automation (#20442) --- esphome/components/valve/__init__.py | 39 +++++++------------ esphome/components/valve/automation.h | 37 ------------------ tests/components/valve/common-triggers.yaml | 9 +++++ .../valve/test-triggers.esp8266-ard.yaml | 3 ++ 4 files changed, 25 insertions(+), 63 deletions(-) delete mode 100644 esphome/components/valve/automation.h create mode 100644 tests/components/valve/common-triggers.yaml create mode 100644 tests/components/valve/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index b000f6dd3f2..41e25d63b15 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_POSITION_STATE_TOPIC, CONF_STATE, CONF_STOP, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_GAS, @@ -62,17 +61,18 @@ VALVE_OPERATIONS = { } validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) -# Actions -ValvePublishAction = valve_ns.class_("ValvePublishAction", automation.Action) - -# Triggers -ValveOpenTrigger = valve_ns.class_("ValveOpenTrigger", automation.Trigger.template()) -ValveClosedTrigger = valve_ns.class_( - "ValveClosedTrigger", automation.Trigger.template() -) - CONF_ON_CLOSED = "on_closed" +# The state callback carries nothing; each automation fires only in its end state. +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN, "add_on_state_callback", when="{parent}->is_fully_open()" + ), + automation.CallbackAutomation( + CONF_ON_CLOSED, "add_on_state_callback", when="{parent}->is_fully_closed()" + ), +) + _VALVE_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -89,16 +89,8 @@ _VALVE_SCHEMA = ( cv.Optional(CONF_POSITION_STATE_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_OPEN): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValveOpenTrigger), - } - ), - cv.Optional(CONF_ON_CLOSED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValveClosedTrigger), - } - ), + cv.Optional(CONF_ON_OPEN): automation.validate_automation({}), + cv.Optional(CONF_ON_CLOSED): automation.validate_automation({}), } ) ) @@ -134,12 +126,7 @@ def valve_schema( async def _setup_valve_core(var, config): setup_device_class(config) - for conf in config.get(CONF_ON_OPEN, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_CLOSED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id_config := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id_config, var) diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h deleted file mode 100644 index 598bbc1678e..00000000000 --- a/esphome/components/valve/automation.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "valve.h" - -namespace esphome::valve { - -class ValveOpenTrigger final : public Trigger<> { - public: - ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { - a_valve->add_on_state_callback([this]() { - if (this->valve_->is_fully_open()) { - this->trigger(); - } - }); - } - - protected: - Valve *valve_; -}; - -class ValveClosedTrigger final : public Trigger<> { - public: - ValveClosedTrigger(Valve *a_valve) : valve_(a_valve) { - a_valve->add_on_state_callback([this]() { - if (this->valve_->is_fully_closed()) { - this->trigger(); - } - }); - } - - protected: - Valve *valve_; -}; - -} // namespace esphome::valve diff --git a/tests/components/valve/common-triggers.yaml b/tests/components/valve/common-triggers.yaml new file mode 100644 index 00000000000..452cc2eac89 --- /dev/null +++ b/tests/components/valve/common-triggers.yaml @@ -0,0 +1,9 @@ +valve: + - platform: template + name: Trigger Test Valve + id: trigger_test_valve + optimistic: true + on_open: + - logger.log: Valve fully open + on_closed: + - logger.log: Valve fully closed diff --git a/tests/components/valve/test-triggers.esp8266-ard.yaml b/tests/components/valve/test-triggers.esp8266-ard.yaml new file mode 100644 index 00000000000..63ca1042de9 --- /dev/null +++ b/tests/components/valve/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + valve: !include common.yaml + triggers: !include common-triggers.yaml From 8567e69d46fe043156a4f16385e6043da520d6f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:51:38 -1000 Subject: [PATCH 176/435] [core] Add status_momentary_warning/error overloads without a name (#20362) --- esphome/core/component.cpp | 28 ++++-- esphome/core/component.h | 14 +++ .../fixtures/status_momentary.yaml | 40 +++++++++ tests/integration/test_status_momentary.py | 87 +++++++++++++++++++ 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/status_momentary.yaml create mode 100644 tests/integration/test_status_momentary.py diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 0aa2dc21a24..edcd922aebd 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -292,14 +292,17 @@ void Component::status_set_warning(const LogString *message) { } void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const LogString *message) { - if (!this->set_status_flag_(STATUS_LED_ERROR)) - return; - ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), - message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); - if (message != nullptr) { + if (this->set_error_flag_(message) && message != nullptr) { store_component_error_message(this, message); } } +bool Component::set_error_flag_(const LogString *message) { + if (!this->set_status_flag_(STATUS_LED_ERROR)) + return false; + ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); + return true; +} void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished @@ -329,6 +332,21 @@ void Component::status_momentary_error(const char *name, uint32_t length) { this->status_set_error(); this->set_timeout(name, length, [this]() { this->status_clear_error(); }); } +void Component::status_momentary_warning(uint32_t length) { + this->status_momentary_warning(static_cast(nullptr), length); +} +void Component::status_momentary_warning(const LogString *message, uint32_t length) { + this->status_set_warning(message); + this->set_timeout(InternalSchedulerID::STATUS_WARNING, length, [this]() { this->status_clear_warning(); }); +} +void Component::status_momentary_error(uint32_t length) { + this->status_momentary_error(static_cast(nullptr), length); +} +void Component::status_momentary_error(const LogString *message, uint32_t length) { + // Not stored: a cleared momentary error must not show up later as a FAILED reason + this->set_error_flag_(message); + this->set_timeout(InternalSchedulerID::STATUS_ERROR, length, [this]() { this->status_clear_error(); }); +} void Component::dump_config() {} // Function implementation of LOG_UPDATE_INTERVAL macro to reduce code size diff --git a/esphome/core/component.h b/esphome/core/component.h index ecaf863ecfa..94ccec13f80 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -67,6 +67,8 @@ inline constexpr uint32_t SCHEDULER_DONT_RUN = 4294967295UL; /// with component-level NUMERIC_ID values, even if the uint32_t values overlap. enum class InternalSchedulerID : uint32_t { POLLING_UPDATE = 0, // PollingComponent interval + STATUS_WARNING = 1, // status_momentary_warning() without a name + STATUS_ERROR = 2, // status_momentary_error() without a name }; // Forward declaration @@ -307,6 +309,11 @@ class Component { */ void status_momentary_warning(const char *name, uint32_t length = 5000); + /// Set warning status flag and clear it after `length` ms. A new call restarts the timeout. + /// `message` is logged in place of "unspecified" when the flag gets set. + void status_momentary_warning(uint32_t length = 5000); + void status_momentary_warning(const LogString *message, uint32_t length = 5000); + /** Set error status flag and automatically clear it after a timeout. * * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). @@ -316,6 +323,11 @@ class Component { */ void status_momentary_error(const char *name, uint32_t length = 5000); + /// Set error status flag and clear it after `length` ms. A new call restarts the timeout. + /// `message` is logged in place of "unspecified"; unlike status_set_error() it is not stored. + void status_momentary_error(uint32_t length = 5000); + void status_momentary_error(const LogString *message, uint32_t length = 5000); + bool has_overridden_loop() const { return (this->component_state_ & COMPONENT_HAS_LOOP) != 0; } /** Get the integration where this component was declared as a LogString for logging. @@ -356,6 +368,8 @@ class Component { /// Note: Callers often use the return value to decide whether to log a warning/error, /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); + /// Set the error flag and log it; true when it was not set before. + bool set_error_flag_(const LogString *message); /** Set an interval function with a const char* name. Empty name means no cancelling possible. * diff --git a/tests/integration/fixtures/status_momentary.yaml b/tests/integration/fixtures/status_momentary.yaml new file mode 100644 index 00000000000..424a73ce3b4 --- /dev/null +++ b/tests/integration/fixtures/status_momentary.yaml @@ -0,0 +1,40 @@ +esphome: + name: status-momentary-test + +host: +api: + actions: + - action: momentary_warning + variables: + length: int + then: + - lambda: "id(probe)->status_momentary_warning(length);" + - action: momentary_error + variables: + length: int + then: + - lambda: "id(probe)->status_momentary_error(length);" + - action: momentary_warning_message + then: + - lambda: 'id(probe)->status_momentary_warning(LOG_STR("probe warning reason"), 300);' + - action: momentary_error_message + then: + - lambda: 'id(probe)->status_momentary_error(LOG_STR("probe error reason"), 300);' + +logger: + +sensor: + - platform: template + id: probe + update_interval: 24h + lambda: return 1.0; + +binary_sensor: + - platform: template + name: Probe Warning + id: probe_warning + lambda: return id(probe)->status_has_warning(); + - platform: template + name: Probe Error + id: probe_error + lambda: return id(probe)->status_has_error(); diff --git a/tests/integration/test_status_momentary.py b/tests/integration/test_status_momentary.py new file mode 100644 index 00000000000..0bf3a820bed --- /dev/null +++ b/tests/integration/test_status_momentary.py @@ -0,0 +1,87 @@ +"""Integration test for status_momentary_warning/error without a name.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import BinarySensorState, EntityState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_status_momentary( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + loop = asyncio.get_running_loop() + warning_logged = loop.create_future() + error_logged = loop.create_future() + + def on_log_line(line: str) -> None: + if ( + "set Warning flag: probe warning reason" in line + and not warning_logged.done() + ): + warning_logged.set_result(True) + if "set Error flag: probe error reason" in line and not error_logged.done(): + error_logged.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + svc = {s.name: s for s in services} + keys = {e.object_id: e.key for e in entities} + warning_key = keys["probe_warning"] + error_key = keys["probe_error"] + + changes: asyncio.Queue[tuple[int, bool, float]] = asyncio.Queue() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState): + changes.put_nowait((state.key, state.state, loop.time())) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + assert initial_state_helper.initial_states[warning_key].state is False + assert initial_state_helper.initial_states[error_key].state is False + + async def next_change(key: int) -> tuple[bool, float]: + while True: + k, value, when = await asyncio.wait_for(changes.get(), timeout=5) + if k == key: + return value, when + + # The flag sets at once and clears after the length + for service, key in ( + ("momentary_warning", warning_key), + ("momentary_error", error_key), + ): + start = loop.time() + await client.execute_service(svc[service], {"length": 300}) + assert (await next_change(key))[0] is True + value, cleared = await next_change(key) + assert value is False + assert cleared - start >= 0.25 + + # A second call restarts the timeout instead of adding a second one + start = loop.time() + await client.execute_service(svc["momentary_warning"], {"length": 1000}) + assert (await next_change(warning_key))[0] is True + await asyncio.sleep(0.6) + await client.execute_service(svc["momentary_warning"], {"length": 1000}) + value, cleared = await next_change(warning_key) + assert value is False + assert cleared - start >= 1.4 + + # A message replaces "unspecified" in the log line + await client.execute_service(svc["momentary_warning_message"], {}) + await asyncio.wait_for(warning_logged, timeout=5) + await client.execute_service(svc["momentary_error_message"], {}) + await asyncio.wait_for(error_logged, timeout=5) From 640d677913de685169d0d4dc0def96d14dd35a3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:51:55 -1000 Subject: [PATCH 177/435] [st7735][st7789v][waveshare_epaper] Declare log tags with ESPHOME_LOG_TAG (#20295) --- esphome/components/st7735/st7735.cpp | 2 +- esphome/components/st7789v/st7789v.cpp | 2 +- esphome/components/waveshare_epaper/waveshare_213v3.cpp | 2 +- esphome/components/waveshare_epaper/waveshare_epaper.cpp | 2 +- script/ci-custom.py | 6 ------ 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome/components/st7735/st7735.cpp b/esphome/components/st7735/st7735.cpp index 7eb70c5c235..94b94d17a98 100644 --- a/esphome/components/st7735/st7735.cpp +++ b/esphome/components/st7735/st7735.cpp @@ -218,7 +218,7 @@ static constexpr uint8_t PROGMEM 100 }; // 100 ms delay // clang-format on -static const char *const TAG = "st7735"; +ESPHOME_LOG_TAG(TAG, "st7735"); ST7735::ST7735(ST7735Model model, int width, int height, int colstart, int rowstart, bool eightbitcolor, bool usebgr, bool invert_colors) diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index 2e07e24522c..343d196b507 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -4,7 +4,7 @@ namespace esphome::st7789v { -static const char *const TAG = "st7789v"; +ESPHOME_LOG_TAG(TAG, "st7789v"); #ifdef USE_ESP32 static constexpr size_t TEMP_BUFFER_SIZE = 1024; #else diff --git a/esphome/components/waveshare_epaper/waveshare_213v3.cpp b/esphome/components/waveshare_epaper/waveshare_213v3.cpp index cc9c34cb42f..5e19782f6d2 100644 --- a/esphome/components/waveshare_epaper/waveshare_213v3.cpp +++ b/esphome/components/waveshare_epaper/waveshare_213v3.cpp @@ -4,7 +4,7 @@ namespace esphome::waveshare_epaper { -static const char *const TAG = "waveshare_2.13v3"; +ESPHOME_LOG_TAG(TAG, "waveshare_2.13v3"); static const uint8_t PARTIAL_LUT[] = { 0x32, // cmd diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 93f23424c01..60d711d1cdf 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -7,7 +7,7 @@ namespace esphome::waveshare_epaper { -static const char *const TAG = "waveshare_epaper"; +ESPHOME_LOG_TAG(TAG, "waveshare_epaper"); static const uint8_t LUT_SIZE_WAVESHARE = 30; diff --git a/script/ci-custom.py b/script/ci-custom.py index 1b3abc40f2d..7839038a8c7 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1273,12 +1273,6 @@ def lint_no_std_nothrow(fname, match): r"\s*(?:=\s*\{?|[{(])\s*\"", prefilter="TAG", include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], - # Deprecated components, converted in a follow-up PR - exclude=[ - "esphome/components/st7735/*", - "esphome/components/st7789v/*", - "esphome/components/waveshare_epaper/*", - ], ) def lint_log_tag_macro(fname, match): return ( From 6b03d68fac862101393f3601b0182e43e7689ef2 Mon Sep 17 00:00:00 2001 From: Zhaoqi Xu Date: Sat, 10 Oct 2026 09:53:38 +0800 Subject: [PATCH 178/435] [heatpumpir] Fix fan speeds for mitsubishi_msc protocol (#20460) --- esphome/components/heatpumpir/heatpumpir.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index eb23eb8ed50..2376a67416f 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -187,15 +187,18 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd = HDIR_SWING; } + // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, + // so low/medium/high have to start at FAN_1 there instead of FAN_2. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC; switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: - fan_speed_cmd = FAN_2; + fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; break; case climate::CLIMATE_FAN_MEDIUM: - fan_speed_cmd = FAN_3; + fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3; break; case climate::CLIMATE_FAN_HIGH: - fan_speed_cmd = FAN_4; + fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4; break; case climate::CLIMATE_FAN_AUTO: default: From 41cc96bbb8cd83368093d0af89499bf4ae95d071 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:55:09 -1000 Subject: [PATCH 179/435] [light] Keep the effect list in a flash table (#20204) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/codegen.py | 1 + esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 11 +---- esphome/components/api/api_pb2.cpp | 8 +-- esphome/components/api/api_pb2.h | 2 +- esphome/components/api/api_pb2_includes.h | 1 + esphome/components/light/__init__.py | 16 +++++- esphome/components/light/light_call.cpp | 2 +- esphome/components/light/light_effect.h | 2 + esphome/components/light/light_effect_names.h | 44 +++++++++++++++++ esphome/components/light/light_state.cpp | 12 ----- esphome/components/light/light_state.h | 11 +++-- esphome/core/hal.h | 8 +++ esphome/cpp_generator.py | 49 ++++++++++++++++--- .../components/api/bench_list_entities.cpp | 18 +++++-- .../light/test_default_setters.py | 26 +++++++++- tests/components/light/common.yaml | 3 +- tests/components/light/test_light_state.cpp | 29 ----------- tests/unit_tests/test_cpp_generator.py | 42 +++++++++++++++- 19 files changed, 207 insertions(+), 80 deletions(-) create mode 100644 esphome/components/light/light_effect_names.h diff --git a/esphome/codegen.py b/esphome/codegen.py index d57ec39aab7..4db01a21740 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -34,6 +34,7 @@ from esphome.cpp_generator import ( # noqa: F401 extern_progmem_array, get_variable, get_variable_with_full_id, + is_static_pointer, is_template, new_Pvariable, new_variable, diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 76b82810a4b..d4ae82fd9f1 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -760,7 +760,7 @@ message ListEntitiesLightResponse { bool legacy_supports_color_temperature = 8 [deprecated=true]; float min_mireds = 9; float max_mireds = 10; - repeated string effects = 11 [(container_pointer_no_template) = "FixedVector"]; + repeated string effects = 11 [(container_pointer_no_template) = "light::LightEffectNames"]; bool disabled_by_default = 13; string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 15; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 940c8847edd..fd51aae1be8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -644,16 +644,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c msg.min_mireds = traits.get_min_mireds(); msg.max_mireds = traits.get_max_mireds(); } - FixedVector effects_list; - if (light->supports_effects()) { - auto &light_effects = light->get_effects(); - effects_list.init(light_effects.size() + 1); - effects_list.push_back("None"); - for (auto *effect : light_effects) { - // c_str() is safe as effect names are null-terminated strings from codegen - effects_list.push_back(effect->get_name().c_str()); - } - } + light::LightEffectNames effects_list(light->get_effects()); msg.effects = &effects_list; return fill_and_encode_entity_info(light, msg, conn, remaining_size); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b59663aeb51..8847249b173 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -688,8 +688,8 @@ uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, uint8_t *__rest if (uint32_t raw = float_to_raw(msg.max_mireds); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - for (const char *it : *msg.effects) { - pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it)); + for (const auto &it : *msg.effects) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it); } pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.disabled_by_default); #ifdef USE_ENTITY_ICON @@ -713,8 +713,8 @@ uint32_t ListEntitiesLightResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_float(1, msg.min_mireds); size += ProtoSize::calc_float(1, msg.max_mireds); if (!msg.effects->empty()) { - for (const char *it : *msg.effects) { - size += ProtoSize::calc_length_force(1, strlen(it)); + for (const auto &it : *msg.effects) { + size += ProtoSize::calc_length_force(1, it.size()); } } size += ProtoSize::calc_bool(1, msg.disabled_by_default); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 421c3fccb38..17436d33aab 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1059,7 +1059,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { const light::ColorModeMask *supported_color_modes{}; float min_mireds{0.0f}; float max_mireds{0.0f}; - const FixedVector *effects{}; + const light::LightEffectNames *effects{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index 833e6529e2a..e076f98fe3d 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -15,6 +15,7 @@ #endif #ifdef USE_LIGHT +#include "esphome/components/light/light_effect_names.h" #include "esphome/components/light/light_traits.h" #endif diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index b175620bbf4..c490300b58c 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -41,7 +41,7 @@ from esphome.core.entity_helpers import ( queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv from esphome.types import ConfigType @@ -71,6 +71,7 @@ from .types import ( # noqa: F401 ChannelColors, ColorMode, GammaTable, + LightEffect, LightOutput, LightState, LightStateRTCState, @@ -519,6 +520,17 @@ def validate_color_temperature_channels(value): return value +def _effects_table(effects: list[MockObj]) -> MockObj: + """Emit the flash table of a light's effect pointers; each must be a static address.""" + return cg.shared_progmem_array( + "light_effects", + LightEffect.operator("ptr"), + cg.ArrayInitializer(*effects), + share=False, + constexpr=False, + ) + + @setup_entity("light") async def setup_light_core_(light_var, config, output_var): # All 8 legacy restore_mode values, and the restore_state key, are just different @@ -586,7 +598,7 @@ async def setup_light_core_(light_var, config, output_var): EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) if effects: - cg.add(light_var.add_effects(effects)) + cg.add(light_var.add_effects(_effects_table(effects), len(effects))) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 5ad9215eedf..0970c513a29 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -147,7 +147,7 @@ void LightCall::perform() { // EFFECT StringRef effect_s; if (this->effect_ == 0u) { - effect_s = StringRef::from_lit("None"); + effect_s = EFFECT_NONE_REF; } else { effect_s = this->parent_->effects_[this->effect_ - 1]->get_name(); } diff --git a/esphome/components/light/light_effect.h b/esphome/components/light/light_effect.h index a89e3fec5a9..3fabcf5d51b 100644 --- a/esphome/components/light/light_effect.h +++ b/esphome/components/light/light_effect.h @@ -5,6 +5,8 @@ namespace esphome::light { +inline constexpr StringRef EFFECT_NONE_REF = StringRef::from_lit("None"); + class LightState; class LightEffect { diff --git a/esphome/components/light/light_effect_names.h b/esphome/components/light/light_effect_names.h new file mode 100644 index 00000000000..7ddf459b248 --- /dev/null +++ b/esphome/components/light/light_effect_names.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "light_effect.h" + +namespace esphome::light { + +/// Effect names as the native API lists them: "None" first, then each effect; empty without effects. +/// Reads the light's effect table directly, so listing entities allocates nothing. Holds a reference: +/// it only lives for one synchronous encode, and by value costs flash on every copy. +class LightEffectNames { + public: + explicit LightEffectNames(const ConstVector &effects) : effects_(effects) {} + + class Iterator { + public: + ESPHOME_ALWAYS_INLINE Iterator(const ConstVector &effects, size_t index) + : effects_(effects), index_(index) {} + ESPHOME_ALWAYS_INLINE StringRef operator*() const { + return this->index_ == 0 ? EFFECT_NONE_REF : this->effects_[this->index_ - 1]->get_name(); + } + ESPHOME_ALWAYS_INLINE Iterator &operator++() { + ++this->index_; + return *this; + } + ESPHOME_ALWAYS_INLINE bool operator!=(const Iterator &other) const { return this->index_ != other.index_; } + + protected: + const ConstVector &effects_; + size_t index_; + }; + + ESPHOME_ALWAYS_INLINE Iterator begin() const { return {this->effects_, 0}; } + ESPHOME_ALWAYS_INLINE Iterator end() const { + return {this->effects_, this->effects_.empty() ? 0 : this->effects_.size() + 1}; + } + ESPHOME_ALWAYS_INLINE bool empty() const { return this->effects_.empty(); } + + protected: + const ConstVector &effects_; +}; + +} // namespace esphome::light diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9fe483a291c..885440187f9 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -9,8 +9,6 @@ #include "light_output.h" #include "transformers.h" -#include - namespace esphome::light { ESPHOME_LOG_TAG(TAG, "light"); @@ -196,8 +194,6 @@ void LightState::publish_state() { LightOutput *LightState::get_output() const { return this->output_; } -static constexpr auto EFFECT_NONE_REF = StringRef::from_lit("None"); - StringRef LightState::get_effect_name() { if (this->active_effect_index_ > 0) { return this->effects_[this->active_effect_index_ - 1]->get_name(); @@ -218,11 +214,6 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen this->target_state_reached_listeners_->push_back(listener); } -void LightState::add_effects(const std::initializer_list &effects) { - // Called once from Python codegen during setup with all effects from YAML config - this->effects_ = effects; -} - void LightState::current_values_as_brightness(float *brightness) { this->current_values.as_brightness(brightness); *brightness = this->gamma_correct_lut(*brightness); @@ -352,9 +343,6 @@ float LightState::gamma_uncorrect_lut(float value) const { #endif // USE_LIGHT_GAMMA_LUT void LightState::start_effect_(uint32_t effect_index) { - // An external add_effects() can exceed the codegen cap; ignore an index the uint16_t can't hold - if (effect_index > std::numeric_limits::max()) - return; this->stop_effect_(); if (effect_index == 0) return; diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index fcb2ca40c90..2a99943e1df 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -217,10 +217,13 @@ class LightState : public EntityBase, public Component { bool supports_effects() const { return !this->effects_.empty(); } /// Get all effects for this light state. - const FixedVector &get_effects() const { return this->effects_; } + const ConstVector &get_effects() const { return this->effects_; } - /// Add effects for this light state. - void add_effects(const std::initializer_list &effects); + /// Codegen only: point at the generated table of this light's effects, which must outlive it. + /// Codegen caps the count at 65535, so an effect index always fits the uint16_t active index. + void add_effects(LightEffect *const *effects, size_t count) { + this->effects_ = ConstVector(effects, count); + } /// Get the total number of effects available for this light. size_t get_effect_count() const { return this->effects_.size(); } @@ -354,7 +357,7 @@ class LightState : public EntityBase, public Component { /// The currently active transformer for this light (transition/flash). std::unique_ptr transformer_{nullptr}; /// List of effects for this light. - FixedVector effects_; + ConstVector effects_; /// Object used to store the persisted values of the light. ESPPreferenceObject rtc_; diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4c5a19c6d1b..dd3618f6237 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -4,9 +4,17 @@ #include #include "gpio.h" #include "esphome/core/defines.h" + #include "esphome/core/time_64.h" #include "esphome/core/time_conversion.h" +/// Address tables in ESP8266 flash must be constant-initialized: a runtime initializer would write to flash. +#if defined(USE_ESP8266) && defined(__GNUC__) && !defined(__clang__) +#define ESPHOME_FLASH_CONSTINIT constinit +#else +#define ESPHOME_FLASH_CONSTINIT +#endif + // Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), // inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem // helpers) live next to each platform component as components//hal.h diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index f5113d8098e..9c9fed66012 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -10,6 +10,7 @@ from esphome.core import ( ID, Define, EnumValue, + EsphomeError, HexInt, Lambda, Library, @@ -447,13 +448,16 @@ class LineComment(Statement): class ProgmemAssignmentExpression(AssignmentExpression): - __slots__ = () + __slots__ = ("constexpr",) - def __init__(self, type_, name, rhs): + def __init__(self, type_, name, rhs, constexpr: bool = True): super().__init__(type_, "", name, rhs) + self.constexpr = constexpr def __str__(self): - return f"static constexpr {self.type} {self.name}[] PROGMEM = {self.rhs}" + if self.constexpr: + return f"static constexpr {self.type} {self.name}[] PROGMEM = {self.rhs}" + return f"ESPHOME_FLASH_CONSTINIT static {self.type} const {self.name}[] PROGMEM = {self.rhs}" class StaticConstAssignmentExpression(AssignmentExpression): @@ -493,20 +497,34 @@ def extern_progmem_array( def shared_progmem_array( - name: str, type_: "MockObjClass", rhs: SafeExpType, *, share: bool = True + name: str, + type_: "MockObjClass", + rhs: SafeExpType, + *, + share: bool = True, + constexpr: bool = True, ) -> "MockObj": """Emit a global PROGMEM array once per distinct type and contents; later calls reuse it. - The array is ``static constexpr``, so elements must be constant expressions and lambdas - must be captureless. Its name is made unique against every config id and variable. + The array is ``static constexpr`` by default, so elements must be constant expressions and + lambdas must be captureless. Its name is made unique against every config id and variable. ``share=False`` always emits a new array, e.g. for lambdas that may keep static state. + ``constexpr=False`` is for tables of generated object pointers: each top level variable + element must pass ``is_static_pointer`` or ``EsphomeError`` is raised. """ from esphome.config import iter_ids from esphome.config_validation import RESERVED_IDS arrays: dict[str, MockObj] = CORE.data.setdefault("shared_progmem_array", {}) rhs = safe_exp(rhs) - key = f"{type_} {rhs}" + if not constexpr and isinstance(rhs, ArrayInitializer): + for arg in rhs.args: + if isinstance(arg, MockObj) and not is_static_pointer(arg): + raise EsphomeError( + f"'{arg}' must be created with cg.new_Pvariable so its address is known " + "at compile time" + ) + key = f"{type_} {rhs} {constexpr}" if share and (array := arrays.get(key)) is not None: return array used = {str(i) for i, _ in iter_ids(CORE.config)} @@ -514,7 +532,7 @@ def shared_progmem_array( used |= set(RESERVED_IDS) | CORE.loaded_integrations id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) # Global, so any scope can use it; anything a lambda references is already declared. - CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs)) + CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs, constexpr)) array = MockObj(id_, ".") CORE.register_variable(id_, array) if share: @@ -703,6 +721,7 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": ) placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args) CORE.add(ExpressionStatement(placement_new)) + CORE.data.setdefault(_STATIC_POINTER_IDS, set()).add(id_.id) else: decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) CORE.add_global(decl) @@ -712,6 +731,20 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": return obj +_STATIC_POINTER_IDS = "static_pointer_ids" + + +def is_static_pointer(obj: SafeExpType) -> bool: + """True if ``obj`` is a Pvariable whose object was placement constructed in static storage. + + Its pointer is then an address known at compile time and may appear in a ``constexpr=False`` + PROGMEM table; a pointer assigned in ``setup()`` may not. + """ + return isinstance(obj, MockObj) and str(obj.base) in CORE.data.get( + _STATIC_POINTER_IDS, () + ) + + def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj": """Declare a new pointer variable in the code generation by calling it's constructor with the given arguments. diff --git a/tests/benchmarks/components/api/bench_list_entities.cpp b/tests/benchmarks/components/api/bench_list_entities.cpp index 4c445c2bb68..8a4cac1e2ef 100644 --- a/tests/benchmarks/components/api/bench_list_entities.cpp +++ b/tests/benchmarks/components/api/bench_list_entities.cpp @@ -3,6 +3,8 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_buffer.h" #include "esphome/components/light/color_mode.h" +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_effect_names.h" namespace esphome::api::benchmarks { @@ -150,7 +152,17 @@ BENCHMARK(CalcAndEncode_ListEntitiesBinarySensorResponse); // --- ListEntitiesLightResponse --- static light::ColorModeMask light_color_modes; -static FixedVector light_effects; + +class BenchEffect : public light::LightEffect { + public: + explicit BenchEffect(const char *name) : LightEffect(name) {} + void apply() override {} +}; +static BenchEffect rainbow_effect("Rainbow"); +static BenchEffect strobe_effect("Strobe"); +static light::LightEffect *const LIGHT_EFFECT_TABLE[] = {&rainbow_effect, &strobe_effect}; +static const ConstVector light_effect_list(LIGHT_EFFECT_TABLE, 2); +static const light::LightEffectNames light_effects(light_effect_list); static ListEntitiesLightResponse make_light_response() { // Initialize static data on first call @@ -158,10 +170,6 @@ static ListEntitiesLightResponse make_light_response() { if (!initialized) { light_color_modes.insert(light::ColorMode::RGB_WHITE); light_color_modes.insert(light::ColorMode::COLOR_TEMPERATURE); - light_effects.init(3); - light_effects.push_back("None"); - light_effects.push_back("Rainbow"); - light_effects.push_back("Strobe"); initialized = true; } diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py index a4fc24a7cbf..cbbecb632f8 100644 --- a/tests/component_tests/light/test_default_setters.py +++ b/tests/component_tests/light/test_default_setters.py @@ -2,6 +2,14 @@ from collections.abc import Callable from pathlib import Path +import re + +import pytest + +import esphome.codegen as cg +from esphome.components import light +from esphome.components.light import types as light_types +from esphome.core import ID, EsphomeError def test_default_flash_length_and_empty_effects_are_not_emitted( @@ -16,4 +24,20 @@ def test_default_flash_length_and_empty_effects_are_not_emitted( assert "bare_light->set_flash_transition_length(" not in main_cpp assert "bare_light->add_effects(" not in main_cpp assert "fancy_light->set_flash_transition_length(500);" in main_cpp - assert "fancy_light->add_effects({" in main_cpp + call = re.search(r"fancy_light->add_effects\((\w+), (\d+)\);", main_cpp) + assert call is not None + # The effect pointers form a flash table; they are address constants, not constexpr + assert re.search( + rf"static light::LightEffect \* const {call.group(1)}\[\] PROGMEM = \{{[^}}]+\}};", + main_cpp, + ) + + +def test_effect_assigned_in_setup_is_rejected() -> None: + """A pointer assigned in setup() would make the flash table need dynamic init.""" + dynamic_effect = cg.Pvariable( + ID("dynamic_effect", is_declaration=True, type=light_types.LightEffect), + cg.RawExpression("make_effect()"), + ) + with pytest.raises(EsphomeError, match="dynamic_effect"): + light._effects_table([dynamic_effect]) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 64406afda86..c1e58c673f6 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -32,6 +32,7 @@ esphome: - lambda: |- const auto &effects = id(test_monochromatic_light).get_effects(); auto &same = id(test_monochromatic_light).get_effects(); + const auto copy = id(test_monochromatic_light).get_effects(); // the view is copyable uint32_t total = effects.size(); for (auto *effect : effects) { ESP_LOGD("test", "Effect %s", effect->get_name().c_str()); @@ -39,7 +40,7 @@ esphome: if (total > 0) { ESP_LOGD("test", "First %s", effects.at(0)->get_name().c_str()); // Raw pointer iterators on purpose: external code binds std::find's result to `const auto *` - auto *it = std::find(same.begin(), same.end(), effects[0]); + auto *it = std::find(same.begin(), same.end(), copy[0]); ESP_LOGD("test", "Index %d", (int) (it - same.begin())); } diff --git a/tests/components/light/test_light_state.cpp b/tests/components/light/test_light_state.cpp index 85bfbd09ef5..076438c7d2c 100644 --- a/tests/components/light/test_light_state.cpp +++ b/tests/components/light/test_light_state.cpp @@ -1,6 +1,5 @@ #include -#include "esphome/components/light/light_effect.h" #include "esphome/components/light/light_output.h" #include "esphome/components/light/light_state.h" @@ -18,36 +17,8 @@ class BrightnessOutput : public LightOutput { void write_state(LightState *state) override {} }; -class NoopEffect : public LightEffect { - public: - using LightEffect::LightEffect; - void apply() override {} -}; - -// start_effect_() is where the uint32_t index is narrowed to the stored uint16_t. -class TestableLightState : public LightState { - public: - using LightState::LightState; - using LightState::start_effect_; -}; - } // namespace -// add_effects() is public, so an external component can exceed the codegen cap on effect count; -// an index the uint16_t can't hold must be ignored rather than wrap onto another effect. -TEST(LightStateEffect, IndexAboveUint16IsIgnoredAndKeepsTheActiveEffect) { - BrightnessOutput output; - TestableLightState state(&output); - NoopEffect effect("Noop"); - state.add_effects({&effect}); - - state.start_effect_(1); - ASSERT_EQ(state.get_current_effect_index(), 1u); - - state.start_effect_(0x10000u); // unchecked narrowing wraps this to 0, which stops the effect - EXPECT_EQ(state.get_current_effect_index(), 1u); -} - // get_gamma_correct() reads the gamma codegen stores after the lookup table, rounded to two decimals. TEST(LightStateGamma, ReadsTheGammaStoredWithTheTable) { static constexpr GammaTable TABLE{{}, 280}; diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index b6f8fd1ec10..6fdd197c952 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -4,7 +4,7 @@ import math import pytest from esphome import cpp_generator as cg, cpp_types as ct -from esphome.core import CORE, ID +from esphome.core import CORE, ID, EsphomeError class TestExpressions: @@ -829,6 +829,46 @@ class TestSharedProgmemArray: assert str(a) != str(b) assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2 + def test_is_static_pointer_only_for_placement_new(self) -> None: + CORE.config = {} + static = cg.new_Pvariable(ID("static_obj", is_declaration=True, type=ct.uint8)) + dynamic = cg.Pvariable( + ID("dynamic_obj", is_declaration=True, type=ct.uint8), + cg.RawExpression("nullptr"), + ) + assert cg.is_static_pointer(static) + assert not cg.is_static_pointer(dynamic) + + def test_constexpr_and_const_arrays_are_not_shared(self) -> None: + CORE.config = {} + a = cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False) + b = cg.shared_progmem_array("table", ct.uint8, [1]) + assert str(a) != str(b) + + def test_constexpr_false_rejects_dynamic_pointers(self) -> None: + CORE.config = {} + dynamic = cg.Pvariable( + ID("dynamic_obj", is_declaration=True, type=ct.uint8), + cg.RawExpression("nullptr"), + ) + with pytest.raises(EsphomeError, match="dynamic_obj"): + cg.shared_progmem_array( + "table", + ct.uint8.operator("ptr"), + cg.ArrayInitializer(dynamic), + constexpr=False, + ) + + def test_constexpr_false_emits_a_const_array(self) -> None: + CORE.config = {} + cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False) + (statement,) = ( + str(st) for st in CORE.global_statements if "PROGMEM" in str(st) + ) + assert statement.startswith( + "ESPHOME_FLASH_CONSTINIT static uint8_t const table[] PROGMEM = {1}" + ) + def test_same_contents_different_type_are_separate(self) -> None: CORE.config = {} a = cg.shared_progmem_array("table", ct.uint8, [1]) From 0ae24f75368ba90e2528b4e29d6a548644edcfae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:00:41 -1000 Subject: [PATCH 180/435] [core] Catch heap allocating helper calls with explicit template arguments (#20391) --- esphome/components/debug/debug_zephyr.cpp | 18 +++++++++--------- script/ci-custom.py | 3 ++- tests/script/test_ci_custom.py | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index cec2ab0be8b..f3a5f3d770d 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -1,5 +1,6 @@ #include "debug_component.h" #ifdef USE_ZEPHYR +#include #include #include "esphome/core/alloc_helpers.h" #include "esphome/core/log.h" @@ -400,18 +401,17 @@ size_t DebugComponent::get_device_info_(std::span #endif } #endif - auto uicr = [](volatile uint32_t *data, uint8_t size) { - std::string res; + // 13 words of 8 hex digits, separated by spaces + char uicr_buf[13 * 9]; + auto uicr = [&uicr_buf](volatile uint32_t *data, uint8_t size) { + size_t len = 0; for (size_t i = 0; i < size; i++) { - if (i > 0) { - res += ' '; - } - res += format_hex_pretty(data[i], '\0', false); + len = buf_append_printf(uicr_buf, sizeof(uicr_buf), len, i > 0 ? " %08" PRIX32 : "%08" PRIX32, data[i]); } - return res; + return uicr_buf; }; - ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13).c_str()); - ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12).c_str()); + ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13)); + ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12)); #ifdef ESPHOME_LOG_HAS_VERBOSE log_peripherals_info(); #endif diff --git a/script/ci-custom.py b/script/ci-custom.py index 7839038a8c7..8361387af01 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1065,7 +1065,8 @@ HEAP_ALLOCATING_HELPERS = { r"str_sprintf|" r"str_snprintf|" r"value_accuracy_to_string" - r")\s*\(" + CPP_RE_EOL, + # Explicit template arguments at any nesting depth, e.g. format_hex>( + r")\s*(?:<[^;{}()\n]*>\s*)?\(" + CPP_RE_EOL, include=cpp_include, exclude=[ # The definitions themselves diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 998650079e2..f303d19bb0d 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -444,6 +444,28 @@ def test_log_tag_as_string_ignores_logging(line: str) -> None: assert not ci_custom.lint_log_tag_as_string("test.cpp", line + "\n") +@pytest.mark.parametrize( + ("content", "flagged"), + [ + ("auto s = format_hex_pretty(data, len);\n", True), + # Explicit template arguments must not hide the call + ("auto s = format_hex_pretty(v, '\\0', false);\n", True), + ("auto s = format_hex>(arr);\n", True), + ( + "auto s = format_hex, uint64_t>>(v);\n", + True, + ), + ("auto s = format_hex < 3;\n", False), + ("format_hex_pretty_to(buf, sizeof(buf), data, len);\n", False), + ], +) +def test_heap_allocating_helpers_detection(content: str, flagged: bool) -> None: + errs = ci_custom.lint_no_heap_allocating_helpers( + Path("esphome/components/x/x.cpp"), content + ) + assert bool(errs) is flagged + + # --- rule: every component __init__.py defines DOMAIN as its own name --- From f7b9646705170ecdffb429a550948f24835513a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:06:14 -1000 Subject: [PATCH 181/435] [improv_ble] Build the state triggers with build_callback_automation (#20444) --- esphome/components/improv_ble/__init__.py | 117 ++++++------------ esphome/components/improv_ble/automation.h | 86 ------------- .../improv_ble/test_improv_ble.py | 18 +-- .../improv_ble/test-triggers.esp32-idf.yaml | 21 ++++ 4 files changed, 68 insertions(+), 174 deletions(-) delete mode 100644 esphome/components/improv_ble/automation.h create mode 100644 tests/components/improv_ble/test-triggers.esp32-idf.yaml diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index 06e73803f19..9dfc700f748 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -2,13 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor, improv_base, output import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_ON_START, - CONF_ON_STATE, - CONF_TRIGGER_ID, - PLATFORM_ESP32, -) +from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, PLATFORM_ESP32 from esphome.core import CORE from esphome.types import ConfigType @@ -59,20 +53,35 @@ State = improv_ns.enum("State") improv_ble_ns = cg.esphome_ns.namespace("improv_ble") ImprovBLEComponent = improv_ble_ns.class_("ImprovBLEComponent", cg.Component) -ImprovBLEProvisionedTrigger = improv_ble_ns.class_( - "ImprovBLEProvisionedTrigger", automation.Trigger.template() -) -ImprovBLEProvisioningTrigger = improv_ble_ns.class_( - "ImprovBLEProvisioningTrigger", automation.Trigger.template() -) -ImprovBLEStartTrigger = improv_ble_ns.class_( - "ImprovBLEStartTrigger", automation.Trigger.template() -) -ImprovBLEStateTrigger = improv_ble_ns.class_( - "ImprovBLEStateTrigger", automation.Trigger.template() -) -ImprovBLEStoppedTrigger = improv_ble_ns.class_( - "ImprovBLEStoppedTrigger", automation.Trigger.template() + +_STATE_PARAMS = [(State, "state"), (Error, "error")] +_NOT_FAILED = "!{parent}->is_failed()" + + +def _state_automation( + conf_key: str, args: list, states: str | None +) -> automation.CallbackAutomation: + # Every automation hangs off the state callback and is silent while the component has failed. + return automation.CallbackAutomation( + conf_key, + "add_on_state_callback", + args, + params=_STATE_PARAMS, + forward=[name for _, name in args], + when=_NOT_FAILED if states is None else f"({states}) && {_NOT_FAILED}", + ) + + +_CALLBACK_AUTOMATIONS = ( + _state_automation(CONF_ON_PROVISIONED, [], "state == improv::STATE_PROVISIONED"), + _state_automation(CONF_ON_PROVISIONING, [], "state == improv::STATE_PROVISIONING"), + _state_automation( + CONF_ON_START, + [], + "state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION", + ), + _state_automation(CONF_ON_STATE, _STATE_PARAMS, None), + _state_automation(CONF_ON_STOP, [], "state == improv::STATE_STOPPED"), ) @@ -93,41 +102,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_WIFI_TIMEOUT, default=DEFAULT_WIFI_TIMEOUT ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEProvisionedTrigger - ), - } - ), - cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEProvisioningTrigger - ), - } - ), - cv.Optional(CONF_ON_START): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStartTrigger - ), - } - ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStateTrigger - ), - } - ), - cv.Optional(CONF_ON_STOP): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStoppedTrigger - ), - } - ), + cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation({}), + cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation({}), + cv.Optional(CONF_ON_START): automation.validate_automation({}), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_STOP): automation.validate_automation({}), } ) .extend(improv_base.IMPROV_SCHEMA) @@ -164,28 +143,6 @@ async def to_code(config: ConfigType) -> None: status_indicator = await cg.get_variable(config[CONF_STATUS_INDICATOR]) cg.add(var.set_status_indicator(status_indicator)) - use_state_callback = False - for conf in config.get(CONF_ON_PROVISIONED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_PROVISIONING, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_START, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(State, "state"), (Error, "error")], conf - ) - use_state_callback = True - for conf in config.get(CONF_ON_STOP, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - if use_state_callback: + if any(config.get(entry.conf_key) for entry in _CALLBACK_AUTOMATIONS): cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/improv_ble/automation.h b/esphome/components/improv_ble/automation.h deleted file mode 100644 index 223a1292384..00000000000 --- a/esphome/components/improv_ble/automation.h +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once -#ifdef USE_ESP32 -#ifdef USE_IMPROV_BLE_STATE_CALLBACK -#include "improv_ble_component.h" - -#include "esphome/core/automation.h" - -#include - -namespace esphome::improv_ble { - -class ImprovBLEProvisionedTrigger final : public Trigger<> { - public: - explicit ImprovBLEProvisionedTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (state == improv::STATE_PROVISIONED && !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEProvisioningTrigger final : public Trigger<> { - public: - explicit ImprovBLEProvisioningTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (state == improv::STATE_PROVISIONING && !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStartTrigger final : public Trigger<> { - public: - explicit ImprovBLEStartTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if ((state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION) && - !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStateTrigger final : public Trigger { - public: - explicit ImprovBLEStateTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (!this->parent_->is_failed()) { - this->trigger(state, error); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStoppedTrigger final : public Trigger<> { - public: - explicit ImprovBLEStoppedTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (state == improv::STATE_STOPPED && !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -} // namespace esphome::improv_ble - -#endif -#endif diff --git a/tests/component_tests/improv_ble/test_improv_ble.py b/tests/component_tests/improv_ble/test_improv_ble.py index 02293bdb23b..d7df3869dc3 100644 --- a/tests/component_tests/improv_ble/test_improv_ble.py +++ b/tests/component_tests/improv_ble/test_improv_ble.py @@ -42,18 +42,20 @@ def test_platform_without_ble_server_rejected( assert "only available on" in capsys.readouterr().out -def test_automations_emit_renamed_triggers( +def test_automations_register_state_callbacks( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: main_cpp = generate_main(component_config_path("automations.yaml")) - for trigger in ( - "ImprovBLEProvisionedTrigger", - "ImprovBLEProvisioningTrigger", - "ImprovBLEStartTrigger", - "ImprovBLEStateTrigger", - "ImprovBLEStoppedTrigger", + assert "Trigger" not in main_cpp + for state in ( + "improv::STATE_PROVISIONED", + "improv::STATE_PROVISIONING", + "improv::STATE_AUTHORIZED", + "improv::STATE_STOPPED", ): - assert f"improv_ble::{trigger}" in main_cpp + assert f"state == {state}" in main_cpp + assert main_cpp.count("add_on_state_callback(") == 5 + assert "->is_failed()" in main_cpp assert "set_authorizer" in main_cpp assert "set_status_indicator" in main_cpp diff --git a/tests/components/improv_ble/test-triggers.esp32-idf.yaml b/tests/components/improv_ble/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..ceeb515ef35 --- /dev/null +++ b/tests/components/improv_ble/test-triggers.esp32-idf.yaml @@ -0,0 +1,21 @@ +wifi: + ssid: MySSID + password: password1 + +binary_sensor: + - platform: gpio + pin: 0 + id: improv_trigger_authorizer + +improv_ble: + authorizer: improv_trigger_authorizer + on_start: + - logger.log: Improv started + on_provisioning: + - logger.log: Provisioning + on_provisioned: + - logger.log: Provisioned + on_stop: + - logger.log: Improv stopped + on_state: + - lambda: ESP_LOGD("improv", "State %d error %d", (int) state, (int) error); From dc553aae55c191d1b82265bda2f8de9cb4641c32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:27:19 -1000 Subject: [PATCH 182/435] [core] Use status_momentary_warning/error without a name in components (#20363) --- esphome/components/cs5460a/cs5460a.cpp | 24 +++++++++---------- esphome/components/debug/debug_component.cpp | 2 +- .../demo/demo_alarm_control_panel.h | 4 ++-- esphome/components/es8388/es8388.cpp | 4 ++-- .../components/esphome/ota/ota_esphome.cpp | 2 +- .../components/espnow/espnow_component.cpp | 10 ++++---- .../http_request/http_request_arduino.cpp | 8 +++---- .../http_request/http_request_host.cpp | 6 ++--- .../http_request/http_request_idf.cpp | 16 ++++++------- .../microphone/i2s_audio_microphone.cpp | 4 ++-- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 4 ++-- .../micro_wake_word/micro_wake_word.cpp | 4 ++-- .../mixer/speaker/mixer_speaker.cpp | 2 +- esphome/components/mqtt/mqtt_button.cpp | 2 +- esphome/components/mqtt/mqtt_client.cpp | 6 ++--- esphome/components/mqtt/mqtt_fan.cpp | 10 ++++---- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- .../components/sound_level/sound_level.cpp | 4 ++-- esphome/components/vl53l0x/vl53l0x_sensor.cpp | 2 +- 21 files changed, 60 insertions(+), 60 deletions(-) diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index efc0cc9fa1f..7c4e4847911 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -148,7 +148,7 @@ void CS5460AComponent::schedule_next_check_() { */ if (time_left > -15000) { time_left = 1000; - this->status_momentary_warning("warning", 1000); + this->status_momentary_warning(1000); } else { ESP_LOGCONFIG(TAG, "Device officially stuck, resetting"); this->cancel_timeout(STATUS_CHECK_TIMEOUT_ID); @@ -179,67 +179,67 @@ bool CS5460AComponent::check_status_() { if (status & (1 << 2)) { clear |= 1 << 2; ESP_LOGE(TAG, "Low supply detected"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 3)) { clear |= 1 << 3; ESP_LOGE(TAG, "Modulator oscillation on current channel"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 4)) { clear |= 1 << 4; ESP_LOGE(TAG, "Modulator oscillation on voltage channel"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 5)) { clear |= 1 << 5; ESP_LOGE(TAG, "Watch-dog timeout"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 11)) { clear |= 1 << 11; ESP_LOGE(TAG, "EOUT Energy Accumulation Register out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 12)) { clear |= 1 << 12; ESP_LOGE(TAG, "Energy out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 13)) { clear |= 1 << 13; ESP_LOGE(TAG, "RMS voltage out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 14)) { clear |= 1 << 14; ESP_LOGE(TAG, "RMS current out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 15)) { clear |= 1 << 15; ESP_LOGE(TAG, "Power calculation out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 16)) { clear |= 1 << 16; ESP_LOGE(TAG, "Voltage out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 17)) { clear |= 1 << 17; ESP_LOGE(TAG, "Current out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 19)) { diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 9c7622d1161..97f8550c5bc 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -58,7 +58,7 @@ void DebugComponent::loop() { if (new_free_heap < this->free_heap_ / 2) { this->free_heap_ = new_free_heap; ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); - this->status_momentary_warning("heap", 1000); + this->status_momentary_warning(1000); } #ifdef USE_SENSOR diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index e85d2a17ba6..79cfc8f8ca4 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -33,7 +33,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { case ACP_STATE_ARMED_AWAY: if (this->get_requires_code_to_arm()) { if (!code.has_value() || *code != "1234") { - this->status_momentary_error("invalid_code", 5000); + this->status_momentary_error(5000); return; } } @@ -42,7 +42,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { case ACP_STATE_DISARMED: if (this->get_requires_code()) { if (!code.has_value() || *code != "1234") { - this->status_momentary_error("invalid_code", 5000); + this->status_momentary_error(5000); return; } } diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 040fd7fe260..b57949ccd0a 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -231,7 +231,7 @@ bool ES8388::set_dac_output(DacOutputLine line) { optional ES8388::get_dac_power() { uint8_t dac_power; if (!this->read_byte(ES8388_DACPOWER, &dac_power)) { - this->status_momentary_warning("dacpower_read"); + this->status_momentary_warning(); return {}; } switch (dac_power) { @@ -274,7 +274,7 @@ bool ES8388::set_adc_input_mic(AdcInputMicLine line) { optional ES8388::get_mic_input() { uint8_t mic_input; if (!this->read_byte(ES8388_ADCCONTROL2, &mic_input)) { - this->status_momentary_warning("adccontrol2_read"); + this->status_momentary_warning(); return {}; } switch (mic_input) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 318e1e5282e..e6b19c42f0c 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -635,7 +635,7 @@ error: this->cleanup_connection_(); - this->status_momentary_error("err", 5000); + this->status_momentary_error(5000); #ifdef USE_OTA_STATE_LISTENER this->notify_state_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 3fd468764af..4d888a9f373 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -406,7 +406,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl if (packet == nullptr) { this->send_packet_queue_.increment_dropped_count(); ESP_LOGE(TAG, "Failed to allocate send packet from pool"); - this->status_momentary_warning("send-packet-pool-full"); + this->status_momentary_warning(); return ESP_ERR_ESPNOW_NO_MEM; } // Load the packet data @@ -434,7 +434,7 @@ void ESPNowComponent::send_() { if (packet->callback_ != nullptr) { packet->callback_(err); } - this->status_momentary_warning("send-failed"); + this->status_momentary_warning(); this->send_packet_pool_.release(packet); this->current_send_packet_ = nullptr; // Reset current packet return; @@ -447,7 +447,7 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { } if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { - this->status_momentary_warning("peer-add-failed"); + this->status_momentary_warning(); return ESP_ERR_INVALID_MAC; } @@ -462,7 +462,7 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); ESP_LOGE(TAG, "Failed to add peer %s - %s", peer_buf, LOG_STR_ARG(espnow_error_to_str(err))); - this->status_momentary_warning("peer-add-failed"); + this->status_momentary_warning(); return err; } } @@ -492,7 +492,7 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); ESP_LOGE(TAG, "Failed to delete peer %s - %s", peer_buf, LOG_STR_ARG(espnow_error_to_str(err))); - this->status_momentary_warning("peer-del-failed"); + this->status_momentary_warning(); return err; } } diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index b7565e676d5..deb9c0fec25 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -28,7 +28,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -85,7 +85,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons if (!status) { ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); return nullptr; } @@ -143,13 +143,13 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); container->end(); return nullptr; } if (!is_success(container->status_code)) { ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index c32881dc1f6..0130445acdc 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -22,7 +22,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -101,7 +101,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c if (!result) { ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); return nullptr; } App.feed_wdt(); @@ -109,7 +109,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c container->status_code = response.status; if (!is_success(response.status)) { ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 1413b843b74..e429df9b971 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -54,7 +54,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -71,7 +71,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Unsupported method"); return nullptr; } @@ -110,7 +110,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch esp_http_client_handle_t client = esp_http_client_init(&config); if (client == nullptr) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; client could not be initialized"); return nullptr; } @@ -131,7 +131,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch esp_err_t err = esp_http_client_open(client, body_len); if (err != ESP_OK) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -154,7 +154,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } if (err != ESP_OK) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -179,7 +179,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch err = esp_http_client_set_redirection(client); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -192,7 +192,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch err = esp_http_client_open(client, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -220,7 +220,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); return container; } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 79367d67e08..1345f0187b3 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -397,7 +397,7 @@ void I2SAudioMicrophone::loop() { if (!this->start_driver_()) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver_fail", 1000); + this->status_momentary_error(1000); this->stop_driver_(); // Stop/frees whatever possibly started break; } @@ -408,7 +408,7 @@ void I2SAudioMicrophone::loop() { if (this->task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task_fail", 1000); + this->status_momentary_error(1000); this->stop_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index a57ef7f55c0..8d586ea4e01 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -140,7 +140,7 @@ void I2SAudioSpeakerBase::loop() { if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver-failure", 1000); + this->status_momentary_error(1000); break; } @@ -153,7 +153,7 @@ void I2SAudioSpeakerBase::loop() { if (this->speaker_task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); + this->status_momentary_error(1000); this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 9ad19f66d5e..6d76e399868 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -471,14 +471,14 @@ void MicroWakeWord::loop() { // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, this->microphone_source_->get_audio_stream_info().get_sample_rate())) { - this->status_momentary_error("frontend_alloc", 1000); + this->status_momentary_error(1000); return; } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state - this->status_momentary_error("task_start", 1000); + this->status_momentary_error(1000); } } break; diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index dbe07991633..23867aef914 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -359,7 +359,7 @@ void MixerSpeaker::loop() { xEventGroupClearBits(this->event_group_, MIXER_TASK_COMMAND_START); } else { ESP_LOGE(TAG, "Failed to start; retrying in 1 second"); - this->status_momentary_error("failure", 1000); + this->status_momentary_error(1000); return; } } diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index 906913723cc..a04121e2a13 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -20,7 +20,7 @@ void MQTTButtonComponent::setup() { this->button_->press(); } else { ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); } diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index b3d8ab80b14..6e5fad3efc7 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -439,7 +439,7 @@ bool MQTTClientComponent::subscribe_(const char *topic, uint8_t qos) { } else { delay(5); ESP_LOGV(TAG, "Subscribe failed for topic='%s'. Will retry", topic); - this->status_momentary_warning("subscribe", 1000); + this->status_momentary_warning(1000); } return ret != 0; } @@ -499,7 +499,7 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { } else { delay(5); ESP_LOGV(TAG, "Unsubscribe failed for topic='%s'.", topic.c_str()); - this->status_momentary_warning("unsubscribe", 1000); + this->status_momentary_warning(1000); } auto it = subscriptions_.begin(); @@ -553,7 +553,7 @@ bool MQTTClientComponent::publish(const char *topic, const char *payload, size_t ESP_LOGVV(TAG, "Publish payload (len=%u): '%.*s'", payload_length, static_cast(payload_length), payload); } else { ESP_LOGV(TAG, "Publish failed for topic='%s' (len=%u). Will retry", topic, payload_length); - this->status_momentary_warning("publish", 1000); + this->status_momentary_warning(1000); } } return ret != 0; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index daf2a511dcc..7aa2f8e9d5a 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -46,7 +46,7 @@ void MQTTFanComponent::setup() { case PARSE_NONE: default: ESP_LOGW(TAG, "Unknown state payload %s", payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); break; } }); @@ -71,7 +71,7 @@ void MQTTFanComponent::setup() { break; case PARSE_NONE: ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str()); - this->status_momentary_warning("direction", 5000); + this->status_momentary_warning(5000); break; } }); @@ -95,7 +95,7 @@ void MQTTFanComponent::setup() { break; case PARSE_NONE: ESP_LOGW(TAG, "Unknown Oscillation Payload %s", payload.c_str()); - this->status_momentary_warning("oscillation", 5000); + this->status_momentary_warning(5000); break; } }); @@ -112,11 +112,11 @@ void MQTTFanComponent::setup() { this->state_->make_call().set_speed(speed_level).perform(); } else { ESP_LOGW(TAG, "Invalid speed level %d", speed_level); - this->status_momentary_warning("speed", 5000); + this->status_momentary_warning(5000); } } else { ESP_LOGW(TAG, "Invalid speed level %s (int expected)", payload.c_str()); - this->status_momentary_warning("speed", 5000); + this->status_momentary_warning(5000); } }); } diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 4f81045fdc3..8e9a6289852 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -25,7 +25,7 @@ void MQTTLockComponent::setup() { this->lock_->open(); } else { ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); this->lock_->add_on_state_callback( diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index 17ba86a9187..44ce2497720 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -29,7 +29,7 @@ void MQTTSwitchComponent::setup() { case PARSE_NONE: default: ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); break; } }); diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index eac45d30b71..69b2f64771b 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -20,7 +20,7 @@ void MQTTUpdateComponent::setup() { this->update_->perform(); } else { ESP_LOGW(TAG, "'%s': Received unknown update payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index c5eee3665c2..1e51e45b7b3 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -171,7 +171,7 @@ bool SoundLevelComponent::start_() { (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); if (temp_ring_buffer == nullptr) { - this->status_momentary_error("ring_buffer", 15000); + this->status_momentary_error(15000); return false; } @@ -180,7 +180,7 @@ bool SoundLevelComponent::start_() { this->audio_source_ = audio::RingBufferAudioSource::create( temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); if (this->audio_source_ == nullptr) { - this->status_momentary_error("audio_source", 15000); + this->status_momentary_error(15000); return false; } this->ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 68cb98b0534..2e3d1c85299 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -265,7 +265,7 @@ void VL53L0XSensor::setup() { void VL53L0XSensor::update() { if (this->initiated_read_ || this->waiting_for_interrupt_) { this->publish_state(NAN); - this->status_momentary_warning("update", 5000); + this->status_momentary_warning(5000); ESP_LOGW(TAG, "%s - update called before prior reading complete - initiated:%d waiting_for_interrupt:%d", this->name_.c_str(), this->initiated_read_, this->waiting_for_interrupt_); return; From ba7d3bd77057dd18560607dba5e4ae8c2cf0c8a2 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:37:46 +0200 Subject: [PATCH 183/435] [gpio][esp32][uart] Add option to hold gpio during (deep)sleep and reboot (3/3) (#18964) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../deep_sleep/deep_sleep_esp32.cpp | 2 +- esphome/components/esp32/gpio.cpp | 78 ++++++++++ esphome/components/esp32/gpio.h | 2 + esphome/components/esp32/gpio.py | 8 +- esphome/components/esp32/gpio_common.py | 26 ++++ esphome/components/esp32/gpio_esp32.py | 10 ++ esphome/components/esp32/gpio_esp32_c3.py | 10 +- esphome/components/esp32/gpio_esp32_c5.py | 7 + esphome/components/esp32/gpio_esp32_c6.py | 10 +- esphome/components/esp32/gpio_esp32_c61.py | 7 + esphome/components/esp32/gpio_esp32_h2.py | 10 +- esphome/components/esp32/gpio_esp32_h21.py | 7 + esphome/components/esp32/gpio_esp32_h4.py | 7 + esphome/components/esp32/gpio_esp32_p4.py | 10 +- esphome/components/esp32/gpio_esp32_s2.py | 6 + esphome/components/esp32/gpio_esp32_s3.py | 10 +- esphome/components/esp32/gpio_esp32_s31.py | 7 + .../uart/uart_component_esp_idf.cpp | 17 +++ .../components/uart/uart_component_esp_idf.h | 3 + tests/component_tests/esp32/test_esp32.py | 136 +++++++++++++++++- .../gpio/test_output_final_validate.py | 38 +++++ .../components/deep_sleep/test.esp32-idf.yaml | 26 +++- tests/components/gpio/test.esp32-c3-idf.yaml | 17 ++- tests/components/gpio/test.esp32-idf.yaml | 24 +++- tests/components/uart/test.esp32-idf.yaml | 7 +- 25 files changed, 442 insertions(+), 43 deletions(-) create mode 100644 esphome/components/esp32/gpio_common.py create mode 100644 tests/component_tests/gpio/test_output_final_validate.py diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index b71a4fa1383..7cf90bc33e3 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -111,7 +111,7 @@ void DeepSleepComponent::deep_sleep_() { if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); -#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP +#ifndef SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP #ifdef USE_GPIO_HOLD // Variants without per-pin deep sleep hold need the global enable gpio_deep_sleep_hold_en(); diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index d9c9b861858..59788e76dc7 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -50,17 +50,52 @@ struct ISRPinArg { gpio_num_t pin; gpio::Flags flags; bool inverted; +#ifdef USE_GPIO_HOLD + bool hold; +#endif #if defined(USE_ESP32_VARIANT_ESP32) bool use_rtc; int rtc_pin; #endif }; +#ifdef USE_GPIO_HOLD +// Re-latch a held pad onto the values just written to its registers. +static inline void refresh_hold(const ESP32InternalGPIOPin &pin) { + pin.set_hold(false); + pin.set_hold(true); +} + +static inline void IRAM_ATTR isr_refresh_hold(const ISRPinArg *arg) { + if (!arg->hold) + return; +#if defined(USE_ESP32_VARIANT_ESP32) + if (arg->use_rtc) { + rtcio_hal_hold_disable(arg->rtc_pin); + rtcio_hal_hold_enable(arg->rtc_pin); + return; + } +#elif defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + // GPIO0-21 are held from the RTC domain; the digital hold mask is zero for them + if (arg->pin < SOC_RTCIO_PIN_COUNT) { + rtcio_hal_hold_disable(arg->pin); + rtcio_hal_hold_enable(arg->pin); + return; + } +#endif + gpio_hal_hold_dis(&GPIO_HAL, arg->pin); + gpio_hal_hold_en(&GPIO_HAL, arg->pin); +} +#endif + ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) arg->pin = this->get_pin_num(); arg->flags = gpio::FLAG_NONE; arg->inverted = this->pin_flags_.inverted; +#ifdef USE_GPIO_HOLD + arg->hold = this->get_hold(); +#endif #if defined(USE_ESP32_VARIANT_ESP32) arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num()); if (arg->use_rtc) @@ -105,7 +140,23 @@ size_t ESP32InternalGPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%" PRIu32, static_cast(this->pin_)); } +#ifdef USE_GPIO_HOLD +void ESP32InternalGPIOPin::set_hold(bool hold) const { + if (!this->get_hold()) + return; + if (hold) { + gpio_hold_en(this->get_pin_num()); + } else { + gpio_hold_dis(this->get_pin_num()); + } +} +#endif + void ESP32InternalGPIOPin::setup() { +#ifdef USE_GPIO_HOLD + // Hold before gpio_config so the pad keeps its state while the registers change + this->set_hold(true); +#endif gpio_config_t conf{}; conf.pin_bit_mask = 1ULL << static_cast(this->pin_); conf.mode = flags_to_mode(this->flags_); @@ -116,6 +167,18 @@ void ESP32InternalGPIOPin::setup() { if (this->flags_ & gpio::FLAG_OUTPUT) { gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength()); } +#ifdef USE_GPIO_HOLD + // gpio_hold_dis logs an error on input-only pads + if (GPIO_IS_VALID_OUTPUT_GPIO(this->get_pin_num())) { + if (!this->get_hold()) { + // Release a hold left behind by an earlier boot + gpio_hold_dis(this->get_pin_num()); + } else if (this->flags_ & gpio::FLAG_INPUT) { + // Inputs apply the config now so reads work; outputs wait for the first write + refresh_hold(*this); + } + } +#endif } void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { @@ -130,6 +193,9 @@ void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { pull_mode = GPIO_PULLDOWN_ONLY; } gpio_set_pull_mode(this->get_pin_num(), pull_mode); +#ifdef USE_GPIO_HOLD + refresh_hold(*this); +#endif } bool ESP32InternalGPIOPin::digital_read() { @@ -137,6 +203,9 @@ bool ESP32InternalGPIOPin::digital_read() { } void ESP32InternalGPIOPin::digital_write(bool value) { gpio_set_level(this->get_pin_num(), value != this->pin_flags_.inverted ? 1 : 0); +#ifdef USE_GPIO_HOLD + refresh_hold(*this); +#endif } void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->get_pin_num()); } @@ -144,6 +213,8 @@ void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->ge using namespace esp32; +// NOLINTBEGIN(clang-analyzer-core.FixedAddressDereference) -- some gpio_hal functions use MMIO at fixed addresses +// internally bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); return bool(gpio_hal_get_level(&GPIO_HAL, arg->pin)) != arg->inverted; @@ -152,6 +223,9 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) { auto *arg = reinterpret_cast(this->arg_); gpio_hal_set_level(&GPIO_HAL, arg->pin, value != arg->inverted); +#ifdef USE_GPIO_HOLD + isr_refresh_hold(arg); +#endif } void IRAM_ATTR ISRInternalGPIOPin::clear_interrupt() { @@ -203,8 +277,12 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { gpio_hal_input_disable(&GPIO_HAL, arg->pin); } } +#ifdef USE_GPIO_HOLD + isr_refresh_hold(arg); +#endif arg->flags = flags; } +// NOLINTEND(clang-analyzer-core.FixedAddressDereference) } // namespace esphome diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 954b213d7f8..6ac16731271 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -37,6 +37,8 @@ class ESP32InternalGPIOPin final : public GPIOPin { gpio_num_t get_pin_num() const { return static_cast(this->pin_); } gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } #ifdef USE_GPIO_HOLD + /// Apply or release the pad hold. Does nothing unless the pin is configured with hold_state. + void set_hold(bool hold) const; bool is_held() const override { if (!this->get_hold()) return false; diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 321dd3d498f..7753574bc85 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -5,6 +5,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -248,6 +249,7 @@ ESP32_PIN_SCHEMA = cv.All( cv.float_with_unit("current", "mA", optional_unit=True), cv.enum(DRIVE_STRENGTHS), ), + cv.Optional(CONF_HOLD_STATE, default=False): cv.boolean, } ), validate_gpio_pin, @@ -266,7 +268,11 @@ async def esp32_pin_to_code(config): cg.add(var.set_inverted(True)) if CONF_DRIVE_STRENGTH in config: cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + flags = pins.gpio_flags_expr(config[CONF_MODE]) + if config[CONF_HOLD_STATE]: + flags = flags | cg.gpio_Flags.FLAG_HOLD + cg.add_define("USE_GPIO_HOLD") + cg.add(var.set_flags(flags)) return var diff --git a/esphome/components/esp32/gpio_common.py b/esphome/components/esp32/gpio_common.py new file mode 100644 index 00000000000..a12bebf6e92 --- /dev/null +++ b/esphome/components/esp32/gpio_common.py @@ -0,0 +1,26 @@ +"""Pin checks shared by the ESP32 variant validators.""" + +from logging import Logger +from typing import Any + +from esphome.components.const import CONF_HOLD_STATE +from esphome.const import CONF_NUMBER + + +def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> None: + if num in usb_jtag_pins: + logger.warning( + "GPIO%d is used by the USB-Serial-JTAG interface." + " Using this pin as GPIO will conflict with USB-Serial-JTAG.", + num, + ) + + +def check_usb_jtag_hold( + conf: dict[str, Any], usb_jtag_pins: set[int], logger: Logger +) -> None: + num = conf[CONF_NUMBER] + if conf.get(CONF_HOLD_STATE) and num in usb_jtag_pins: + logger.warning( + "GPIO%d cannot hold at low level during wakeup from deep sleep.", num + ) diff --git a/esphome/components/esp32/gpio_esp32.py b/esphome/components/esp32/gpio_esp32.py index fec257f90b8..11c62359c11 100644 --- a/esphome/components/esp32/gpio_esp32.py +++ b/esphome/components/esp32/gpio_esp32.py @@ -1,6 +1,7 @@ import logging from typing import Any +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_INPUT, @@ -53,6 +54,7 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: is_output = mode[CONF_OUTPUT] is_pullup = mode[CONF_PULLUP] is_pulldown = mode[CONF_PULLDOWN] + is_hold = value.get(CONF_HOLD_STATE) if is_input: # All ESP32 pins support input mode @@ -70,6 +72,14 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: raise cv.Invalid( f"GPIO{num} (34-39) does not support pulldowns.", [CONF_MODE, CONF_PULLDOWN] ) + if is_hold and 34 <= num <= 39: + raise cv.Invalid( + f"GPIO{num} (34-39) is input-only and cannot be held.", + [CONF_HOLD_STATE], + ) + if is_hold and num == 20: + # Not in the digital hold mask and not an RTC pad, so the driver cannot hold it + raise cv.Invalid("GPIO20 has no hold function.", [CONF_HOLD_STATE]) check_strapping_pin(value, _ESP32_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c3.py b/esphome/components/esp32/gpio_esp32_c3.py index 6eb002f3f0a..c4624a93a4e 100644 --- a/esphome/components/esp32/gpio_esp32_c3.py +++ b/esphome/components/esp32/gpio_esp32_c3.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32C3_SPI_PSRAM_PINS = { 12: "SPIHD", 13: "SPIWP", @@ -28,12 +30,7 @@ def esp32_c3_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-C3s and is already used by the SPI/PSRAM interface (function: {_ESP32C3_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32C3_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32C3_USB_JTAG_PINS, _LOGGER) return value @@ -49,6 +46,7 @@ def esp32_c3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C3_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32C3_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index 639ed64c9ea..b3f4c319c61 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c5/include/hal/i2c_ll.h _ESP32C5_I2C_LP_PINS = {"SDA": 2, "SCL": 3} @@ -20,6 +22,8 @@ _ESP32C5_SPI_PSRAM_PINS = { _ESP32C5_STRAPPING_PINS = {2, 7, 27, 28} +_ESP32C5_USB_JTAG_PINS = {13, 14} + _LOGGER = logging.getLogger(__name__) @@ -31,6 +35,7 @@ def esp32_c5_validate_gpio_pin(value: int) -> int: f"This pin cannot be used on ESP32-C5s and is already used by the SPI/PSRAM interface (function: {_ESP32C5_SPI_PSRAM_PINS[value]})" ) + check_usb_jtag_pin(value, _ESP32C5_USB_JTAG_PINS, _LOGGER) return value @@ -45,6 +50,8 @@ def esp32_c5_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C5_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index bd7bb9e2208..9eba375e26e 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c6/include/hal/i2c_ll.h _ESP32C6_I2C_LP_PINS = {"SDA": 6, "SCL": 7} @@ -32,12 +34,7 @@ def esp32_c6_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-C6s and is already used by the SPI/PSRAM interface (function: {_ESP32C6_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32C6_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32C6_USB_JTAG_PINS, _LOGGER) return value @@ -52,6 +49,7 @@ def esp32_c6_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C6_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32C6_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c61.py b/esphome/components/esp32/gpio_esp32_c61.py index 2f3abe6a0f9..3ae8f1c2921 100644 --- a/esphome/components/esp32/gpio_esp32_c61.py +++ b/esphome/components/esp32/gpio_esp32_c61.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # GPIO14-17, GPIO19-21 are used for SPI flash/PSRAM _ESP32C61_SPI_PSRAM_PINS = { 14: "SPICS0", @@ -18,6 +20,8 @@ _ESP32C61_SPI_PSRAM_PINS = { _ESP32C61_STRAPPING_PINS = {8, 9} +_ESP32C61_USB_JTAG_PINS = {12, 13} + _LOGGER = logging.getLogger(__name__) @@ -29,6 +33,7 @@ def esp32_c61_validate_gpio_pin(value: int) -> int: f"This pin cannot be used on ESP32-C61s and is already used by the SPI/PSRAM interface (function: {_ESP32C61_SPI_PSRAM_PINS[value]})" ) + check_usb_jtag_pin(value, _ESP32C61_USB_JTAG_PINS, _LOGGER) return value @@ -43,5 +48,7 @@ def esp32_c61_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32-C61 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C61_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32C61_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index 9dd65376944..41520bb4b75 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32H2_SPI_FLASH_PINS = {6, 7, 15, 16, 17, 18, 19, 20, 21} _ESP32H2_USB_JTAG_PINS = {26, 27} @@ -24,12 +26,7 @@ def esp32_h2_validate_gpio_pin(value: int) -> int: "See https://docs.espressif.com/projects/esp-idf/en/latest/esp32h2/api-reference/peripherals/gpio.html", value, ) - if value in _ESP32H2_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32H2_USB_JTAG_PINS, _LOGGER) return value @@ -44,5 +41,6 @@ def esp32_h2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H2_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32H2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h21.py b/esphome/components/esp32/gpio_esp32_h21.py index 5ab1b7c0740..a13fe3e4ccc 100644 --- a/esphome/components/esp32/gpio_esp32_h21.py +++ b/esphome/components/esp32/gpio_esp32_h21.py @@ -5,17 +5,22 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Partial set from the ESP-IDF / esptool boot-mode docs: # https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html # The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section. _ESP32H21_STRAPPING_PINS: set[int] = {13, 14} +_ESP32H21_USB_JTAG_PINS = {17, 18} + _LOGGER = logging.getLogger(__name__) def esp32_h21_validate_gpio_pin(value: int) -> int: if value < 0 or value > 25: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)") + check_usb_jtag_pin(value, _ESP32H21_USB_JTAG_PINS, _LOGGER) return value @@ -30,5 +35,7 @@ def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H21_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py index 86a4d558589..0cbd83c50cb 100644 --- a/esphome/components/esp32/gpio_esp32_h4.py +++ b/esphome/components/esp32/gpio_esp32_h4.py @@ -5,17 +5,22 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Partial set from the ESP-IDF / esptool boot-mode docs: # https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html # The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section. _ESP32H4_STRAPPING_PINS: set[int] = {13, 14} +_ESP32H4_USB_JTAG_PINS = {13, 14} + _LOGGER = logging.getLogger(__name__) def esp32_h4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 39: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") + check_usb_jtag_pin(value, _ESP32H4_USB_JTAG_PINS, _LOGGER) return value @@ -30,5 +35,7 @@ def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H4_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 6e9227c5015..69fdf5dd22c 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://documentation.espressif.com/esp32-p4-chip-revision-v1.3_datasheet_en.pdf _ESP32P4_LP_PINS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} @@ -18,12 +20,7 @@ _LOGGER = logging.getLogger(__name__) def esp32_p4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") - if value in _ESP32P4_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32P4_USB_JTAG_PINS, _LOGGER) return value @@ -38,6 +35,7 @@ def esp32_p4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32P4_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s2.py b/esphome/components/esp32/gpio_esp32_s2.py index 4978f48a1c2..0de621789d5 100644 --- a/esphome/components/esp32/gpio_esp32_s2.py +++ b/esphome/components/esp32/gpio_esp32_s2.py @@ -1,6 +1,7 @@ import logging from typing import Any +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_INPUT, @@ -70,6 +71,11 @@ def esp32_s2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: raise cv.Invalid( f"GPIO{num} does not support pulldowns.", [CONF_MODE, CONF_PULLDOWN] ) + if value.get(CONF_HOLD_STATE) and num == 46: + raise cv.Invalid( + f"GPIO{num} is input-only and cannot be held.", + [CONF_HOLD_STATE], + ) check_strapping_pin(value, _ESP32S2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index db8c5205336..d38c0a8011f 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -12,6 +12,8 @@ from esphome.const import ( from esphome.pins import PIN_SCHEMA_REGISTRY, check_strapping_pin from esphome.types import ConfigType +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32S3_SPI_PSRAM_PINS = { 26: "SPICS1", 27: "SPIHD", @@ -53,12 +55,7 @@ def esp32_s3_validate_gpio_pin(value: int) -> int: # These pins are not exposed in GPIO mux (reason unknown) # but they're missing from IO_MUX list in datasheet raise cv.Invalid(f"The pin GPIO{value} is not usable on ESP32-S3s.") - if value in _ESP32S3_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32S3_USB_JTAG_PINS, _LOGGER) return value @@ -73,6 +70,7 @@ def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32S3_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32S3_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index 7ccb7cdb903..a6a3b39193e 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Per the ESP32-S31 IDF DOCS and datasheet: # https://docs.espressif.com/projects/esp-idf/en/v6.1/esp32s31/api-reference/peripherals/gpio.html # https://documentation.espressif.com/esp32-s31_datasheet_en.pdf @@ -16,6 +18,8 @@ _ESP32S31_STRAPPING_PINS: set[int] = {36, 37, 60, 61} # LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. _ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} +_ESP32S31_USB_JTAG_PINS = {33, 34} + _LOGGER = logging.getLogger(__name__) @@ -28,6 +32,7 @@ def esp32_s31_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." ) + check_usb_jtag_pin(value, _ESP32S31_USB_JTAG_PINS, _LOGGER) return value @@ -46,6 +51,8 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32S31_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 41fae283b4d..cccb7289fba 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -238,6 +238,10 @@ void IDFUARTComponent::load_settings(bool dump_config) { this->mark_failed(); return; } +#ifdef USE_GPIO_HOLD + // Release held pins so the UART peripheral can drive them + this->set_pins_hold_(false); +#endif #ifdef USE_UART_WAKE_LOOP_ON_RX // Register ISR callback to wake the main loop when UART data arrives. @@ -514,7 +518,20 @@ void IDFUARTComponent::on_shutdown() { if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); } +#ifdef USE_GPIO_HOLD + // Hold the pins again so they keep their state through the reset + this->set_pins_hold_(true); +#endif } +#ifdef USE_GPIO_HOLD +void IDFUARTComponent::set_pins_hold_(bool hold) { + for (auto *pin : {this->tx_pin_, this->rx_pin_, this->flow_control_pin_}) { + if (pin != nullptr) + pin->set_hold(hold); + } +} +#endif + } // namespace esphome::uart #endif // USE_ESP32 diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 7c93b74fbdd..260117c4299 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -90,6 +90,9 @@ class IDFUARTComponent final : public UARTComponent, public Component { protected: void check_logger_conflict() override; +#ifdef USE_GPIO_HOLD + void set_pins_hold_(bool hold); +#endif uint32_t line_inversion_mask_(); // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. esp_err_t apply_line_settings_(); diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 04416d7f57a..4b7a0a6ab8e 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,6 +10,7 @@ from typing import Any import pytest +from esphome.components.const import CONF_HOLD_STATE from esphome.components.esp32 import ( _ESP_TLS_LINKING_COMPONENTS, DEFAULT_EXCLUDED_IDF_COMPONENTS, @@ -38,13 +39,30 @@ from esphome.components.esp32.const import ( KEY_NETWORK_SDKCONFIG, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, ) -from esphome.components.esp32.gpio import validate_gpio_pin +from esphome.components.esp32.gpio import validate_gpio_pin, validate_supports import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_INPUT, + CONF_MODE, CONF_NUMBER, + CONF_OPEN_DRAIN, + CONF_OUTPUT, + CONF_PULLDOWN, + CONF_PULLUP, PlatformFramework, Toolchain, ) @@ -57,7 +75,7 @@ def test_esp32_config( ) -> None: set_core_config(PlatformFramework.ESP32_IDF) - from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_ESP32, VARIANT_FRIENDLY + from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_FRIENDLY # Example ESP32 configuration config = { @@ -1722,9 +1740,8 @@ def test_esp32_s31_gpio_validation( caplog: pytest.LogCaptureFixture, ) -> None: """S31: GPIO26-28/30-32 are reserved for the SPI flash interface, GPIO29 - and GPIO41 do not exist, GPIO33 is a normal pin, and GPIO36 is a + and GPIO41 do not exist, GPIO35 is a normal pin, and GPIO36 is a strapping pin.""" - from esphome.components.esp32.const import VARIANT_ESP32S31 from esphome.components.esp32.gpio import validate_supports from esphome.const import CONF_INPUT, CONF_MODE, CONF_OPEN_DRAIN, CONF_OUTPUT @@ -1735,8 +1752,8 @@ def test_esp32_s31_gpio_validation( input_mode = {CONF_INPUT: True, CONF_OUTPUT: False, CONF_OPEN_DRAIN: False} # Not reserved; a normal GPIO - pin = {CONF_NUMBER: 33, CONF_IGNORE_PIN_VALIDATION_ERROR: False} - assert validate_gpio_pin(pin)[CONF_NUMBER] == 33 + pin = {CONF_NUMBER: 35, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + assert validate_gpio_pin(pin)[CONF_NUMBER] == 35 # Reserved for the SPI flash interface, but can be bypassed with # ignore_pin_validation_error @@ -1764,6 +1781,113 @@ def test_esp32_s31_gpio_validation( assert "GPIO36 is a strapping pin" in caplog.text +_INPUT_ONLY_SETTINGS = ( + (CONF_OUTPUT, "does not support output pin mode"), + (CONF_PULLUP, "does not support pullups"), + (CONF_PULLDOWN, "does not support pulldowns"), + (CONF_HOLD_STATE, "is input-only and cannot be held"), +) + + +@pytest.mark.parametrize( + ("variant", "number", "setting", "error"), + [ + pytest.param( + variant, number, setting, error, id=f"{name}-gpio{number}-{setting}" + ) + for variant, name, numbers in ( + (VARIANT_ESP32, "esp32", range(34, 40)), + (VARIANT_ESP32S2, "s2", (46,)), + ) + for number in numbers + for setting, error in _INPUT_ONLY_SETTINGS + ] + + [ + pytest.param( + VARIANT_ESP32, + 20, + CONF_HOLD_STATE, + "GPIO20 has no hold function", + id="esp32-gpio20-hold_state", + ) + ], +) +def test_input_only_gpio_rejects_unsupported_modes( + set_core_config: SetCoreConfigCallable, + variant: str, + number: int, + setting: str, + error: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + mode = { + CONF_INPUT: True, + CONF_OUTPUT: False, + CONF_OPEN_DRAIN: False, + CONF_PULLUP: False, + CONF_PULLDOWN: False, + } + pin = {CONF_NUMBER: number, CONF_MODE: mode} + if setting == CONF_HOLD_STATE: + pin[setting] = True + else: + mode[setting] = True + + with pytest.raises(cv.Invalid, match=error): + validate_supports(pin) + + +@pytest.mark.parametrize( + ("variant", "number"), + [ + pytest.param(VARIANT_ESP32C3, 18, id="c3-18"), + pytest.param(VARIANT_ESP32C3, 19, id="c3-19"), + pytest.param(VARIANT_ESP32C5, 13, id="c5-13"), + pytest.param(VARIANT_ESP32C5, 14, id="c5-14"), + pytest.param(VARIANT_ESP32C6, 12, id="c6-12"), + pytest.param(VARIANT_ESP32C6, 13, id="c6-13"), + pytest.param(VARIANT_ESP32C61, 12, id="c61-12"), + pytest.param(VARIANT_ESP32C61, 13, id="c61-13"), + pytest.param(VARIANT_ESP32H2, 26, id="h2-26"), + pytest.param(VARIANT_ESP32H2, 27, id="h2-27"), + pytest.param(VARIANT_ESP32H4, 13, id="h4-13"), + pytest.param(VARIANT_ESP32H4, 14, id="h4-14"), + pytest.param(VARIANT_ESP32H21, 17, id="h21-17"), + pytest.param(VARIANT_ESP32H21, 18, id="h21-18"), + pytest.param(VARIANT_ESP32P4, 24, id="p4-24"), + pytest.param(VARIANT_ESP32P4, 25, id="p4-25"), + pytest.param(VARIANT_ESP32S3, 19, id="s3-19"), + pytest.param(VARIANT_ESP32S3, 20, id="s3-20"), + pytest.param(VARIANT_ESP32S31, 33, id="s31-33"), + pytest.param(VARIANT_ESP32S31, 34, id="s31-34"), + ], +) +def test_usb_jtag_gpio_hold_state_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + variant: str, + number: int, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + pin = { + CONF_NUMBER: number, + CONF_MODE: { + CONF_INPUT: True, + CONF_OUTPUT: False, + CONF_OPEN_DRAIN: False, + }, + CONF_HOLD_STATE: True, + } + + with caplog.at_level(logging.WARNING): + validate_supports(pin) + + assert ( + f"GPIO{number} cannot hold at low level during wakeup from deep sleep." + in caplog.text + ) + + _TLS_SERVER_OPTIONS = ( "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", diff --git a/tests/component_tests/gpio/test_output_final_validate.py b/tests/component_tests/gpio/test_output_final_validate.py new file mode 100644 index 00000000000..5d92ad44c6a --- /dev/null +++ b/tests/component_tests/gpio/test_output_final_validate.py @@ -0,0 +1,38 @@ +"""Tests for GPIO output final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.const import CONF_HOLD_STATE +from esphome.components.gpio.output import FINAL_VALIDATE_SCHEMA +from esphome.config import Config +from esphome.const import CONF_ID, CONF_PIN, CONF_POWER_SUPPLY, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("power_supply_holds", [False, True]) +def test_output_hold_state_requires_power_supply_hold_state( + set_core_config: SetCoreConfigCallable, + power_supply_holds: bool, +) -> None: + supply_id = ID("supply", is_declaration=True, type="power_supply") + full_config = Config() + full_config["power_supply"] = [ + {CONF_ID: supply_id, CONF_PIN: {CONF_HOLD_STATE: power_supply_holds}} + ] + full_config.declare_ids.append((supply_id, ["power_supply", 0, CONF_ID])) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + + config = { + CONF_PIN: {CONF_HOLD_STATE: True}, + CONF_POWER_SUPPLY: ID("supply", is_declaration=False, type="power_supply"), + } + if power_supply_holds: + FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises( + cv.Invalid, + match="The power supply pin must also set hold_state when an output it powers sets it", + ): + FINAL_VALIDATE_SCHEMA(config) diff --git a/tests/components/deep_sleep/test.esp32-idf.yaml b/tests/components/deep_sleep/test.esp32-idf.yaml index e45eb08349a..cc9bf5af166 100644 --- a/tests/components/deep_sleep/test.esp32-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-idf.yaml @@ -1,5 +1,27 @@ substitutions: wakeup_pin: GPIO4 -<<: !include common.yaml -<<: !include common-esp32-all.yaml +packages: + deep_sleep: !include common.yaml + deep_sleep_esp32: !include common-esp32-all.yaml + +power_supply: + - id: power_supply_ds_esp32 + pin: + number: GPIO5 + hold_state: true + +output: + - platform: gpio + pin: + number: GPIO22 + hold_state: true + id: gpio_output_ds_esp32 + power_supply: power_supply_ds_esp32 + +switch: + - platform: gpio + pin: + number: GPIO23 + hold_state: true + id: gpio_switch_ds_esp32 diff --git a/tests/components/gpio/test.esp32-c3-idf.yaml b/tests/components/gpio/test.esp32-c3-idf.yaml index e9071b43560..42c207d627d 100644 --- a/tests/components/gpio/test.esp32-c3-idf.yaml +++ b/tests/components/gpio/test.esp32-c3-idf.yaml @@ -6,4 +6,19 @@ substitutions: switch_pin_3: GPIO6 switch_pin_4: GPIO7 -<<: !include common.yaml +packages: + gpio: !include common.yaml + +binary_sensor: + - platform: gpio + pin: + number: GPIO8 + hold_state: true + id: gpio_binary_sensor_esp32 + +output: + - platform: gpio + pin: + number: GPIO9 + hold_state: true + id: gpio_output_esp32 diff --git a/tests/components/gpio/test.esp32-idf.yaml b/tests/components/gpio/test.esp32-idf.yaml index 862aa533ead..12be2ef6fe1 100644 --- a/tests/components/gpio/test.esp32-idf.yaml +++ b/tests/components/gpio/test.esp32-idf.yaml @@ -6,4 +6,26 @@ substitutions: switch_pin_3: GPIO16 switch_pin_4: GPIO17 -<<: !include common.yaml +packages: + gpio: !include common.yaml + +binary_sensor: + - platform: gpio + pin: + number: GPIO18 + hold_state: true + id: gpio_binary_sensor_esp32 + +output: + - platform: gpio + pin: + number: GPIO19 + hold_state: true + id: gpio_output_esp32 + +switch: + - platform: gpio + pin: + number: GPIO21 + hold_state: true + id: gpio_switch_esp32 diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 9550de911b7..3bb87aa3cc7 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -14,7 +14,12 @@ esphome: uart: - id: uart_id - tx_pin: 17 + tx_pin: + number: 17 + mode: + pullup: true + output: true + hold_state: true rx_pin: 16 flow_control_pin: 4 baud_rate: 9600 From dd45f8a6f81a623d1b14200d784e419b7565f9c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:38:05 -1000 Subject: [PATCH 184/435] [usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373) --- esphome/components/usb_host/__init__.py | 53 ++++++++++---- tests/unit_tests/components/test_usb_host.py | 75 +++++++++++++++++++- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 94532958ed1..317cec8236b 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,4 +1,5 @@ -from itertools import combinations +import itertools +import logging import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER @@ -22,6 +23,8 @@ import esphome.final_validate as fv from esphome.helpers import cpp_u16string_escape from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["bytebuffer"] CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["esp32"] @@ -71,15 +74,19 @@ def usb_device_schema( ) -def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: +def _clients_overlap(first: ConfigType, second: ConfigType) -> bool: # Two entries overlap when no field they both constrain tells them apart - for first, second in combinations(configs, 2): - for key, wildcard in _FILTER_WILDCARDS.items(): - a = first.get(key) - b = second.get(key) - if wildcard not in (a, b) and a != b: - break - else: + for key, wildcard in _FILTER_WILDCARDS.items(): + a = first.get(key) + b = second.get(key) + if wildcard not in (a, b) and a != b: + return False + return True + + +def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: + for first, second in itertools.combinations(configs, 2): + if _clients_overlap(first, second): raise cv.Invalid( f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}" ) @@ -87,11 +94,29 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: def _final_validate(config: ConfigType) -> ConfigType: - # Every USB client on the bus, whichever component configured it: any two could - # otherwise open the same device - clients = list(config.get(CONF_DEVICES) or ()) - clients.extend(fv.full_config.get().get("usb_uart") or ()) - validate_usb_clients(clients) + # Any two overlapping clients could otherwise open the same device + devices = config.get(CONF_DEVICES) or [] + uarts = fv.full_config.get().get("usb_uart") or [] + validate_usb_clients(devices) + validate_usb_clients(uarts) + # Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call + for device, uart in itertools.product(devices, uarts): + if not _clients_overlap(device, uart): + continue + # The device matches nothing the usb_uart entry does not already claim + redundant = all( + uart.get(key) in (wildcard, device.get(key)) + for key, wildcard in _FILTER_WILDCARDS.items() + ) + _LOGGER.warning( + "usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. " + "This will be an error in 2027.10.0", + device[CONF_ID], + uart[CONF_ID], + "remove it from usb_host devices" + if redundant + else "narrow its filter so it no longer matches that device", + ) return config diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py index 1a1df68f4de..8d572dba968 100644 --- a/tests/unit_tests/components/test_usb_host.py +++ b/tests/unit_tests/components/test_usb_host.py @@ -1,9 +1,12 @@ """Tests for usb_host device matching validation.""" +import logging + import pytest -from esphome.components.usb_host import validate_usb_clients +from esphome.components.usb_host import _final_validate, validate_usb_clients import esphome.config_validation as cv +import esphome.final_validate as fv from esphome.types import ConfigType @@ -109,3 +112,73 @@ def test_every_pair_is_compared_not_just_neighbours() -> None: ] with pytest.raises(cv.Invalid, match="a, c"): validate_usb_clients(configs) + + +def _run_final_validate( + devices: list[ConfigType], uarts: list[ConfigType] +) -> ConfigType: + config = {"id": "usb_host", "devices": devices} + token = fv.full_config.set({"usb_host": config, "usb_uart": uarts}) + try: + return _final_validate(config) + finally: + fv.full_config.reset(token) + + +def test_device_duplicating_usb_uart_warns(caplog: pytest.LogCaptureFixture) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "'device_0'" in caplog.text + assert "'uart_0'" in caplog.text + assert "remove it from usb_host devices" in caplog.text + + +def test_wildcard_device_covering_usb_uart_suggests_narrowing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "narrow its filter" in caplog.text + assert "remove it" not in caplog.text + + +def test_disjoint_device_and_usb_uart_do_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4002}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert caplog.text == "" + + +_DUPLICATE_CLIENTS = [ + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4001}, +] + + +@pytest.mark.parametrize( + ("devices", "uarts"), + [([*_DUPLICATE_CLIENTS], []), ([], [*_DUPLICATE_CLIENTS])], + ids=["devices", "uarts"], +) +def test_overlap_within_one_component_is_rejected( + devices: list[ConfigType], uarts: list[ConfigType] +) -> None: + with pytest.raises(cv.Invalid, match="a, b"): + _run_final_validate(devices, uarts) + + +def test_device_inside_broader_usb_uart_suggests_removing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "remove it from usb_host devices" in caplog.text From d3e7bb119c5b1826b8e2f0bb9d37835edee67d55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:39:21 -1000 Subject: [PATCH 185/435] [core] Deprecate the named status_momentary_warning/error overloads (#20369) --- esphome/core/component.h | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 94ccec13f80..0e95f8d4e90 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -300,13 +300,8 @@ class Component { this->status_clear_error_slow_path_(); } - /** Set warning status flag and automatically clear it after a timeout. - * - * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). - * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. - * This is NOT a message to display - use status_set_warning() with a message if logging is needed. - * @param length Duration in milliseconds before the warning is automatically cleared. - */ + // Remove before 2027.5.0 + ESPDEPRECATED("Use status_momentary_warning() without a name. Removed in 2027.5.0", "2026.11.0") void status_momentary_warning(const char *name, uint32_t length = 5000); /// Set warning status flag and clear it after `length` ms. A new call restarts the timeout. @@ -314,13 +309,8 @@ class Component { void status_momentary_warning(uint32_t length = 5000); void status_momentary_warning(const LogString *message, uint32_t length = 5000); - /** Set error status flag and automatically clear it after a timeout. - * - * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). - * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. - * This is NOT a message to display - use status_set_error() with a message if logging is needed. - * @param length Duration in milliseconds before the error is automatically cleared. - */ + // Remove before 2027.5.0 + ESPDEPRECATED("Use status_momentary_error() without a name. Removed in 2027.5.0", "2026.11.0") void status_momentary_error(const char *name, uint32_t length = 5000); /// Set error status flag and clear it after `length` ms. A new call restarts the timeout. From f9a81869efd3ec43329cf8cd56cf82e0cfc2dfbc Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:38 +1100 Subject: [PATCH 186/435] [it8951] Hold display enable pins off during deep-sleep (#20471) --- esphome/components/it8951/display.py | 4 ++-- esphome/components/it8951/it8951.cpp | 6 +++++ esphome/components/it8951/it8951.h | 1 + tests/component_tests/it8951/__init__.py | 0 tests/component_tests/it8951/test_init.py | 28 +++++++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/it8951/__init__.py create mode 100644 tests/component_tests/it8951/test_init.py diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index b5f3019a126..76fbadd1968 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -145,8 +145,8 @@ IT8951Model( reset_pin=12, cs_pin=10, # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply - # (GPIO11). Driven high during setup so no separate power_supply is needed. - enable_pin=[21, 11], + # (GPIO11). Driven high during setup so no separate power_supply is needed, and held low during deep_sleep. + enable_pin=[{"number": 21, "hold_state": True}, {"number": 11, "hold_state": True}], vcom=1400, # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET # selector; using the default 0x0001 leaves VCOM unchanged and breaks diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 2cfcf111ea3..fe99017774d 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -351,6 +351,12 @@ void IT8951Display::on_safe_shutdown() { this->spi_cmd_(TCON_SLEEP); } +void IT8951Display::on_powerdown() { + for (auto *pin : this->enable_pins_) { + pin->digital_write(false); + } +} + // --- Init op enqueuers ------------------------------------------------------- void IT8951Display::enqueue_init_reset_() { diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index fcdc6d1d196..e1273587c45 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -150,6 +150,7 @@ class IT8951Display : public Display, void loop() override; void dump_config() override; void on_safe_shutdown() override; + void on_powerdown() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } // --- Config setters (called from generated code) --- diff --git a/tests/component_tests/it8951/__init__.py b/tests/component_tests/it8951/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/it8951/test_init.py b/tests/component_tests/it8951/test_init.py new file mode 100644 index 00000000000..46c203e5eb5 --- /dev/null +++ b/tests/component_tests/it8951/test_init.py @@ -0,0 +1,28 @@ +"""Tests for it8951 configuration validation.""" + +from collections.abc import Callable +from typing import Any + +from esphome.components.const import CONF_HOLD_STATE +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.it8951.display import CONFIG_SCHEMA +from esphome.const import CONF_ENABLE_PIN, CONF_NUMBER, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_reterminal_e1003_enable_pins_hold_state( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The reTerminal E1003 enable pins must hold their state during deep sleep.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 7, "mosi_pin": 9}) + + config = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-e1003"}) + + enable_pins = config[CONF_ENABLE_PIN] + assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11] + assert all(pin[CONF_HOLD_STATE] for pin in enable_pins) From 721228c1d2f5604efc81ac4ffd53d236cdb1f6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:31:06 +0200 Subject: [PATCH 187/435] [teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/teleinfo/__init__.py | 2 +- .../components/teleinfo/sensor/__init__.py | 144 ++++++++++++++++- tests/component_tests/teleinfo/__init__.py | 0 tests/component_tests/teleinfo/test_sensor.py | 149 ++++++++++++++++++ tests/components/teleinfo/common.yaml | 86 +++++++++- 6 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 tests/component_tests/teleinfo/__init__.py create mode 100644 tests/component_tests/teleinfo/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a851e62a3e5..4264479349c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -570,7 +570,7 @@ esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet esphome/components/tcp_uart/* @Bascht74 esphome/components/tee501/* @Stock-M -esphome/components/teleinfo/* @0hax +esphome/components/teleinfo/* @0hax @FredM67 esphome/components/tem3200/* @bakerkj esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/template/datetime/* @rfdarter diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 8caf0202129..5a6a01f6695 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -CODEOWNERS = ["@0hax"] +CODEOWNERS = ["@0hax", "@FredM67"] DOMAIN = "teleinfo" MULTI_CONF = True diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b51d4cb7953..6a23836c8ab 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,10 +1,32 @@ import esphome.codegen as cg from esphome.components import sensor +import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, ICON_FLASH, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_MILLIVOLT, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = sensor.sensor_schema( - TeleInfoSensor, - unit_of_measurement=UNIT_WATT_HOURS, - icon=ICON_FLASH, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, -).extend(TELEINFO_LISTENER_SCHEMA) + +def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]: + return { + CONF_UNIT_OF_MEASUREMENT: unit, + CONF_DEVICE_CLASS: device_class, + CONF_STATE_CLASS: state_class, + } + + +_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING) +_REACTIVE_ENERGY = _preset( + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT) +_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT) +_APPARENT_POWER_VA = _preset( + UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_APPARENT_POWER_KVA = _preset( + UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT) + +# Legacy defaults, kept for tags without a known preset +_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH} + +# Units that are valid for each device class, used to decide whether a preset's +# device class can be kept when the user overrides the unit (and vice versa) +_COMPATIBLE_UNITS = { + DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS}, + DEVICE_CLASS_REACTIVE_ENERGY: { + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + }, + DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP}, + DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT}, + DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS}, + DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT}, +} + +# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant. +TIC_TAG_CONFIGS = { + # Standard mode + "EA": _ENERGY, + "ER": _REACTIVE_ENERGY, + "IRMS": _CURRENT, + "URMS": _VOLTAGE, + "UMOY": _VOLTAGE, + "SINST": _APPARENT_POWER_VA, + "SMAX": _APPARENT_POWER_VA, + "CC": _POWER, + "PREF": _APPARENT_POWER_KVA, + "PCOUP": _APPARENT_POWER_KVA, + # Historical mode + "BASE": _ENERGY, + "HCH": _ENERGY, + "EJP": _ENERGY, + "BBRH": _ENERGY, + "IINST": _CURRENT, + "IMAX": _CURRENT, + "ISOUSC": _CURRENT, + "ADPS": _CURRENT, + "ADIR": _CURRENT, + "PAPP": _APPARENT_POWER_VA, + "PMAX": _POWER, +} + + +def apply_tag_config(config: ConfigType) -> ConfigType: + """Apply preset configurations based on the tag name. + + Only keys not set by the user are filled in. For known tags, a preset unit or + device class is dropped if it is incompatible with the one set by the user. + Unknown tags get the legacy defaults, merged key by key as before. + """ + if CONF_TAG_NAME not in config: + return config + + # Runs before cv.string, so the raw value may not be a string yet + tag_name = str(config[CONF_TAG_NAME]) + preset = next( + (p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)), + None, + ) + + config = dict(config) + if preset is None: + for key, value in _LEGACY_DEFAULT.items(): + config.setdefault(key, value) + return config + + unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT]) + device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS]) + compatible = unit in _COMPATIBLE_UNITS.get(device_class, ()) + + for key, value in preset.items(): + if key in config: + continue + if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible: + continue + config[key] = value + return config + + +CONFIG_SCHEMA = cv.All( + apply_tag_config, + sensor.sensor_schema( + TeleInfoSensor, + accuracy_decimals=0, + ).extend(TELEINFO_LISTENER_SCHEMA), +) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) + teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID]) cg.add(teleinfo.register_teleinfo_listener(var)) diff --git a/tests/component_tests/teleinfo/__init__.py b/tests/component_tests/teleinfo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 00000000000..a8f9420deac --- /dev/null +++ b/tests/component_tests/teleinfo/test_sensor.py @@ -0,0 +1,149 @@ +"""Tests for teleinfo sensor tag presets.""" + +import pytest + +from esphome.components.teleinfo import CONF_TAG_NAME +from esphome.components.teleinfo.sensor import apply_tag_config +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, + UNIT_WATT_HOURS, +) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class"), + [ + ("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ( + "ERQ1", + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ), + ("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT), + ( + "SINSTS", + UNIT_VOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ( + "PREF", + UNIT_KILOVOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT), + ("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ], +) +def test_known_tag_preset( + tag: str, unit: str, device_class: str, state_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == state_class + assert CONF_ICON not in config + + +def test_unknown_tag_keeps_legacy_defaults() -> None: + config = apply_tag_config({CONF_TAG_NAME: "MYTAG"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_starting_with_u_is_not_voltage() -> None: + config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"}) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +def test_numeric_tag_name_does_not_crash() -> None: + config = apply_tag_config({CONF_TAG_NAME: 1234}) + assert config[CONF_TAG_NAME] == 1234 + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class"), + [ + ("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER), + ("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT), + ], +) +def test_user_compatible_unit_keeps_preset_device_class( + tag: str, unit: str, device_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + + +def test_user_incompatible_unit_skips_preset_device_class() -> None: + config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + +def test_user_incompatible_device_class_skips_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER} + ) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert CONF_UNIT_OF_MEASUREMENT not in config + + +def test_user_same_device_class_keeps_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS + + +def test_user_state_class_is_kept() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT} + ) + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index dcb2cb14bdf..7f73bca62c2 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -22,11 +22,95 @@ teleinfo: update_interval: 60s sensor: + # Historical mode tags - auto-configured via apply_tag_config - platform: teleinfo name: hchc tag_name: HCHC teleinfo_id: test_teleinfo - unit_of_measurement: Wh + - platform: teleinfo + name: base + tag_name: BASE + teleinfo_id: test_teleinfo + - platform: teleinfo + name: iinst1 + tag_name: IINST1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: papp + tag_name: PAPP + teleinfo_id: test_teleinfo + - platform: teleinfo + name: bbrhcjb + tag_name: BBRHCJB + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ejphn + tag_name: EJPHN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pmax + tag_name: PMAX + teleinfo_id: test_teleinfo + - platform: teleinfo + name: imax1 + tag_name: IMAX1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: isousc + tag_name: ISOUSC + teleinfo_id: test_teleinfo + - platform: teleinfo + name: adps + tag_name: ADPS + teleinfo_id: test_teleinfo + # Standard mode tags - auto-configured via apply_tag_config + - platform: teleinfo + name: east + tag_name: EAST + teleinfo_id: test_teleinfo + - platform: teleinfo + name: erqt + tag_name: ERQT + teleinfo_id: test_teleinfo + - platform: teleinfo + name: irms1 + tag_name: IRMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: urms1 + tag_name: URMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: sinsts + tag_name: SINSTS + teleinfo_id: test_teleinfo + - platform: teleinfo + name: smaxsn + tag_name: SMAXSN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ccasn + tag_name: CCASN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pref + tag_name: PREF + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pcoup + tag_name: PCOUP + teleinfo_id: test_teleinfo + # Explicit override - user-specified unit should not be overwritten + - platform: teleinfo + name: hchp_override + tag_name: HCHP + teleinfo_id: test_teleinfo + unit_of_measurement: kWh + # Unknown tag - no auto-config match, should still compile + - platform: teleinfo + name: custom_tag + tag_name: MYTAG + teleinfo_id: test_teleinfo text_sensor: - platform: teleinfo From f4fbc3215d226c4f9bb47ab3650e14935e258acc Mon Sep 17 00:00:00 2001 From: jabla Date: Sat, 10 Oct 2026 19:28:40 +0200 Subject: [PATCH 188/435] [espidf] Clean the build when the IDF target changes (#20473) --- esphome/espidf/toolchain.py | 50 ++++++ tests/unit_tests/test_espidf_toolchain.py | 199 ++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index af3fda63495..871e74d36d3 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -1,5 +1,6 @@ """ESP-IDF direct build API for ESPHome.""" +import contextlib from dataclasses import dataclass, field import fnmatch import json @@ -124,6 +125,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] = os.environ.copy() # Do not leak PYTHONPATH into child env env_cache[version].pop("PYTHONPATH", None) + # Do not let an inherited IDF_TARGET override the variant + env_cache[version].pop("IDF_TARGET", None) # Use provided IDF framework if available if _esphome_manages_idf(): @@ -227,6 +230,8 @@ FILTER_IDF_LINES: list[str] = [ # click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE. _CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) _CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$") +_SDKCONFIG_TARGET_LINE = re.compile(r'^CONFIG_IDF_TARGET="([^"]*)"$', re.MULTILINE) +_TOOLCHAIN_FILE_TARGET = re.compile(r"toolchain-(?:clang-)?(\w+)\.cmake$") @dataclass(frozen=True, kw_only=True) @@ -310,6 +315,50 @@ def tree_skips_bootloader(build_dir: Path) -> bool: return cache.get(SKIP_BOOTLOADER_DEFINE) == "1" +def _foreign_idf_target(target: str) -> str | None: + """Return another IDF target recorded in the CMake caches or sdkconfig.""" + build_dir = _build_dir() + sdkconfig = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + found = [] + for cache_path in ( + build_dir / "CMakeCache.txt", + build_dir / "bootloader" / "CMakeCache.txt", + ): + with contextlib.suppress(OSError, ValueError): + cache = _parse_cmakecache(cache_path) + found.append(cache.get("IDF_TARGET")) + if m := _TOOLCHAIN_FILE_TARGET.search( + cache.get("CMAKE_TOOLCHAIN_FILE", "") + ): + found.append(m.group(1)) + with contextlib.suppress(OSError, ValueError): + if m := _SDKCONFIG_TARGET_LINE.search(sdkconfig.read_text(encoding="utf-8")): + found.append(m.group(1)) + return next((t for t in found if t and t != target), None) + + +def _clean_foreign_target_tree() -> None: + """Clean a build configured for another IDF target, like idf.py set-target.""" + variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT) + if variant is None: + return + target = variant_to_idf_target(variant) + if (old := _foreign_idf_target(target)) is None: + return + from esphome.writer import clean_build + + _LOGGER.info( + "IDF target changed from %s to %s, cleaning build files...", old, target + ) + clean_build(clear_pio_cache=False) + if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): + return + # Restart sdkconfig from ESPHome's options + internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") + if internal.is_file(): + shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) + + def _skip_bootloader() -> bool: """Whether this tree should not build a bootloader at all; per-run memo.""" cache = _cache() @@ -804,6 +853,7 @@ def run_compile(config, verbose: bool) -> int: 3. Run full build """ jobs = _build_jobs(config) + _clean_foreign_target_tree() if need_reconfigure(): if (rc := _configure_project(verbose)) != 0: return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 2a799a4d84a..c73858ed529 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import json import os from pathlib import Path +import shutil import subprocess from unittest.mock import MagicMock, call, patch @@ -262,6 +263,14 @@ def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: assert "PYTHONPATH" not in env +def test_get_idf_env_pops_inherited_idf_target(setup_core: Path) -> None: + """An inherited IDF_TARGET does not reach cmake.""" + toolchain._cache().env.clear() + with patch.dict(os.environ, {"IDF_PATH": str(setup_core), "IDF_TARGET": "esp32"}): + env = toolchain._get_idf_env(version="5.5.4") + assert "IDF_TARGET" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. @@ -1575,3 +1584,193 @@ def test_missing_image_hint_names_the_flag(setup_core: Path) -> None: f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n" ) assert "--skip-bootloader" in toolchain.missing_image_hint() + + +def _write_target_tree( + main: str | None = None, + bootloader: str | None = None, + bootloader_toolchain: str | None = None, + sdkconfig: str | None = None, +) -> None: + """Write the target into the CMake caches and sdkconfig.""" + tools = "/idf/tools/cmake" + for rel, target, toolchain_target in ( + ("build", main, main), + ("build/bootloader", bootloader, bootloader_toolchain or bootloader), + ): + if target is None: + continue + cache = CORE.relative_build_path(rel, "CMakeCache.txt") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text( + f"CMAKE_TOOLCHAIN_FILE:FILEPATH={tools}/toolchain-{toolchain_target}.cmake\n" + f"IDF_TARGET:STRING={target}\n" + ) + if sdkconfig is not None: + path = CORE.relative_build_path("sdkconfig.test") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'CONFIG_FOO=y\nCONFIG_IDF_TARGET="{sdkconfig}"\n') + + +@pytest.mark.parametrize( + ("tree", "expected"), + [ + ({}, None), + ({"main": "esp32c6", "bootloader": "esp32c6", "sdkconfig": "esp32c6"}, None), + ({"main": "esp32h2"}, "esp32h2"), + ({"bootloader": "esp32h2"}, "esp32h2"), + # Failed bootloader configure: new IDF_TARGET, old toolchain file + ({"bootloader": "esp32c6", "bootloader_toolchain": "esp32h2"}, "esp32h2"), + ({"main": "esp32c6", "sdkconfig": "esp32h2"}, "esp32h2"), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32c6", + }, + None, + ), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32h2", + }, + "esp32h2", + ), + ], +) +def test_foreign_idf_target( + setup_core: Path, tree: dict[str, str], expected: str | None +) -> None: + """Another target in any cache or sdkconfig counts.""" + _setup_build(setup_core) + _write_target_tree(**tree) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +@pytest.mark.parametrize( + ("cache", "sdkconfig", "expected"), + [ + ("IDF_TARGET:STRING=esp32h2\n", None, "esp32h2"), + (None, "CONFIG_FOO=y\n", None), + ], +) +def test_foreign_idf_target_partial_entries( + setup_core: Path, cache: str | None, sdkconfig: str | None, expected: str | None +) -> None: + """Missing entries are skipped.""" + _setup_build(setup_core) + for rel, content in ( + ("build/CMakeCache.txt", cache), + ("sdkconfig.test", sdkconfig), + ): + if content is not None: + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +def test_foreign_idf_target_ignores_unreadable_files(setup_core: Path) -> None: + """Unreadable files are ignored.""" + _setup_build(setup_core) + for rel in ("build/CMakeCache.txt", "sdkconfig.test"): + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xff\xfe IDF_TARGET:STRING=esp32h2\n") + assert toolchain._foreign_idf_target("esp32c6") is None + + +@pytest.mark.parametrize("snapshot", [True, False]) +def test_clean_foreign_target_tree_cleans_and_resets_sdkconfig( + setup_core: Path, snapshot: bool +) -> None: + """A foreign tree is cleaned and sdkconfig reset from the snapshot.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(bootloader="esp32c6", bootloader_toolchain="esp32h2") + _write_target_tree(sdkconfig="esp32h2") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + if snapshot: + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + + with patch( + "esphome.writer.clean_build", + side_effect=lambda **_: shutil.rmtree(CORE.relative_build_path("build")), + ) as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_called_once_with(clear_pio_cache=False) + sdkconfig = CORE.relative_build_path("sdkconfig.test").read_text() + if snapshot: + assert sdkconfig == "CONFIG_IDF_TARGET_ESP32C6=y\n" + else: + assert 'CONFIG_IDF_TARGET="esp32h2"' in sdkconfig + + +@pytest.mark.parametrize("variant", ["ESP32C6", None]) +def test_clean_foreign_target_tree_keeps_matching_tree( + setup_core: Path, variant: str | None +) -> None: + """A matching tree or unknown variant is left alone.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + _write_target_tree(main="esp32c6", bootloader="esp32c6", sdkconfig="esp32c6") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + before = sdkconfig.read_text() + + with patch("esphome.writer.clean_build") as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_not_called() + assert sdkconfig.read_text() == before + + +@pytest.mark.parametrize("main", ["esp32h2", None]) +def test_clean_foreign_target_tree_skipped_clean_keeps_sdkconfig( + setup_core: Path, monkeypatch: pytest.MonkeyPatch, main: str | None +) -> None: + """ESPHOME_SKIP_CLEAN_BUILD keeps the tree and sdkconfig.""" + _setup_build(setup_core) + monkeypatch.setenv("ESPHOME_SKIP_CLEAN_BUILD", "1") + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main=main, sdkconfig="esp32h2") + CORE.relative_build_path("sdkconfig.test.esphomeinternal").write_text( + "CONFIG_IDF_TARGET_ESP32C6=y\n" + ) + + toolchain._clean_foreign_target_tree() + + assert CORE.relative_build_path("build/CMakeCache.txt").is_file() == bool(main) + assert 'CONFIG_IDF_TARGET="esp32h2"' in ( + CORE.relative_build_path("sdkconfig.test").read_text() + ) + + +def test_run_compile_cleans_foreign_target_tree_first(setup_core: Path) -> None: + """run_compile cleans a foreign tree before the reconfigure check.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main="esp32h2") + calls: list[str] = [] + + with ( + patch( + "esphome.writer.clean_build", + side_effect=lambda **_: calls.append("clean"), + ), + patch.object( + toolchain, + "need_reconfigure", + side_effect=lambda: calls.append("check") or False, + ), + patch.object(toolchain, "_cache_entries_changed", return_value=False), + patch.object(toolchain, "_run_ninja", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + + assert calls == ["clean", "check"] From 4ba0e1bfa2796116ec831d5a46e8eb54954f18f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 07:39:49 -1000 Subject: [PATCH 189/435] [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_gen/host.py | 2 +- esphome/build_helpers/ninja_gen.py | 21 ++++++++++++------- .../unit_tests/build_gen/test_arduino8266.py | 3 ++- tests/unit_tests/build_gen/test_host.py | 5 ++++- .../build_helpers/test_ninja_gen.py | 18 ++++++++++++++++ 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 44369136a01..b0ba7663927 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), *pch_rule_lines(), "rule link", diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index f1e53e1ccb2..3a8587891b2 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: lines = [ *tool_lines(compilers.cc, compilers.cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index a2ee244a29f..c74262fdeea 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -74,33 +74,40 @@ def tool_lines( # path gets f"python = {_q(strip_win_long_path_prefix(sys.executable))}", f"buildtool = {_q(_BUILD_TOOL)}", - f"ccache = {_q(ccache) if ccache else ''}", + *([f"ccache = {_q(ccache)}"] if ccache else []), "", ] -def compile_rule_lines() -> list[str]: - """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" +def compile_rule_lines(ccache: str | None) -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it. + + ``ccache`` is the launcher ``tool_lines`` was given; the same test here + keeps the variable and the rules in step. It is spliced in rather than + left as an empty variable: a leading space on the command line makes + CreateProcess fail on Windows. + """ + launcher = "$ccache " if ccache else "" return [ "rule c", - " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CC $out", "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CXX $out", "rule aspp", - " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = AS $out", # Plain assembler, as SCons's ASCOM: no preprocessor, so no # depfile and no $flags (defines/includes) either "rule asm", - " command = $ccache $cc -x assembler $asflags -c $in -o $out", + f" command = {launcher}$cc -x assembler $asflags -c $in -o $out", " description = AS $out", ] diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 2ea4ffabe6e..4c60b4e4764 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -888,8 +888,9 @@ def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None _set_flags() content = _write_ninja(paths) assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $cc -x assembler $asflags -c $in -o $out" in content assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( - content + _write_ninja(paths, ccache="/cc/ccache") ) diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py index 9e40698c91c..c3936ee65a8 100644 --- a/tests/unit_tests/build_gen/test_host.py +++ b/tests/unit_tests/build_gen/test_host.py @@ -308,7 +308,10 @@ def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render() - assert "ccache = \n" in ninja + # No launcher variable, and no leading space on the commands either: + # CreateProcess on Windows rejects a command line that starts with one + assert "$ccache" not in ninja + assert " command = $cxx -MMD -MF $out.d" in ninja def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py index e7f9b96af2e..d51110efad8 100644 --- a/tests/unit_tests/build_helpers/test_ninja_gen.py +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -55,3 +55,21 @@ def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: tmp_path / "b.cpp", tmp_path / "sub" / "c.S", ] + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_compile_rule_lines_splice_the_launcher(ccache: str | None) -> None: + """Without ccache no command keeps a leading space, which CreateProcess + on Windows rejects; with it every compile rule starts with the launcher.""" + commands = [ + line.removeprefix(" command = ") + for line in ninja_gen.compile_rule_lines(ccache) + if line.startswith(" command = ") + ] + assert len(commands) == 4 + for command in commands: + if ccache: + assert command.startswith("$ccache $c") + else: + assert command.startswith("$c") + assert "$ccache" not in command From 9f4a7cf5cc715e8795ddc4c270c061669dfee847 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 10 Oct 2026 19:49:35 +0200 Subject: [PATCH 190/435] [nrf52] fix build error on 3.4.0 with pch ON (#20474) --- esphome/components/nrf52/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7cd0c6cdc1b..de9dc6401e4 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -835,7 +835,7 @@ _PCH_CMAKE_LINES = [ "set(esphome_kept_options)", "set(esphome_pch_headers)", "foreach(option IN LISTS esphome_options)", - ' if(option MATCHES "imacros> (.+)$")', + ' if(option MATCHES "imacros> ([^>]+)")', ' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")', " list(APPEND esphome_kept_options", ' "$<$,$,app>>>:${option}>")', From 3447b681363f6aae7aeed9f7a1570b7028b5dc7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 08:00:40 -1000 Subject: [PATCH 191/435] [ci] Compile an ESP8266 config with the native toolchain on Windows (#20467) --- .github/workflows/ci.yml | 86 +++++++++++++++++++++++++++++++++ script/test_build_components.py | 5 +- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 586263c6514..285d6869e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1335,6 +1335,91 @@ jobs: # config first, so a separate config pass is redundant. python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests + test-esp8266-native-windows: + name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }}) + runs-on: windows-latest + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true' + # A hung compiler must fail the job, not hold the runner for six hours. + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + # Both ways the build runs on Windows: ESPHome Desktop bundles + # ccache and puts it on PATH, a pip install has none. + ccache: ["1", "0"] + env: + ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }} + # The test script prints characters the cp1252 console cannot encode + PYTHONUTF8: "1" + # The exact static build ESPHome Desktop ships + CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip + CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Install ccache + if: matrix.ccache == '1' + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP "ccache" + New-Item -ItemType Directory -Force $dir | Out-Null + $zip = Join-Path $dir "ccache.zip" + Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() + if ($hash -ne $env:CCACHE_SHA256) { + Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256" + exit 1 + } + Expand-Archive -Path $zip -DestinationPath $dir + $bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1 + Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName + & $bin.FullName --version + + - name: Compile + shell: pwsh + # One component test: web_server brings wifi, api and the async web + # server libraries, the shape of a typical ESP8266 device + run: | + . .\venv\Scripts\Activate.ps1 + $arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests" + $p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru + # Reading the handle keeps ExitCode available after the process exits + $null = $p.Handle + if (-not $p.WaitForExit(25 * 60 * 1000)) { + Write-Host "::error::The compile did not finish in 25 minutes; processes still running:" + Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } | + Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine | + Format-List | Out-String -Width 4096 | Write-Host + Get-Process | + Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } | + Select-Object Id, Name, CPU, WorkingSet64 | + Format-Table | Out-String | Write-Host + taskkill /PID $p.Id /T /F + exit 1 + } + exit $p.ExitCode + + - name: Show ccache statistics + # Also after a failed or killed compile: the counters say whether + # ccache was ever reached + if: always() && matrix.ccache == '1' + shell: pwsh + # The build keeps its store under the esphome tools cache + run: | + $env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache" + ccache -s + device-builder: name: Test downstream esphome/device-builder runs-on: ubuntu-24.04 @@ -1705,6 +1790,7 @@ jobs: - test-build-components-split - test-esp32-platformio - test-esp8266-native + - test-esp8266-native-windows - device-builder - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/test_build_components.py b/script/test_build_components.py index c955391a619..85e4df38f17 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -21,6 +21,7 @@ from dataclasses import dataclass import hashlib import os from pathlib import Path +import shutil import subprocess import sys import time @@ -83,7 +84,9 @@ def show_disk_space_if_ci(esphome_command: str) -> None: print("=" * 80) # Use sys.stdout.flush() to ensure output appears immediately sys.stdout.flush() - subprocess.run(["df", "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr) + # Windows has no df + if df := shutil.which("df"): + subprocess.run([df, "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr) print("=" * 80 + "\n") sys.stdout.flush() From eb1afa4245e32f6bf806f8a84817d136d9ca7fa6 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:15:30 +0200 Subject: [PATCH 192/435] [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215) Co-authored-by: J. Nick Koston --- tests/integration/test_socket_tcp_client_link.py | 6 ++++++ tests/integration/test_tcp_uart_bridge.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/integration/test_socket_tcp_client_link.py b/tests/integration/test_socket_tcp_client_link.py index 16b5f544f79..89a6ff42347 100644 --- a/tests/integration/test_socket_tcp_client_link.py +++ b/tests/integration/test_socket_tcp_client_link.py @@ -35,6 +35,7 @@ async def test_socket_tcp_client_link( link_down = asyncio.Event() second_link_up = asyncio.Event() link_up_count = 0 + writers: list[asyncio.StreamWriter] = [] def on_log_line(line: str) -> None: nonlocal link_up_count @@ -48,6 +49,7 @@ async def test_socket_tcp_client_link( async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: + writers.append(writer) if not echo_done.is_set(): writer.write(PAYLOAD) await writer.drain() @@ -98,5 +100,9 @@ async def test_socket_tcp_client_link( "Second session echo wrong; stale bytes from the first session?" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() diff --git a/tests/integration/test_tcp_uart_bridge.py b/tests/integration/test_tcp_uart_bridge.py index fb22a26b558..70befc90498 100644 --- a/tests/integration/test_tcp_uart_bridge.py +++ b/tests/integration/test_tcp_uart_bridge.py @@ -41,12 +41,14 @@ async def test_tcp_uart_bridge( service_byte_ok = asyncio.Event() reconnect_echo_ok = asyncio.Event() sessions = 0 + writers: list[asyncio.StreamWriter] = [] async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: nonlocal sessions sessions += 1 + writers.append(writer) if sessions == 1: writer.write(GREETING) await writer.drain() @@ -98,5 +100,9 @@ async def test_tcp_uart_bridge( reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() From ea7c277c08af45828fdd8309d8c8d80d6f09c06e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:34:29 -0400 Subject: [PATCH 193/435] [ci] Mark PRs with merge conflicts stale after 30 days (#20490) --- .github/workflows/stale.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 43b7e6663d6..7507eb4a424 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,16 +16,18 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main + uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: # Live only on dev: a workflow_dispatch from any other branch is a dry run dry-run: ${{ github.ref != 'refs/heads/dev' }} days-before-stale: 90 + days-before-stale-conflicting: 30 days-before-close: 7 stale-label: stale exempt-label: not-stale + conflict-label: needs-rebase ignored-users: esphbot,codecov-commenter stale-pr-message: > There hasn't been any activity on this pull request recently. This @@ -36,6 +38,18 @@ jobs: to keep it open. Also, please merge the latest dev branch into your branch to ensure that it's up to date with the latest changes. + Thank you for your contribution! + conflict-pr-message: > + This pull request has merge conflicts. Please merge the latest dev + branch into your branch to resolve them. + stale-conflicting-pr-message: > + The merge conflicts on this pull request have not been resolved for + 30 days, so it has been marked as stale and will be closed if they + are not resolved within 7 days. + + Please merge the latest dev branch into your branch. Once the pull + request is mergeable again the stale label is removed. + Thank you for your contribution! stale-issue-message: > There hasn't been any activity on this issue recently. Due to the From a4ef8a6c905197680341c631f851f4e16241d56a Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 10 Oct 2026 11:43:33 -0700 Subject: [PATCH 194/435] [addressable_light] Clear white channel when setting HSV color (#20265) --- esphome/components/light/esp_color_view.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 440a23e9c93..018e30a9a81 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -19,10 +19,7 @@ class ESPColorSettable { virtual void lighten(uint8_t delta) = 0; virtual void darken(uint8_t delta) = 0; void set(const ESPHSVColor &color) { this->set_hsv(color); } - void set_hsv(const ESPHSVColor &color) { - Color rgb = color.to_rgb(); - this->set_rgb(rgb.r, rgb.g, rgb.b); - } + void set_hsv(const ESPHSVColor &color) { this->set(color.to_rgb()); } void set_rgb(uint8_t red, uint8_t green, uint8_t blue) { this->set_red(red); this->set_green(green); From 667dbd1ec3fb752928b95e4b4c44ad3e65ac0d8d Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 10 Oct 2026 11:56:16 -0700 Subject: [PATCH 195/435] [adalight] Add a component build test (#19103) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- tests/components/adalight/common.yaml | 13 +++++++++++++ tests/components/adalight/test.esp8266-ard.yaml | 3 +++ 2 files changed, 16 insertions(+) create mode 100644 tests/components/adalight/common.yaml create mode 100644 tests/components/adalight/test.esp8266-ard.yaml diff --git a/tests/components/adalight/common.yaml b/tests/components/adalight/common.yaml new file mode 100644 index 00000000000..4203c76a500 --- /dev/null +++ b/tests/components/adalight/common.yaml @@ -0,0 +1,13 @@ +adalight: + +light: + - platform: neopixelbus + id: adalight_light + name: Neopixelbus Light + method: esp8266_uart + num_leds: 5 + pin: 2 + type: GRBW + variant: SK6812 + effects: + - adalight: diff --git a/tests/components/adalight/test.esp8266-ard.yaml b/tests/components/adalight/test.esp8266-ard.yaml new file mode 100644 index 00000000000..08ddd3f007a --- /dev/null +++ b/tests/components/adalight/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + adalight: !include common.yaml From 2ac48d408f33c6430acbecf9036096f0384c9f7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 08:56:49 -1000 Subject: [PATCH 196/435] [const] Move automatic_baseline_calibration to the shared constants (#20492) --- esphome/components/const/__init__.py | 1 + esphome/components/mhz19/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index b54d797f096..6b1ffc5473d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -9,6 +9,7 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_ALLOWED_IPS = "allowed_ips" +CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 053f17765e1..4daeef5059c 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -2,6 +2,7 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( CONF_CO2, @@ -19,7 +20,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] -CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_DETECTION_RANGE = "detection_range" mhz19_ns = cg.esphome_ns.namespace("mhz19") From f70b6a6b03afe9256b72a77159fffaa9ae49e5c9 Mon Sep 17 00:00:00 2001 From: Jeff Zigler <123041141+zigboi@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:04:06 -0700 Subject: [PATCH 197/435] [mitsubishi] Add fahrenheit_compatibility mode (#18880) 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/mitsubishi/climate.py | 3 ++ esphome/components/mitsubishi/mitsubishi.cpp | 32 +++++++++++++++++++- esphome/components/mitsubishi/mitsubishi.h | 10 ++++++ tests/components/mitsubishi/common.yaml | 4 +++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 2d38351898a..37431648969 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -20,6 +20,7 @@ SETFANMODE = { CONF_SUPPORTS_DRY = "supports_dry" CONF_SUPPORTS_FAN_ONLY = "supports_fan_only" +CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") @@ -49,6 +50,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).ex cv.Optional(CONF_SET_FAN_MODE, default="3levels"): cv.enum(SETFANMODE), cv.Optional(CONF_SUPPORTS_DRY, default=False): cv.boolean, cv.Optional(CONF_SUPPORTS_FAN_ONLY, default=False): cv.boolean, + cv.Optional(CONF_FAHRENHEIT_COMPATIBILITY, default=False): cv.boolean, cv.Optional(CONF_HORIZONTAL_DEFAULT, default="middle"): cv.enum( HORIZONTAL_DIRECTIONS ), @@ -65,5 +67,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fan_mode(config[CONF_SET_FAN_MODE])) cg.add(var.set_supports_dry(config[CONF_SUPPORTS_DRY])) cg.add(var.set_supports_fan_only(config[CONF_SUPPORTS_FAN_ONLY])) + cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index dabd62b8f1d..8ee4bda042e 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -1,4 +1,5 @@ #include "mitsubishi.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -53,6 +54,33 @@ const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; const uint8_t MITSUBISHI_BYTE16 = 0x00; +// Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree +// maps to its own code. +static const uint8_t FAHRENHEIT_CODES[] PROGMEM = {0x00, 0x10, 0x01, 0x11, 0x02, 0x12, 0x03, 0x04, 0x05, 0x15, + 0x06, 0x16, 0x07, 0x17, 0x08, 0x18, 0x09, 0x19, 0x0a, 0x1a, + 0x0b, 0x1b, 0x0c, 0x1c, 0x0d, 0x1d, 0x0e, 0x0f}; +static constexpr uint8_t FAHRENHEIT_MIN = 61; +static constexpr uint8_t MITSUBISHI_HALF_DEGREE = 0x10; + +static uint8_t fahrenheit_code(float celsius) { + int fahrenheit = (int) roundf(celsius_to_fahrenheit(celsius)); + size_t index = clamp(fahrenheit - FAHRENHEIT_MIN, 0, sizeof(FAHRENHEIT_CODES) - 1); + return progmem_read_byte(&FAHRENHEIT_CODES[index]); +} + +// In Fahrenheit mode a code from the table decodes to the exact Fahrenheit value. Any other code, and every code in +// Celsius mode, is a whole degree plus the half degree bit, which a remote set to Fahrenheit also sets. +static float decode_temperature(uint8_t code, bool fahrenheit) { + if (fahrenheit) { + for (size_t i = 0; i < sizeof(FAHRENHEIT_CODES); i++) { + if (code == progmem_read_byte(&FAHRENHEIT_CODES[i])) { + return fahrenheit_to_celsius(FAHRENHEIT_MIN + i); + } + } + } + return MITSUBISHI_TEMP_MIN + (code & 0x0F) + ((code & MITSUBISHI_HALF_DEGREE) ? 0.5f : 0.0f); +} + void MitsubishiClimate::transmit_state() { // Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00 // Byte 5: On=0x20, Off: 0x00 @@ -113,6 +141,8 @@ void MitsubishiClimate::transmit_state() { // Temperature if (this->mode == climate::CLIMATE_MODE_DRY) { remote_state[7] = 24 - MITSUBISHI_TEMP_MIN; // Remote sends always 24°C if "Dry" mode is selected + } else if (this->fahrenheit_compatibility_) { + remote_state[7] = fahrenheit_code(this->target_temperature); } else { remote_state[7] = (uint8_t) roundf( clamp(this->target_temperature, MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX) - MITSUBISHI_TEMP_MIN); @@ -300,7 +330,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Temp - this->target_temperature = state_frame[7] + MITSUBISHI_TEMP_MIN; + this->target_temperature = decode_temperature(state_frame[7], this->fahrenheit_compatibility_); // Fan uint8_t fan = state_frame[9] & 0x07; //(Bit 0,1,2 = Speed) diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 269d39b06b4..cf943389e11 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/core/helpers.h" #include @@ -9,6 +10,8 @@ namespace esphome::mitsubishi { // Temperature const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius +// Remotes set to Fahrenheit go up to 88°F, one step past the Celsius range +static constexpr float MITSUBISHI_TEMP_MAX_FAHRENHEIT = fahrenheit_to_celsius(88.0f); // Fan mode enum SetFanMode : uint8_t { @@ -55,6 +58,12 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_supports_fan_only(bool supports_fan_only) { this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only); } + void set_fahrenheit_compatibility(bool fahrenheit_compatibility) { + this->fahrenheit_compatibility_ = fahrenheit_compatibility; + if (fahrenheit_compatibility) { + this->maximum_temperature_ = MITSUBISHI_TEMP_MAX_FAHRENHEIT; + } + } void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; @@ -80,6 +89,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); SetFanMode fan_mode_; + bool fahrenheit_compatibility_{false}; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; diff --git a/tests/components/mitsubishi/common.yaml b/tests/components/mitsubishi/common.yaml index 4a2deda1630..8335ea35435 100644 --- a/tests/components/mitsubishi/common.yaml +++ b/tests/components/mitsubishi/common.yaml @@ -2,3 +2,7 @@ climate: - platform: mitsubishi name: Mitsubishi transmitter_id: xmitr + fahrenheit_compatibility: true + - platform: mitsubishi + name: Mitsubishi Celsius + transmitter_id: xmitr From 17cbb751613e6559b31651f37c1e9068c6cbd6eb Mon Sep 17 00:00:00 2001 From: Jeff Zigler <123041141+zigboi@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:11:14 -0700 Subject: [PATCH 198/435] [mitsubishi] Control both vanes on multi-vane Mitsubishi minisplit heads (#12609) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mitsubishi/climate.py | 4 ++++ esphome/components/mitsubishi/mitsubishi.cpp | 19 +++++++++++++------ esphome/components/mitsubishi/mitsubishi.h | 2 ++ tests/components/mitsubishi/common.yaml | 1 + 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 37431648969..1fbe1d67cf3 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -44,6 +44,8 @@ VERTICAL_DIRECTIONS = { "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, } +CONF_VERTICAL_VANES = "vertical_vanes" + CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).extend( { @@ -57,6 +59,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).ex cv.Optional(CONF_VERTICAL_DEFAULT, default="middle"): cv.enum( VERTICAL_DIRECTIONS ), + cv.Optional(CONF_VERTICAL_VANES, default=1): cv.int_range(1, 2), } ) @@ -70,3 +73,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) + cg.add(var.set_vertical_vanes(config[CONF_VERTICAL_VANES])) diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 8ee4bda042e..515cd81fdfe 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -27,6 +27,7 @@ const uint8_t MITSUBISHI_WIDE_VANE_SWING = 0xC0; const uint8_t MITSUBISHI_FAN_AUTO = 0x00; +const uint8_t MITSUBISHI_VERTICAL_VANE_MASK = 0x38; // Bits 3,4,5 const uint8_t MITSUBISHI_VERTICAL_VANE_SWING = 0x38; // const uint8_t MITSUBISHI_AUTO = 0x80; @@ -52,7 +53,6 @@ const uint8_t MITSUBISHI_BYTE02 = 0x26; const uint8_t MITSUBISHI_BYTE03 = 0x01; const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; -const uint8_t MITSUBISHI_BYTE16 = 0x00; // Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree // maps to its own code. @@ -100,7 +100,7 @@ void MitsubishiClimate::transmit_state() { // Byte 13: Constant 0x00 // Byte 14: HVAC specfic, i.e. ECONO COOL, CLEAN MODE, always 0x00 // Byte 15: HVAC specfic, i.e. POWERFUL, SMART SET, PLASMA, always 0x00 - // Byte 16: Constant 0x00 + // Byte 16: Left vane control (for specific models). Constants match right vane controls. // Byte 17: Checksum: SUM[Byte0...Byte16] uint8_t remote_state[18] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; @@ -214,7 +214,7 @@ void MitsubishiClimate::transmit_state() { break; } - ESP_LOGD(TAG, "default_vertical_direction_: %02X", this->default_vertical_direction_); + ESP_LOGD(TAG, "Vertical default: 0x%02X, vanes: %u", this->default_vertical_direction_, this->vertical_vanes_); // Special modes switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { @@ -236,6 +236,11 @@ void MitsubishiClimate::transmit_state() { break; } + if (this->vertical_vanes_ > 1) { + // Heads with two vertical vanes carry the left vane in byte 16; it follows the right one, presets included + remote_state[16] = remote_state[9] & MITSUBISHI_VERTICAL_VANE_MASK; + } + // Checksum for (int i = 0; i < 17; i++) { remote_state[17] += remote_state[i]; @@ -296,11 +301,11 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } state_frame[pos] = byte; - // Check Header && Footer + // Check Header && Footer. Byte 16 only ever carries the left vane position (bits 3,4,5). if ((pos == 0 && byte != MITSUBISHI_BYTE00) || (pos == 1 && byte != MITSUBISHI_BYTE01) || (pos == 2 && byte != MITSUBISHI_BYTE02) || (pos == 3 && byte != MITSUBISHI_BYTE03) || (pos == 4 && byte != MITSUBISHI_BYTE04) || (pos == 13 && byte != MITSUBISHI_BYTE13) || - (pos == 16 && byte != MITSUBISHI_BYTE16)) { + (pos == 16 && (byte & ~MITSUBISHI_VERTICAL_VANE_MASK) != 0)) { ESP_LOGV(TAG, "Bytes 0,1,2,3,4,13 or 16 fail - invalid value"); return false; } @@ -362,7 +367,9 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Vertical Vane - uint8_t vertical_vane = state_frame[9] & 0x38; // Bits 3,4,5 + // On dual vane heads, the left vane is in [16] and right vane is in [9]. Left is ignored here because + // the swing_mode enum doesn't convey that level of detail. + uint8_t vertical_vane = state_frame[9] & MITSUBISHI_VERTICAL_VANE_MASK; switch (vertical_vane) { case MITSUBISHI_VERTICAL_VANE_SWING: if (this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL) { diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index cf943389e11..493661f9a6d 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -80,6 +80,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_vertical_default(VerticalDirection vertical_direction) { this->default_vertical_direction_ = vertical_direction; } + void set_vertical_vanes(uint8_t vertical_vanes) { this->vertical_vanes_ = vertical_vanes; } protected: // Transmit via IR the state of this climate controller. @@ -93,6 +94,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; + uint8_t vertical_vanes_{1}; }; } // namespace esphome::mitsubishi diff --git a/tests/components/mitsubishi/common.yaml b/tests/components/mitsubishi/common.yaml index 8335ea35435..541ba4f5587 100644 --- a/tests/components/mitsubishi/common.yaml +++ b/tests/components/mitsubishi/common.yaml @@ -3,6 +3,7 @@ climate: name: Mitsubishi transmitter_id: xmitr fahrenheit_compatibility: true + vertical_vanes: 2 - platform: mitsubishi name: Mitsubishi Celsius transmitter_id: xmitr From 865863abf5fc848d3056a93a47a2e1dcfa436fdd Mon Sep 17 00:00:00 2001 From: CzBiX Date: Sun, 11 Oct 2026 03:12:40 +0800 Subject: [PATCH 199/435] [cm1106] Add ABC function (#8894) 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: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/cm1106/cm1106.cpp | 81 ++++++++++++++++++++-------- esphome/components/cm1106/cm1106.h | 25 ++++++++- esphome/components/cm1106/sensor.py | 29 ++++++++++ tests/components/cm1106/common.yaml | 8 +++ 4 files changed, 120 insertions(+), 23 deletions(-) diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index a25a753f754..edfcd0f7309 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -6,10 +6,16 @@ namespace esphome::cm1106 { ESPHOME_LOG_TAG(TAG, "cm1106"); -static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +static constexpr uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +// The factory default ABC calibration cycle differs between CM1106 models, so +// every ABC write applies the configured cycle and baseline explicitly. +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS[10] = {0x11, 0x07, 0x10, 0x64, 0x00, 0x0F, 0x01, 0x90, 0x64, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS_RESPONSE[4] = {0x16, 0x01, 0x10, 0xD9}; +static constexpr uint8_t CM1106_ABC_FLAG_ENABLE = 0x0; +static constexpr uint8_t CM1106_ABC_FLAG_DISABLE = 0x2; uint8_t cm1106_checksum(const uint8_t *response, size_t len) { uint8_t crc = 0; for (size_t i = 0; i < len - 1; i++) { @@ -25,34 +31,46 @@ void CM1106Component::setup() { this->mark_failed(); return; } + + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + this->abc_set_(this->abc_boot_logic_ == CM1106_ABC_ENABLED); + } } void CM1106Component::update() { uint8_t response[8] = {0}; if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + ESP_LOGW(TAG, "Reading data failed!"); this->status_set_warning(); return; } if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); return; } uint8_t checksum = cm1106_checksum(response, sizeof(response)); if (response[7] != checksum) { - ESP_LOGW(TAG, "CM1106 Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); + ESP_LOGW(TAG, "Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); this->status_set_warning(); return; } + const uint16_t ppm = response[3] << 8 | response[4]; + const uint8_t status = response[5]; + if (status & 0x1) { + ESP_LOGW(TAG, "Preheating; CO₂=%uppm", ppm); + this->status_set_warning(); + return; + } + if (status) { + ESP_LOGW(TAG, "Status: %02X", status); + } this->status_clear_warning(); - uint16_t ppm = response[3] << 8 | response[4]; - ESP_LOGD(TAG, "CM1106 Received CO₂=%uppm DF3=%02X DF4=%02X", ppm, response[5], response[6]); + ESP_LOGD(TAG, "CO₂=%uppm DF4=%02X", ppm, response[6]); if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(ppm); } @@ -62,24 +80,38 @@ void CM1106Component::calibrate_zero(uint16_t ppm) { memcpy(cmd, C_M1106_CMD_SET_CO2_CALIB, sizeof(cmd)); cmd[3] = ppm >> 8; cmd[4] = ppm & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_CO2_CALIB_RESPONSE)) { + ESP_LOGD(TAG, "Successfully calibrated sensor to %uppm", ppm); + } +} + +void CM1106Component::abc_set_(bool enabled) { + ESP_LOGD(TAG, "%sabling automatic baseline calibration", enabled ? LOG_STR_LITERAL("En") : LOG_STR_LITERAL("Dis")); + uint8_t cmd[10]; + memcpy(cmd, C_M1106_CMD_SET_ABC_STATUS, sizeof(cmd)); + cmd[4] = enabled ? CM1106_ABC_FLAG_ENABLE : CM1106_ABC_FLAG_DISABLE; + cmd[5] = this->abc_cycle_; + cmd[6] = this->abc_baseline_ >> 8; + cmd[7] = this->abc_baseline_ & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_ABC_STATUS_RESPONSE)) { + ESP_LOGD(TAG, "Successfully set ABC status"); + } +} + +bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack) { uint8_t response[4] = {0}; - - if (!this->cm1106_write_command_(cmd, sizeof(cmd), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) { + ESP_LOGW(TAG, "Command %02X failed", command[2]); this->status_set_warning(); - return; + return false; } - - // check if correct response received - if (memcmp(response, C_M1106_CMD_SET_CO2_CALIB_RESPONSE, sizeof(response)) != 0) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + if (memcmp(response, ack, sizeof(response)) != 0) { + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); - return; + return false; } - this->status_clear_warning(); - ESP_LOGD(TAG, "CM1106 Successfully calibrated sensor to %uppm", ppm); + return true; } bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, @@ -100,6 +132,13 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma void CM1106Component::dump_config() { ESP_LOGCONFIG(TAG, "CM1106:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + ESP_LOGCONFIG(TAG, + " Automatic baseline calibration on boot: %s\n" + " ABC calibration cycle: %u days\n" + " ABC baseline: %uppm", + ONOFF(this->abc_boot_logic_ == CM1106_ABC_ENABLED), this->abc_cycle_, this->abc_baseline_); + } if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index e381db7b5cd..1066a5c44dd 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -6,6 +6,14 @@ namespace esphome::cm1106 { +// ABC logic requested at boot; CM1106_ABC_NONE means nothing is sent and the +// sensor keeps its stored setting. +enum CM1106ABCLogic : uint8_t { + CM1106_ABC_NONE = 0, + CM1106_ABC_ENABLED, + CM1106_ABC_DISABLED, +}; + class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; @@ -13,13 +21,26 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { void dump_config() override; void calibrate_zero(uint16_t ppm); + void abc_enable() { this->abc_set_(true); } + void abc_disable() { this->abc_set_(false); } void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; } + void set_abc(bool enabled, uint8_t cycle_days, uint16_t baseline_ppm) { + this->abc_boot_logic_ = enabled ? CM1106_ABC_ENABLED : CM1106_ABC_DISABLED; + this->abc_cycle_ = cycle_days; + this->abc_baseline_ = baseline_ppm; + } protected: - sensor::Sensor *co2_sensor_{nullptr}; - bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); + /// Sends a command and checks the sensor's four byte acknowledgement, updating the warning status. + bool send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack); + void abc_set_(bool enabled); + + sensor::Sensor *co2_sensor_{nullptr}; + CM1106ABCLogic abc_boot_logic_{CM1106_ABC_NONE}; + uint8_t abc_cycle_{15}; + uint16_t abc_baseline_{400}; }; } // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index c3939735a43..fa179f9667d 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -4,9 +4,12 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( + CONF_BASELINE, CONF_CO2, + CONF_CYCLE, CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, @@ -18,11 +21,22 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andrewjswan"] + +# true enables ABC with these defaults, false disables it, a mapping enables it with custom values +_ABC_SETTINGS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_CYCLE, default=15): cv.int_range(min=1, max=90), + cv.Optional(CONF_BASELINE, default=400): cv.int_range(min=400, max=10000), + } +) + + cm1106_ns = cg.esphome_ns.namespace("cm1106") CM1106Component = cm1106_ns.class_( "CM1106Component", cg.PollingComponent, uart.UARTDevice ) + CONFIG_SCHEMA = ( cv.Schema( { @@ -34,6 +48,9 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_AUTOMATIC_BASELINE_CALIBRATION): cv.Any( + cv.boolean, _ABC_SETTINGS_SCHEMA + ), }, ) .extend(cv.polling_component_schema("60s")) @@ -57,6 +74,11 @@ async def to_code(config: ConfigType) -> None: sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) + if (abc := config.get(CONF_AUTOMATIC_BASELINE_CALIBRATION)) is not None: + enabled = abc if isinstance(abc, bool) else True + settings = _ABC_SETTINGS_SCHEMA({}) if isinstance(abc, bool) else abc + cg.add(var.set_abc(enabled, settings[CONF_CYCLE], settings[CONF_BASELINE])) + CALIBRATION_ACTION_SCHEMA = maybe_simple_id( { @@ -70,3 +92,10 @@ automation.register_apply_action( CALIBRATION_ACTION_SCHEMA, automation.ApplyCall("calibrate_zero(400)"), ) +for _name, _call in ( + ("cm1106.abc_enable", "abc_enable()"), + ("cm1106.abc_disable", "abc_disable()"), +): + automation.register_apply_action( + _name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/cm1106/common.yaml b/tests/components/cm1106/common.yaml index ed2fd670075..eb2fde3a492 100644 --- a/tests/components/cm1106/common.yaml +++ b/tests/components/cm1106/common.yaml @@ -1,5 +1,13 @@ sensor: - platform: cm1106 + id: cm1106_sensor co2: name: CM1106 CO2 Value + on_value: + then: + - cm1106.abc_enable: cm1106_sensor + - cm1106.abc_disable: cm1106_sensor update_interval: 15s + automatic_baseline_calibration: + cycle: 7 + baseline: 420 From ff96530e4d455b7e6d3c6c710690c978b48a4310 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:13:05 -0400 Subject: [PATCH 200/435] [esp32] Honor sdkconfig CPU frequency options (#20158) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 90 +++++++- tests/component_tests/esp32/test_esp32.py | 239 ++++++++++++++++++++++ 2 files changed, 326 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aa4f78de69d..9bceaf5676d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -551,9 +551,88 @@ assert all(variant in CPU_FREQUENCIES for variant in VARIANTS) FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values())) +_SDKCONFIG_CPU_FREQUENCY_OPTION = "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ" +_SDKCONFIG_CPU_FREQUENCY_PREFIX = f"{_SDKCONFIG_CPU_FREQUENCY_OPTION}_" +# Older IDF versions named the CPU frequency options after the chip +_LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS = frozenset( + {VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3} +) + + +def _get_sdkconfig_cpu_frequencies( + sdkconfig_options: dict[str, str], variant: str +) -> tuple[set[int], set[int]]: + """Return the CPU frequencies in MHz that sdkconfig_options selects and disables.""" + # (integer option, choice option prefix) pairs that can carry a frequency + keys = [(_SDKCONFIG_CPU_FREQUENCY_OPTION, _SDKCONFIG_CPU_FREQUENCY_PREFIX)] + if variant in _LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS: + keys.append( + ( + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_MHZ", + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_", + ) + ) + selected: set[int] = set() + disabled: set[int] = set() + for integer_key, choice_prefix in keys: + if (value := sdkconfig_options.get(integer_key)) is not None: + try: + selected.add(int(value)) + except ValueError as err: + raise cv.Invalid( + f"{integer_key} must be an integer MHz value", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) from err + for name, value in sdkconfig_options.items(): + mhz = name.removeprefix(choice_prefix) + if mhz == name or not mhz.isdigit(): + continue + if value.lower() == "y": + selected.add(int(mhz)) + elif value.lower() == "n": + disabled.add(int(mhz)) + else: + raise cv.Invalid( + f"{name} must be y or n", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) + return selected, disabled + + def set_core_data(config): - cpu_frequency = config.get(CONF_CPU_FREQUENCY, None) + cpu_frequency = config.get(CONF_CPU_FREQUENCY) variant = config[CONF_VARIANT] + sdkconfig_path = [CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS] + selected, disabled = _get_sdkconfig_cpu_frequencies( + config[CONF_FRAMEWORK][CONF_SDKCONFIG_OPTIONS], variant + ) + if len(selected) > 1: + conflicting = ", ".join(f"{mhz}MHz" for mhz in sorted(selected)) + raise cv.Invalid( + f"sdkconfig_options contains conflicting CPU frequencies ({conflicting})", + path=sdkconfig_path, + ) + sdkconfig_cpu_frequency = f"{selected.pop()}MHZ" if selected else None + if cpu_frequency is None and sdkconfig_cpu_frequency is not None: + if sdkconfig_cpu_frequency not in CPU_FREQUENCIES[variant]: + raise cv.Invalid( + f"sdkconfig_options selects {sdkconfig_cpu_frequency}, which {variant} does not support", + path=sdkconfig_path, + ) + _LOGGER.warning( + "sdkconfig_options contains a CPU frequency setting; using %s. " + "Set 'esp32.cpu_frequency' to configure it directly.", + sdkconfig_cpu_frequency, + ) + cpu_frequency = sdkconfig_cpu_frequency + elif ( + sdkconfig_cpu_frequency is not None and sdkconfig_cpu_frequency != cpu_frequency + ): + raise cv.Invalid( + f"esp32.cpu_frequency ({cpu_frequency}) conflicts with sdkconfig_options " + f"({sdkconfig_cpu_frequency})", + path=sdkconfig_path, + ) # if not specified in config, default to the maximum supported frequency # (ESP32-P4 engineering samples are limited to 360MHz, non-engineering can do 400MHz) if cpu_frequency is None: @@ -562,12 +641,17 @@ def set_core_data(config): cpu_frequency = "360MHZ" else: cpu_frequency = choices[-1] - config[CONF_CPU_FREQUENCY] = cpu_frequency elif cpu_frequency not in CPU_FREQUENCIES[variant]: raise cv.Invalid( f"Invalid CPU frequency '{cpu_frequency}' for {config[CONF_VARIANT]}", path=[CONF_CPU_FREQUENCY], ) + if int(cpu_frequency[:-3]) in disabled: + raise cv.Invalid( + f"sdkconfig_options disables the selected CPU frequency ({cpu_frequency})", + path=sdkconfig_path, + ) + config[CONF_CPU_FREQUENCY] = cpu_frequency if variant == VARIANT_ESP32P4 and cpu_frequency == "400MHZ": _LOGGER.warning( @@ -3138,7 +3222,7 @@ async def to_code(config): # Set default CPU frequency add_idf_sdkconfig_option( - f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True + f"{_SDKCONFIG_CPU_FREQUENCY_PREFIX}{config[CONF_CPU_FREQUENCY][:-3]}", True ) # Apply LWIP optimization settings diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 4b7a0a6ab8e..9072a906293 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -111,6 +111,245 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ( + "sdkconfig_options", + "cpu_frequency", + "expected_frequency", + "warns", + "variant", + "framework_type", + ), + [ + pytest.param( + { + "CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y", + "CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160", + }, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-frequency-options-with-default", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"}, + "160MHz", + "160MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="explicit-cpu-frequency", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="current-idf-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "n"}, + None, + "240MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="disabled-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ_240": "n"}, + "240MHz", + "240MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="ignore-unrecognized-legacy-disabled-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32C3, + "esp-idf", + id="current-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32S2_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32S2, + "esp-idf", + id="s2-legacy-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32S3, + "esp-idf", + id="s3-legacy-choice-frequency", + ), + pytest.param( + {"CONFIG_ESP32C3_DEFAULT_CPU_FREQ_80": "y"}, + None, + "80MHZ", + True, + VARIANT_ESP32C3, + "esp-idf", + id="c3-legacy-choice-frequency", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "arduino", + id="arduino-framework-current-scalar-frequency", + ), + ], +) +def test_sdkconfig_cpu_frequency_is_honored( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + sdkconfig_options: dict[str, str], + cpu_frequency: str | None, + expected_frequency: str, + warns: bool, + variant: str, + framework_type: str, +) -> None: + """SDK config frequency settings are honored and recommend the native config key.""" + platform_framework = ( + PlatformFramework.ESP32_ARDUINO + if framework_type == "arduino" + else PlatformFramework.ESP32_IDF + ) + set_core_config(platform_framework) + from esphome.components.esp32 import CONFIG_SCHEMA + + config: dict[str, Any] = { + "variant": variant, + "framework": { + "type": framework_type, + "sdkconfig_options": sdkconfig_options, + }, + } + if cpu_frequency is not None: + config["cpu_frequency"] = cpu_frequency + + with caplog.at_level(logging.WARNING): + config = CONFIG_SCHEMA(config) + + assert ("CPU frequency setting" in caplog.text) is warns + assert config["cpu_frequency"] == expected_frequency + + +@pytest.mark.parametrize( + ("variant", "cpu_frequency", "sdkconfig_options", "error_match"), + [ + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "fast"}, + "must be an integer MHz value", + id="integer-option-not-a-number", + ), + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"}, + "ESP32C3 does not support", + id="unsupported-frequency", + ), + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "yes"}, + "must be y or n", + id="choice-option-not-y-or-n", + ), + pytest.param( + VARIANT_ESP32C3, + None, + { + "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y", + "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y", + }, + r"conflicting CPU frequencies \(160MHz, 240MHz\)", + id="two-frequencies-selected", + ), + pytest.param( + VARIANT_ESP32, + "160MHz", + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"}, + "conflicts with sdkconfig_options", + id="conflicts-with-explicit-value", + ), + pytest.param( + VARIANT_ESP32, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "n"}, + "disables the selected CPU frequency", + id="default-frequency-disabled", + ), + pytest.param( + VARIANT_ESP32, + "160MHz", + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "n"}, + "disables the selected CPU frequency", + id="explicit-frequency-disabled-by-legacy-option", + ), + ], +) +def test_sdkconfig_cpu_frequency_errors( + set_core_config: SetCoreConfigCallable, + variant: str, + cpu_frequency: str | None, + sdkconfig_options: dict[str, str], + error_match: str, +) -> None: + """Bad, conflicting or disabled sdkconfig frequencies fail at validation.""" + set_core_config(PlatformFramework.ESP32_IDF) + from esphome.components.esp32 import CONFIG_SCHEMA + + config: dict[str, Any] = { + "variant": variant, + "framework": {"type": "esp-idf", "sdkconfig_options": sdkconfig_options}, + } + if cpu_frequency is not None: + config["cpu_frequency"] = cpu_frequency + + with pytest.raises(cv.Invalid, match=error_match) as exc_info: + CONFIG_SCHEMA(config) + assert exc_info.value.path == ["framework", "sdkconfig_options"] + + @pytest.mark.parametrize( ("config_toolchain", "expected"), [ From ebbe3860eca1edee66f564483a64a3ee414bdfb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 09:43:57 -1000 Subject: [PATCH 201/435] [const] Move aqi to the shared constants (#20494) --- esphome/components/aqi/__init__.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/ens160_base/__init__.py | 2 +- esphome/components/hm3301/sensor.py | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py index 02c0bd2228a..56308e32983 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.const import CONF_AQI # noqa: F401 CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] DOMAIN = "aqi" @@ -6,7 +7,6 @@ DOMAIN = "aqi" aqi_ns = cg.esphome_ns.namespace("aqi") AQICalculatorType = aqi_ns.enum("AQICalculatorType") -CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" CONF_EXTENDED_RANGE = "extended_range" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6b1ffc5473d..e93d508a3d8 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -9,6 +9,7 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_ALLOWED_IPS = "allowed_ips" +CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index b41575c7171..8ef5971f17a 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_COMPENSATION, @@ -26,7 +27,6 @@ DOMAIN = "ens160_base" ens160_ns = cg.esphome_ns.namespace("ens160_base") -CONF_AQI = "aqi" CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 79fb81ea5f5..d5e15f7c620 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -2,7 +2,8 @@ import logging import esphome.codegen as cg from esphome.components import i2c, sensor -from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_ID, From 6559ea470b1119d0c7a14d04ea905afcde7ae271 Mon Sep 17 00:00:00 2001 From: DAVe3283 Date: Sat, 10 Oct 2026 14:58:09 -0600 Subject: [PATCH 202/435] =?UTF-8?q?[tca9548a]=20Allow=20separate=20I=C2=B2?= =?UTF-8?q?C=20bus=20frequencies=20on=20each=20multiplexer=20port=20(#1042?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/i2c/i2c_bus.h | 10 ++ esphome/components/i2c/i2c_bus_arduino.cpp | 93 +++++++++------- esphome/components/i2c/i2c_bus_arduino.h | 14 ++- esphome/components/i2c/i2c_bus_esp_idf.cpp | 96 ++++++++++++----- esphome/components/i2c/i2c_bus_esp_idf.h | 15 +++ esphome/components/i2c/i2c_bus_host.h | 5 + esphome/components/i2c/i2c_bus_zephyr.cpp | 33 ++++-- esphome/components/i2c/i2c_bus_zephyr.h | 7 ++ esphome/components/tca9548a/__init__.py | 53 ++++++++- esphome/components/tca9548a/tca9548a.cpp | 102 +++++++++++++++++- esphome/components/tca9548a/tca9548a.h | 28 ++++- esphome/core/defines.h | 1 + tests/components/tca9548a/common.yaml | 4 + .../tca9548a/test.nrf52-adafruit.yaml | 3 + 14 files changed, 380 insertions(+), 84 deletions(-) create mode 100644 tests/components/tca9548a/test.nrf52-adafruit.yaml diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index 0c5e80bfe5f..be38317497a 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -4,6 +4,7 @@ #include #include +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" namespace esphome::i2c { @@ -53,6 +54,15 @@ class I2CBus { return this->write_readv(address, buffer, len, nullptr, 0); } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// @brief Switch the bus frequency for the transactions that follow; an external bus + /// that does not override this reports an error and keeps its frequency + virtual ErrorCode switch_frequency(uint32_t frequency) { return ERROR_UNKNOWN; } + + /// @brief The frequency the bus runs at + virtual uint32_t get_frequency() const { return 0; } +#endif + protected: /// @brief Scans the I2C bus for devices. Devices presence is kept in an array of std::pair /// that contains the address and the corresponding bool presence flag. diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 95aee2bddb2..215cc1364f8 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -15,7 +15,7 @@ ESPHOME_LOG_TAG(TAG, "i2c"); static constexpr size_t I2C_MAX_LOG_BYTES = 32; void ArduinoI2CBus::setup() { - recover_(); + this->recover_(); #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) @@ -25,9 +25,9 @@ void ArduinoI2CBus::setup() { // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf // RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf if ((this->sda_pin_ / 2) % 2 == 0) { - wire_ = &Wire; + this->wire_ = &Wire; } else { - wire_ = &Wire1; + this->wire_ = &Wire1; } #endif @@ -42,22 +42,22 @@ void ArduinoI2CBus::setup() { void ArduinoI2CBus::set_pins_and_clock_() { #ifdef USE_RP2 - wire_->setSDA(this->sda_pin_); - wire_->setSCL(this->scl_pin_); - wire_->begin(); + this->wire_->setSDA(this->sda_pin_); + this->wire_->setSCL(this->scl_pin_); + this->wire_->begin(); #else - wire_->begin(static_cast(sda_pin_), static_cast(scl_pin_)); + this->wire_->begin(static_cast(this->sda_pin_), static_cast(this->scl_pin_)); #endif - if (timeout_ > 0) { // if timeout specified in yaml + if (this->timeout_ > 0) { // if timeout specified in yaml #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h - wire_->setClockStretchLimit(timeout_); // unit: us + this->wire_->setClockStretchLimit(this->timeout_); // unit: us #elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h - wire_->setTimeout(timeout_ / 1000); // unit: ms + this->wire_->setTimeout(this->timeout_ / 1000); // unit: ms #endif } - wire_->setClock(frequency_); + this->wire_->setClock(this->frequency_); } void ArduinoI2CBus::dump_config() { @@ -67,7 +67,7 @@ void ArduinoI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %u Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); #elif defined(USE_RP2) @@ -87,10 +87,10 @@ void ArduinoI2CBus::dump_config() { } if (this->scan_) { ESP_LOGI(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGI(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGI(TAG, "Found device at address 0x%02X", s.first); } else { @@ -106,7 +106,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe #if defined(USE_ESP8266) this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances #endif - if (!initialized_) { + if (!this->initialized_) { ESP_LOGD(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -118,22 +118,22 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe uint8_t status = 0; if (write_count != 0 || read_count == 0) { - wire_->beginTransmission(address); - size_t ret = wire_->write(write_buffer, write_count); + this->wire_->beginTransmission(address); + size_t ret = this->wire_->write(write_buffer, write_count); if (ret != write_count) { ESP_LOGV(TAG, "TX failed"); return ERROR_UNKNOWN; } - status = wire_->endTransmission(read_count == 0); + status = this->wire_->endTransmission(read_count == 0); } if (status == 0 && read_count != 0) { - size_t ret2 = wire_->requestFrom(address, read_count, true); + size_t ret2 = this->wire_->requestFrom(address, read_count, true); if (ret2 != read_count) { ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", read_count, address, ret2); return ERROR_TIMEOUT; } for (size_t j = 0; j != read_count; j++) - read_buffer[j] = wire_->read(); + read_buffer[j] = this->wire_->read(); } // Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266 if (status == 0) @@ -154,6 +154,19 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_UNKNOWN; } +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ArduinoI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + this->frequency_ = frequency; + if (this->initialized_) { + this->wire_->setClock(this->frequency_); + } + return ERROR_OK; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf @@ -167,15 +180,15 @@ void ArduinoI2CBus::recover_() { const auto half_period_usec = 1000000 / 100000 / 2; // Activate input and pull up resistor for the SCL pin. - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT // This should make the signal on the line HIGH. If SCL is pulled low // on the I2C bus however, then some device is interfering with the SCL // line. In that case, the I2C bus cannot be recovered. delayMicroseconds(half_period_usec); - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -189,18 +202,18 @@ void ArduinoI2CBus::recover_() { // Make sure that switching to output mode will make SCL low, just in // case other code has setup the pin for a HIGH signal. - digitalWrite(scl_pin_, LOW); // NOLINT + digitalWrite(this->scl_pin_, LOW); // NOLINT delayMicroseconds(half_period_usec); for (auto i = 0; i < 9; i++) { // Release pull up resistor and switch to output to make the signal LOW. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, OUTPUT); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, OUTPUT); // NOLINT delayMicroseconds(half_period_usec); // Release output and activate pull up resistor to make the signal HIGH. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT delayMicroseconds(half_period_usec); // When SCL is kept LOW at this point, we might be looking at a device @@ -211,28 +224,28 @@ void ArduinoI2CBus::recover_() { // should recover in a few ms or less else not likely to recovery at // all. auto wait = 250; - while (wait-- && digitalRead(scl_pin_) == LOW) { // NOLINT + while (wait-- && digitalRead(this->scl_pin_) == LOW) { // NOLINT App.feed_wdt(); delayMicroseconds(half_period_usec * 2); } - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } // Activate input and pull resistor for the SDA pin, so we can verify // that SDA is pulled HIGH in the following step. - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT - digitalWrite(sda_pin_, LOW); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT + digitalWrite(this->sda_pin_, LOW); // NOLINT // By now, any stuck device ought to have sent all remaining bits of its // transaction, meaning that it should have freed up the SDA line, resulting // in SDA being pulled up. - if (digitalRead(sda_pin_) == LOW) { // NOLINT + if (digitalRead(this->sda_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -248,8 +261,8 @@ void ArduinoI2CBus::recover_() { // SCL and SDA are already high at this point, so we can generate a START // condition by making the SDA signal LOW. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, OUTPUT); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, OUTPUT); // NOLINT // From the specification: // "A START condition immediately followed by a STOP condition (void @@ -258,10 +271,10 @@ void ArduinoI2CBus::recover_() { // Finally, we'll bring the I2C bus into a starting state by generating // a STOP condition. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index 71e91e770bb..9feea9f7ffa 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -22,13 +22,17 @@ class ArduinoI2CBus final : public InternalI2CBus, public Component { size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } - void set_scan(bool scan) { scan_ = scan; } - void set_sda_pin(uint8_t sda_pin) { sda_pin_ = sda_pin; } - void set_scl_pin(uint8_t scl_pin) { scl_pin_ = scl_pin; } - void set_frequency(uint32_t frequency) { frequency_ = frequency; } - void set_timeout(uint32_t timeout) { timeout_ = timeout; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } + void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } int get_port() const override { return 0; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index b13b525abbb..931bcfa0889 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -32,8 +32,8 @@ void IDFI2CBus::setup() { i2c_master_bus_config_t bus_conf{}; memset(&bus_conf, 0, sizeof(bus_conf)); - bus_conf.sda_io_num = gpio_num_t(sda_pin_); - bus_conf.scl_io_num = gpio_num_t(scl_pin_); + bus_conf.sda_io_num = gpio_num_t(this->sda_pin_); + bus_conf.scl_io_num = gpio_num_t(this->scl_pin_); bus_conf.glitch_ignore_cnt = 7; #if SOC_LP_I2C_SUPPORTED if (this->lp_mode_) { @@ -59,7 +59,7 @@ void IDFI2CBus::setup() { } #endif bus_conf.i2c_port = this->port_; - bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_; + bus_conf.flags.enable_internal_pullup = this->sda_pullup_enabled_ || this->scl_pullup_enabled_; esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_); if (err != ESP_OK) { ESP_LOGW(TAG, "i2c_new_master_bus failed: %s", esp_err_to_name(err)); @@ -67,18 +67,21 @@ void IDFI2CBus::setup() { return; } - i2c_device_config_t dev_conf{}; - memset(&dev_conf, 0, sizeof(dev_conf)); - dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; - dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; - dev_conf.scl_speed_hz = this->frequency_; - dev_conf.scl_wait_us = this->timeout_; - err = i2c_master_bus_add_device(this->bus_, &dev_conf, &this->dev_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + this->dev_ = this->add_device_(this->frequency_); + if (this->dev_ == nullptr) { this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + this->add_frequency(this->frequency_); + for (auto &device : this->devices_) { + device.dev = device.frequency == this->frequency_ ? this->dev_ : this->add_device_(device.frequency); + if (device.dev == nullptr) { + this->mark_failed(); + return; + } + } +#endif this->initialized_ = true; @@ -88,6 +91,21 @@ void IDFI2CBus::setup() { } } +i2c_master_dev_handle_t IDFI2CBus::add_device_(uint32_t frequency) { + i2c_device_config_t dev_conf{}; + dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; + dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; + dev_conf.scl_speed_hz = frequency; + dev_conf.scl_wait_us = this->timeout_; + i2c_master_dev_handle_t dev{}; + const esp_err_t err = i2c_master_bus_add_device(this->bus_, &dev_conf, &dev); + if (err != ESP_OK) { + ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + return nullptr; + } + return dev; +} + void IDFI2CBus::dump_config() { ESP_LOGCONFIG(TAG, "I2C Bus:"); ESP_LOGCONFIG(TAG, @@ -95,7 +113,7 @@ void IDFI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %" PRIu32 " Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 "us", this->timeout_); } switch (this->recovery_result_) { @@ -111,10 +129,10 @@ void IDFI2CBus::dump_config() { } if (this->scan_) { ESP_LOGCONFIG(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGCONFIG(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first); } else { @@ -129,7 +147,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s size_t read_count) { // logging is only enabled with v level, if warnings are shown the caller // should log them - if (!initialized_) { + if (!this->initialized_) { ESP_LOGW(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -198,14 +216,44 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s return ERROR_OK; } +#ifdef I2C_PORT_FREQUENCY_COUNT +void IDFI2CBus::add_frequency(uint32_t frequency) { + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + return; + } + } + this->devices_.push_back({frequency, nullptr}); +} + +ErrorCode IDFI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + if (!this->initialized_) { + this->frequency_ = frequency; + return ERROR_OK; + } + // Every handle exists since setup(); a frequency nobody registered has none + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + this->dev_ = device.dev; + this->frequency_ = frequency; + return ERROR_OK; + } + } + return ERROR_UNKNOWN; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf void IDFI2CBus::recover_() { ESP_LOGI(TAG, "Performing bus recovery"); - const auto scl_pin = static_cast(scl_pin_); - const auto sda_pin = static_cast(sda_pin_); + const auto scl_pin = static_cast(this->scl_pin_); + const auto sda_pin = static_cast(this->sda_pin_); // For the upcoming operations, target for a 60kHz toggle frequency. // 1000kHz is the maximum frequency for I2C running in standard-mode, @@ -218,7 +266,7 @@ void IDFI2CBus::recover_() { // Configure SCL pin for open drain input/output, with a pull up resistor. gpio_set_level(scl_pin, 1); gpio_config_t scl_config{}; - scl_config.pin_bit_mask = 1ULL << scl_pin_; + scl_config.pin_bit_mask = 1ULL << this->scl_pin_; scl_config.mode = GPIO_MODE_INPUT_OUTPUT_OD; scl_config.pull_up_en = GPIO_PULLUP_ENABLE; scl_config.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -228,7 +276,7 @@ void IDFI2CBus::recover_() { // Configure SDA pin for open drain input/output, with a pull up resistor. gpio_set_level(sda_pin, 1); gpio_config_t sda_conf{}; - sda_conf.pin_bit_mask = 1ULL << sda_pin_; + sda_conf.pin_bit_mask = 1ULL << this->sda_pin_; sda_conf.mode = GPIO_MODE_INPUT_OUTPUT_OD; sda_conf.pull_up_en = GPIO_PULLUP_ENABLE; sda_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -240,7 +288,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -271,7 +319,7 @@ void IDFI2CBus::recover_() { } if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } @@ -281,7 +329,7 @@ void IDFI2CBus::recover_() { // in SDA being pulled up. if (gpio_get_level(sda_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -308,7 +356,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); gpio_set_level(sda_pin, 1); - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 92e96f649b6..b9d8824d25e 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -34,13 +34,28 @@ class IDFI2CBus final : public InternalI2CBus, public Component { #endif int get_port() const override { return this->port_; } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Register a frequency used on the bus so setup() creates its device handle + void add_frequency(uint32_t frequency); + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); + i2c_master_dev_handle_t add_device_(uint32_t frequency); RecoveryCode recovery_result_{}; protected: i2c_master_dev_handle_t dev_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + // One handle per frequency used on the bus, registered at code generation + struct FrequencyDevice { + uint32_t frequency; + i2c_master_dev_handle_t dev; + }; + StaticVector devices_; +#endif i2c_master_bus_handle_t bus_{}; i2c_port_t port_{}; uint8_t sda_pin_{}; diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8064016a520..086baab26ca 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -21,7 +21,12 @@ class HostI2CBus final : public I2CBus, public Component { void set_device(const std::string &device) { this->device_ = device; } void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + // The kernel driver owns the clock, so switch_frequency keeps the base default + uint32_t get_frequency() const override { return this->frequency_; } +#endif const std::string &get_device() const { return this->device_; } diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 4145cd58592..ee6e66893e0 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -120,15 +120,34 @@ ErrorCode ZephyrI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer return ERROR_OK; } -void ZephyrI2CBus::set_frequency(uint32_t frequency) { - this->dev_config_ &= ~I2C_SPEED_MASK; - if (frequency >= 400000) { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_FAST); - } else { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_STANDARD); - } +static uint32_t speed_config(uint32_t dev_config, uint32_t frequency) { + const uint32_t speed = frequency >= 400000 ? I2C_SPEED_FAST : I2C_SPEED_STANDARD; + return (dev_config & ~I2C_SPEED_MASK) | I2C_SPEED_SET(speed); } +void ZephyrI2CBus::set_frequency(uint32_t frequency) { +#ifdef I2C_PORT_FREQUENCY_COUNT + this->frequency_ = frequency; +#endif + this->dev_config_ = speed_config(this->dev_config_, frequency); +} + +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ZephyrI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + const uint32_t dev_config = speed_config(this->dev_config_, frequency); + // Until ready, setup() applies dev_config_ + if (this->is_ready() && i2c_configure(this->i2c_dev_, dev_config) < 0) { + return ERROR_UNKNOWN; + } + this->dev_config_ = dev_config; + this->frequency_ = frequency; + return ERROR_OK; +} +#endif + } // namespace esphome::i2c #endif diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3ada1e0a0f2..413d78d6a38 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -22,6 +22,10 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } void set_frequency(uint32_t frequency); +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif int get_port() const override { return 0; } @@ -31,6 +35,9 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { uint8_t sda_pin_{}; uint8_t scl_pin_{}; uint32_t dev_config_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{}; +#endif }; } // namespace esphome::i2c diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index a1945e97c52..368e34f3250 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -1,7 +1,14 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_CHANNELS, CONF_ID +from esphome.const import ( + CONF_CHANNEL, + CONF_CHANNELS, + CONF_FREQUENCY, + CONF_I2C_ID, + CONF_ID, +) +from esphome.core import CORE, ID from esphome.types import ConfigType CODEOWNERS = ["@andreashergert1984"] @@ -16,14 +23,51 @@ TCA9548AChannel = tca9548a_ns.class_("TCA9548AChannel", i2c.I2CBus) MULTI_CONF = True CONF_BUS_ID = "bus_id" +# Sizes the root bus's frequency table; only emitted when a frequency is set +_request_port_frequency_slot = cg.slot_counter("I2C_PORT_FREQUENCY_COUNT") + + +def _root_bus_id(bus_id: ID) -> ID: + """The hardware bus behind a port, through any multiplexers in between.""" + parents = { + str(chan[CONF_BUS_ID]): mux[CONF_I2C_ID] + for mux in CORE.config.get(DOMAIN, []) + for chan in mux.get(CONF_CHANNELS, []) + } + while (parent := parents.get(str(bus_id))) is not None: + bus_id = parent + return bus_id + + +def _declared_bus_type(bus_id: ID) -> cg.MockObjClass | None: + """The class the i2c component declared for a bus, or None for an external bus.""" + for bus in CORE.config.get(i2c.DOMAIN, []): + if bus[CONF_ID].id == bus_id.id: + return bus[CONF_ID].type + return None + + +async def _add_frequency(root_bus_id: ID, frequency: int) -> None: + """Count the frequency against the root bus; the built-in ESP32 bus also + creates its device handle in setup().""" + _request_port_frequency_slot(str(root_bus_id)) + if _declared_bus_type(root_bus_id) is i2c.IDFI2CBus: + root = await cg.get_variable(root_bus_id) + cg.add(root.add_frequency(frequency)) + + +_FREQUENCY = cv.All(cv.frequency, cv.Range(min=0, min_included=False)) + CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(TCA9548AComponent), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, cv.Optional(CONF_CHANNELS, default=[]): cv.ensure_list( { cv.Required(CONF_BUS_ID): cv.declare_id(TCA9548AChannel), cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=7), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, } ), } @@ -37,8 +81,15 @@ 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) + root_bus_id = _root_bus_id(config[CONF_I2C_ID]) + if (frequency := config.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(var.set_frequency(int(frequency))) for conf in config[CONF_CHANNELS]: chan = cg.new_Pvariable(conf[CONF_BUS_ID]) cg.add(chan.set_parent(var)) cg.add(chan.set_channel(conf[CONF_CHANNEL])) + if (frequency := conf.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(chan.set_frequency(int(frequency))) diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index f10578c97c2..ac33ca8dedb 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -1,4 +1,6 @@ #include "tca9548a.h" + +#include #include "esphome/core/log.h" namespace esphome::tca9548a { @@ -7,20 +9,111 @@ ESPHOME_LOG_TAG(TAG, "tca9548a"); i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) { - auto err = this->parent_->switch_to_channel(channel_); +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t select_frequency = this->select_frequency_(); + if (select_frequency != 0) { + return this->write_readv_at_port_frequency_(select_frequency, address, write_buffer, write_count, read_buffer, + read_count); + } +#endif + auto err = this->parent_->switch_to_channel(this->channel_); if (err != i2c::ERROR_OK) return err; err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); this->parent_->disable_all_channels(); return err; } + +#ifdef I2C_PORT_FREQUENCY_COUNT +// The multiplexer's own frequency, else the slower of the bus and the port +uint32_t TCA9548AChannel::select_frequency_() const { + if (this->parent_->frequency_ != 0) + return this->parent_->frequency_; + if (this->frequency_ == 0) + return 0; + // A bus that does not report its frequency gets the port's, and fails the switch + const uint32_t bus_frequency = this->parent_->bus_->get_frequency(); + return bus_frequency != 0 ? std::min(this->frequency_, bus_frequency) : this->frequency_; +} + +// The port frequency, else the multiplexer's +uint32_t TCA9548AChannel::transfer_frequency_() const { + return this->frequency_ != 0 ? this->frequency_ : this->parent_->frequency_; +} + +i2c::ErrorCode TCA9548AChannel::write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, + const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + const uint32_t original_frequency = this->parent_->bus_->get_frequency(); + // A failed switch leaves the bus as it was + auto err = this->switch_bus_(select_frequency); + if (err != i2c::ERROR_OK) + return err; + + err = this->parent_->switch_to_channel(this->channel_); + if (err == i2c::ERROR_OK) { + err = this->transfer_(select_frequency, address, write_buffer, write_count, read_buffer, read_count); + this->parent_->disable_all_channels(); + } + const i2c::ErrorCode restored = this->switch_bus_(original_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Transfer at the port frequency, back to the select frequency for the deselect +i2c::ErrorCode TCA9548AChannel::transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count) { + const uint32_t transfer_frequency = this->transfer_frequency_(); + if (transfer_frequency == select_frequency) { + return this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + auto err = this->switch_bus_(transfer_frequency); + if (err == i2c::ERROR_OK) { + err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + const i2c::ErrorCode restored = this->switch_bus_(select_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Forward to the shared upstream bus +i2c::ErrorCode TCA9548AChannel::switch_frequency(uint32_t frequency) { + return this->parent_->bus_->switch_frequency(frequency); +} + +i2c::ErrorCode TCA9548AChannel::switch_bus_(uint32_t frequency) { + const i2c::ErrorCode err = this->switch_frequency(frequency); + if (err != i2c::ERROR_OK) { + this->parent_->status_set_error(LOG_STR("Failed to switch the bus frequency")); + } + return err; +} + +uint32_t TCA9548AChannel::get_frequency() const { return this->parent_->bus_->get_frequency(); } +#endif + void TCA9548AComponent::setup() { uint8_t status = 0; - if (this->read(&status, 1) != i2c::ERROR_OK) { +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t original_frequency = this->bus_->get_frequency(); + if (this->frequency_ != 0 && this->bus_->switch_frequency(this->frequency_) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif + const i2c::ErrorCode err = this->read(&status, 1); +#ifdef I2C_PORT_FREQUENCY_COUNT + const bool restored = this->frequency_ == 0 || this->bus_->switch_frequency(original_frequency) == i2c::ERROR_OK; +#endif + if (err != i2c::ERROR_OK) { ESP_LOGE(TAG, "TCA9548A failed"); this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + if (!restored) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif ESP_LOGD(TAG, "Channels currently open: %d", status); } void TCA9548AComponent::dump_config() { @@ -32,14 +125,13 @@ i2c::ErrorCode TCA9548AComponent::switch_to_channel(uint8_t channel) { if (this->is_failed()) return i2c::ERROR_NOT_INITIALIZED; - uint8_t channel_val = 1 << channel; + const uint8_t channel_val = 1 << channel; return this->write(&channel_val, 1); } void TCA9548AComponent::disable_all_channels() { if (this->write(&TCA9548A_DISABLE_CHANNELS_COMMAND, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Failed to disable all channels."); - this->status_set_error(); // couldn't disable channels, set error status + this->status_set_error(LOG_STR("Failed to disable all channels.")); } } diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index a98c226d322..5b0d8a0a769 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -10,13 +10,30 @@ static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; class TCA9548AChannel final : public i2c::I2CBus { public: - void set_channel(uint8_t channel) { channel_ = channel; } - void set_parent(TCA9548AComponent *parent) { parent_ = parent; } + void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_parent(TCA9548AComponent *parent) { this->parent_ = parent; } 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; +#ifdef I2C_PORT_FREQUENCY_COUNT + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + i2c::ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override; +#endif + protected: +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t select_frequency_() const; + uint32_t transfer_frequency_() const; + i2c::ErrorCode write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count); + i2c::ErrorCode transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count); + /// Switch the upstream bus, flagging the multiplexer on failure + i2c::ErrorCode switch_bus_(uint32_t frequency); + uint32_t frequency_{0}; +#endif uint8_t channel_; TCA9548AComponent *parent_; }; @@ -30,8 +47,15 @@ class TCA9548AComponent final : public Component, public i2c::I2CDevice { i2c::ErrorCode switch_to_channel(uint8_t channel); void disable_all_channels(); +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Addressing frequency, and the default for the ports + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#endif protected: friend class TCA9548AChannel; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{0}; +#endif }; } // namespace esphome::tca9548a diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 731c0d20171..d4aca85962f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -137,6 +137,7 @@ #define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 diff --git a/tests/components/tca9548a/common.yaml b/tests/components/tca9548a/common.yaml index bfaf1e26fbb..eea1b9964db 100644 --- a/tests/components/tca9548a/common.yaml +++ b/tests/components/tca9548a/common.yaml @@ -2,9 +2,13 @@ tca9548a: - id: multiplex0 i2c_id: i2c_bus address: 0x70 + frequency: 400 kHz channels: - bus_id: multiplex0_chan0 channel: 0 + - bus_id: multiplex0_chan1 + channel: 1 + frequency: 1 MHz - id: multiplex1 i2c_id: i2c_bus address: 0x71 diff --git a/tests/components/tca9548a/test.nrf52-adafruit.yaml b/tests/components/tca9548a/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..a6641791371 --- /dev/null +++ b/tests/components/tca9548a/test.nrf52-adafruit.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + tca9548a: !include common.yaml From 78d3b9f5a1f255154f45ec9de26ac80c1a9f2296 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Sat, 10 Oct 2026 21:17:01 +0000 Subject: [PATCH 203/435] [nrf52] Fix BLE OTA/log device discovery & transport selection on macOS (#17101) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/nrf52/__init__.py | 8 +- esphome/components/nrf52/ble_logger.py | 23 ++++- esphome/components/nrf52/ota.py | 21 ++--- tests/unit_tests/test_nrf52_ota.py | 121 +++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_ota.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index de9dc6401e4..f2a78259973 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -732,13 +732,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool: return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths - from .ble_logger import is_mac_address + from .ble_logger import is_ble_address from .ota import smpmgr_scan, smpmgr_upload if host == "BLE": mcumgr_device = asyncio.run(smpmgr_scan(CORE.name)) - if is_mac_address(host): + if is_ble_address(host): mcumgr_device = host if mcumgr_device: @@ -753,7 +753,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: def show_logs(config: ConfigType, args, devices: list[str]) -> bool: address = devices[0] - from .ble_logger import is_mac_address, logger_connect, logger_scan + from .ble_logger import is_ble_address, logger_connect, logger_scan if devices[0] == "BLE": ble_device = asyncio.run(logger_scan(CORE.name)) @@ -762,7 +762,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: else: return True - if is_mac_address(address): + if is_ble_address(address): asyncio.run(logger_connect(address)) return True return False diff --git a/esphome/components/nrf52/ble_logger.py b/esphome/components/nrf52/ble_logger.py index f74a49ea897..5edcddfd326 100644 --- a/esphome/components/nrf52/ble_logger.py +++ b/esphome/components/nrf52/ble_logger.py @@ -4,6 +4,7 @@ import re from typing import Final from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.backends.scanner import AdvertisementData from bleak.exc import ( BleakCharacteristicNotFoundError, BleakDBusError, @@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" MAC_ADDRESS_PATTERN: Final = re.compile( r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE ) +# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address +UUID_PATTERN: Final = re.compile( + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + flags=re.IGNORECASE, +) -def is_mac_address(value: str) -> bool: - return MAC_ADDRESS_PATTERN.match(value) +def is_ble_address(value: str) -> bool: + return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value)) + + +def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool: + # device.name can be a stale cached name on macOS; adv.local_name is what is on the air now + return name in (device.name, adv.local_name) + + +async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None: + return await BleakScanner.find_device_by_filter( + lambda device, adv: _name_matches(device, adv, name), timeout=timeout + ) async def logger_scan(name: str) -> BLEDevice | None: _LOGGER.info("Scanning bluetooth for %s...", name) - device = await BleakScanner.find_device_by_name(name) + device = await find_device_by_name(name) if not device: _LOGGER.error("%s Bluetooth LE device was not found!", name) return device diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index cafeda64785..9115e218747 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -4,7 +4,6 @@ import json import logging from pathlib import Path -from bleak import BleakScanner from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient @@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport from esphome.core import EsphomeError from esphome.espota2 import ProgressBar -from .ble_logger import is_mac_address +from .ble_logger import find_device_by_name, is_ble_address -SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84" BLE_SCAN_TIMEOUT = 10.0 # seconds RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute @@ -45,12 +43,12 @@ def _json_state(o: object) -> object: async def smpmgr_scan(name: str) -> str: _LOGGER.info("Scanning bluetooth for %s...", name) - for device in await BleakScanner.discover( - timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID] - ): - if device.name == name: - return device.address - raise EsphomeError(f"BLE device {name} with OTA service not found") + # No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on. + # The SMP service is checked on connect. + device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT) + if device is None: + raise EsphomeError(f"BLE device {name} not found") + return device.address async def smpmgr_upload(device: str, firmware: Path) -> None: @@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes: async def _smpmgr_upload(device: str, firmware: Path) -> None: image_tlv_sha256 = _get_image_tlv_sha256(firmware) - if is_mac_address(device): + if is_ble_address(device): smp_client = SMPClient(SMPBLETransport(), device) else: smp_client = SMPClient(SMPSerialTransport(), device) @@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: ) from exc raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: - raise EsphomeError(f"Connection error with {device}") from exc + # Reached when a device with the right name has no SMP service, among other causes + raise EsphomeError(f"Connection error with {device}: {exc}") from exc _LOGGER.info("Connected %s...", device) try: diff --git a/tests/unit_tests/test_nrf52_ota.py b/tests/unit_tests/test_nrf52_ota.py new file mode 100644 index 00000000000..c0b5e202f70 --- /dev/null +++ b/tests/unit_tests/test_nrf52_ota.py @@ -0,0 +1,121 @@ +"""Tests for nRF52 BLE device discovery and OTA transport selection.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from esphome.components.nrf52 import ble_logger, ota, show_logs +from esphome.core import EsphomeError + +MAC = "AA:BB:CC:DD:EE:FF" +# macOS/CoreBluetooth identifies peripherals by UUID instead of MAC address +UUID = "0FA1B2C3-D4E5-F607-1829-3A4B5C6D7E8F" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (MAC, True), + (MAC.lower(), True), + (UUID, True), + ("/dev/ttyACM0", False), + ("COM3", False), + ], +) +def test_is_ble_address(value: str, expected: bool) -> None: + assert ble_logger.is_ble_address(value) is expected + + +def _scan_result( + monkeypatch, devices: list[tuple[str, str | None, str | None]] +) -> AsyncMock: + """Stub the bleak scan so the filter is applied to the given (address, name, local_name) devices.""" + + async def find_device_by_filter(filterfunc, timeout): + for address, name, local_name in devices: + device = SimpleNamespace(address=address, name=name) + if filterfunc(device, SimpleNamespace(local_name=local_name)): + return device + return None + + mock = AsyncMock(side_effect=find_device_by_filter) + monkeypatch.setattr(ble_logger.BleakScanner, "find_device_by_filter", mock) + return mock + + +def test_scan_matches_advertised_local_name(monkeypatch) -> None: + """On macOS device.name is cached from an earlier connection; the live name is adv.local_name.""" + _scan_result(monkeypatch, [(UUID, "stale-old-name", "my-device")]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == UUID + + +def test_scan_matches_device_name(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "my-device", None)]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == MAC + + +def test_scan_raises_when_no_device_found(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "someone-else", "someone-else")]) + with pytest.raises(EsphomeError, match="not found"): + asyncio.run(ota.smpmgr_scan("my-device")) + + +@pytest.mark.parametrize( + ("device", "transport"), + [("/dev/ttyACM0", "serial"), ("COM3", "serial"), (MAC, "ble"), (UUID, "ble")], +) +def test_upload_transport(monkeypatch, device: str, transport: str) -> None: + """Serial ports go to the serial transport; MAC addresses and macOS UUIDs to BLE.""" + captured: dict = {} + + class FakeClient: + def __init__(self, transport, address): + captured["transport"] = transport + captured["address"] = address + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass + + monkeypatch.setattr(ota, "_get_image_tlv_sha256", lambda firmware: b"") + monkeypatch.setattr(ota, "_smpmgr_upload_connected", AsyncMock()) + monkeypatch.setattr(ota, "SMPSerialTransport", lambda: "serial") + monkeypatch.setattr(ota, "SMPBLETransport", lambda: "ble") + monkeypatch.setattr(ota, "SMPClient", FakeClient) + + asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"))) # pylint: disable=protected-access + assert captured == {"transport": transport, "address": device} + + +@pytest.mark.parametrize( + ("device", "connected_to"), + [("BLE", UUID), (MAC, MAC), (UUID, UUID), ("my-device.local", None)], +) +def test_show_logs_routing(monkeypatch, device: str, connected_to: str | None) -> None: + """A scanned device, a MAC or a UUID is handed to the BLE logger; anything else falls through.""" + scan = AsyncMock(return_value=SimpleNamespace(address=UUID)) + connect = AsyncMock(return_value=0) + monkeypatch.setattr(ble_logger, "logger_scan", scan) + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + handled = show_logs(config={}, args=None, devices=[device]) + + assert handled is (connected_to is not None) + if connected_to is None: + connect.assert_not_called() + else: + connect.assert_awaited_once_with(connected_to) + + +def test_show_logs_returns_when_the_scan_finds_nothing(monkeypatch) -> None: + monkeypatch.setattr(ble_logger, "logger_scan", AsyncMock(return_value=None)) + connect = AsyncMock() + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + assert show_logs(config={}, args=None, devices=["BLE"]) is True + connect.assert_not_called() From 642c3b7a9ecf333b25ce71b39d28adede90144f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 11:43:21 -1000 Subject: [PATCH 204/435] [const] Add the milliampere hour unit to the shared constants (#20495) --- esphome/components/const/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index e93d508a3d8..eb999a05ff4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -73,3 +73,4 @@ KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" UNIT_COUNTS = "#" +UNIT_MILLIAMPERE_HOUR = "mAh" From 5f43b8168eac44bfef1d5490e0e5951fafc01d30 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:00:39 -1000 Subject: [PATCH 205/435] Bump bundled esphome-device-builder to 1.23.1 (#20497) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9edbd6e4f9b..c9aa3dba62c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 RUN \ platformio settings set enable_telemetry No \ From 924d7364ba9c419b5dbf1c027a256291679f706e Mon Sep 17 00:00:00 2001 From: Paul Traina Date: Sat, 10 Oct 2026 16:22:56 -0700 Subject: [PATCH 206/435] [mipi_spi] ESP32-2432S028-9342 panel is RGB, not BGR (#19419) --- esphome/components/mipi_spi/models/cyd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index 0a35cb4feea..f8a96739608 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,3 +1,4 @@ +from esphome.components.mipi import MODE_RGB from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER from .ili import GC9A01A, ILI9341, ILI9342, ST7789V @@ -21,8 +22,10 @@ ST7789V.extend( # fmt: off ILI9342.extend( - # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller + # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller. + # The panel on this board is wired RGB, not the BGR default. "ESP32-2432S028-9342", + color_order=MODE_RGB, data_rate="40MHz", cs_pin={"number": 15, "ignore_strapping_warning": True}, dc_pin={"number": 2, "ignore_strapping_warning": True}, From 1763adf526b338b52d775140b9128e253bcef0e6 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:24:13 +0200 Subject: [PATCH 207/435] [zigbee] add support for SED on esp32 (#20487) --- esphome/components/zigbee/__init__.py | 28 +++++++++++++--- esphome/components/zigbee/const.py | 1 + esphome/components/zigbee/const_zephyr.py | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 37 ++++++++++++++-------- esphome/components/zigbee/zigbee_esp32.h | 5 ++- esphome/components/zigbee/zigbee_esp32.py | 7 ++++ esphome/components/zigbee/zigbee_zephyr.py | 2 +- tests/components/zigbee/common_esp32.yaml | 5 +-- 8 files changed, 63 insertions(+), 23 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 0dc3b4b6f71..d01eddb9ff7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -13,7 +13,14 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S31, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START +from esphome.const import ( + CONF_ID, + CONF_INTERNAL, + CONF_MODEL, + CONF_NAME, + CONF_ON_START, + CONF_POLLING_INTERVAL, +) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType @@ -25,6 +32,7 @@ from .const import ( CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, @@ -35,7 +43,6 @@ from .const import ( from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER_ZEPHYR, - CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, ) @@ -128,8 +135,11 @@ NUMBER_SCHEMA = ( def _validate_router_sleepy(config: ConfigType) -> ConfigType: - if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY): - raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_ROUTER): + if config.get(CONF_SLEEPY): + raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_POLLING_INTERVAL): + raise cv.Invalid(f"{CONF_POLLING_INTERVAL} is only valid for end devices.") return config @@ -160,9 +170,17 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), - cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All( + cv.Optional(CONF_SLEEPY, default=False): cv.All( cv.boolean, ), + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.requires_component(ESP32_DOMAIN), + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriodMilliseconds(milliseconds=200), + max=cv.TimePeriodMinutes(minutes=60), # 1 hour + ), + ), } ).extend(cv.COMPONENT_SCHEMA), _validate_router_sleepy, diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 86cac665b43..aad2b5c0e39 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -72,6 +72,7 @@ CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_SLEEPY = "sleepy" CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index bf8e8287c4b..7177b9fc798 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -4,7 +4,6 @@ CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" -CONF_SLEEPY = "sleepy" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 89417e748d2..7c197133c2b 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -319,7 +319,7 @@ void ZigbeeComponent::setup() { #else esp_zigbee_zed_config_s zb_zed_cfg = { .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, + .keep_alive = this->keep_alive_, }; device_config.zed_config = zb_zed_cfg; #endif @@ -339,6 +339,8 @@ void ZigbeeComponent::setup() { #ifdef CONFIG_ZB_ZCZR ezb_bdb_set_router_rejoin_required(true); +#else + ezb_nwk_set_rx_on_when_idle(!this->sleepy_); // if sleepy, disable RX when idle #endif ezb_aps_secur_enable_distributed_security(false); @@ -419,18 +421,27 @@ void ZigbeeComponent::loop() { void ZigbeeComponent::dump_config() { if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n" - " Device is joined to the network: %s\n" - " Current channel: %d\n" - " Short addr: 0x%04X\n" - " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), - ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + ESP_LOGCONFIG( + TAG, + "Zigbee\n" + " Model: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); esp_zigbee_lock_release(); } else { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 093ae959390..28ea64c8cea 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -20,7 +20,6 @@ namespace esphome::zigbee { /* Zigbee configuration */ -static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; @@ -43,6 +42,8 @@ class ZigbeeComponent final : public Component { void loop() override; void dump_config() override; + void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } + void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); @@ -73,6 +74,8 @@ class ZigbeeComponent final : public Component { bool is_joined() { return this->joined_; } protected: + bool sleepy_{false}; + uint32_t keep_alive_{3000}; struct { uint8_t *model; uint8_t *manufacturer; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 103e901b13b..68136d57192 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -24,6 +24,7 @@ from esphome.const import ( CONF_MAX_LENGTH, CONF_MODEL, CONF_NAME, + CONF_POLLING_INTERVAL, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, @@ -45,6 +46,7 @@ from .const import ( CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, POWER_SOURCE, @@ -412,6 +414,11 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config.get(CONF_SLEEPY): + cg.add(var.set_sleepy(True)) + if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: + cg.add(var.set_keep_alive(keep_alive)) + cg.add( var.set_basic_cluster( config[CONF_MODEL], diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 3f863140e69..8197796b6db 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -24,6 +24,7 @@ from .const import ( BACNET_UNITS, CONF_POWER_SOURCE, CONF_ROUTER, + CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -35,7 +36,6 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_SLEEPY, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 3d19180c064..84b79bb84f0 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -58,8 +58,9 @@ switch: zigbee: model: zigbee_test - router: false - power_source: MAINS_SINGLE_PHASE + sleepy: true + polling_interval: 2s + power_source: BATTERY on_join: then: - logger.log: "Joined network" From 3be8c5b6b8f7bd769d1f6a81963021668a1661d1 Mon Sep 17 00:00:00 2001 From: fakuivan Date: Sat, 10 Oct 2026 22:07:36 -0300 Subject: [PATCH 208/435] [canbus] Fix high frame rates causing crashes due to unbounded iteration (#14481) 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/canbus/__init__.py | 20 ++++++++++++++++---- esphome/components/canbus/canbus.cpp | 22 +++++++++++++++------- esphome/components/canbus/canbus.h | 6 ++++-- tests/components/canbus/common.yaml | 1 + 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 51bda0c75df..23dc41d5092 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -20,6 +20,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request" CONF_CANBUS_ID = "canbus_id" CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" +CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" +DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h +DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h def validate_id(config: ConfigType) -> ConfigType: @@ -87,8 +90,13 @@ CANBUS_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(CanbusComponent), cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True), + cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum( + CAN_SPEEDS, upper=True + ), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, + cv.Optional( + CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP + ): cv.positive_not_null_int, cv.Optional(CONF_ON_FRAME): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger), @@ -109,9 +117,13 @@ CANBUS_SCHEMA.add_extra(validate_id) async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - cg.add(var.set_can_id([config[CONF_CAN_ID]])) - cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) - cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]])) + cg.add(var.set_can_id(config[CONF_CAN_ID])) + if config[CONF_USE_EXTENDED_ID]: + cg.add(var.set_use_extended_id(True)) + if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE: + cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate])) + if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP: + cg.add(var.set_max_frames_per_loop(max_frames)) for conf in config.get(CONF_ON_FRAME, []): can_id = conf[CONF_CAN_ID] diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 828eb77d1ca..88eed2246fd 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -66,16 +66,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { void Canbus::loop() { struct CanFrame can_message; - // read all messages until queue is empty - int message_counter = 0; - while (this->read_message(&can_message) == canbus::ERROR_OK) { - message_counter++; + uint32_t message_counter = 0; + for (; message_counter < this->max_frames_per_loop_; message_counter++) { + if (this->read_message(&can_message) != canbus::ERROR_OK) { + break; + } + if (can_message.use_extended_id) { - ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter, + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1, can_message.can_id, can_message.can_data_length_code); } else { - ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id, - can_message.can_data_length_code); + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1, + can_message.can_id, can_message.can_data_length_code); } std::vector data; @@ -99,6 +101,12 @@ void Canbus::loop() { } } } + + if (message_counter == this->max_frames_per_loop_) { + ESP_LOGW(TAG, + "Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped", + this->max_frames_per_loop_); + } } } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 8685c026b0b..432eb77e13d 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -82,6 +82,7 @@ class Canbus : public Component { void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } + void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } void add_trigger(CanbusTrigger *trigger); /** @@ -100,8 +101,9 @@ class Canbus : public Component { template friend class CanbusSendAction; std::vector triggers_{}; uint32_t can_id_; - bool use_extended_id_; - CanSpeed bit_rate_; + uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py + CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py + bool use_extended_id_{false}; CallbackManager &data)> callback_manager_{}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 57ee25bfc16..14e0d6bf5a6 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -5,6 +5,7 @@ canbus: tx_pin: 5 can_id: 4 bit_rate: 50kbps + max_frames_per_loop: 100 on_frame: - can_id: 500 then: From 8af91c966304aed12af8d33374ec4e0afd5d5b70 Mon Sep 17 00:00:00 2001 From: Achilleas Kotsis Date: Sun, 11 Oct 2026 04:23:31 +0300 Subject: [PATCH 209/435] [feedback] Support for overshoot_duration (#13186) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/feedback/cover.py | 22 ++++++++++++++++ .../components/feedback/feedback_cover.cpp | 26 +++++++++++++++++-- esphome/components/feedback/feedback_cover.h | 9 +++++++ esphome/core/defines.h | 1 + tests/components/feedback/common.yaml | 13 ++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 032d01c8e51..9fbda9a9737 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -24,6 +24,7 @@ CONF_HAS_BUILT_IN_ENDSTOP = "has_built_in_endstop" CONF_INFER_ENDSTOP_FROM_MOVEMENT = "infer_endstop_from_movement" CONF_DIRECTION_CHANGE_WAIT_TIME = "direction_change_wait_time" CONF_ACCELERATION_WAIT_TIME = "acceleration_wait_time" +CONF_OVERSHOOT_DURATION = "overshoot_duration" CONF_OBSTACLE_ROLLBACK = "obstacle_rollback" endstop_ns = cg.esphome_ns.namespace("feedback") @@ -82,6 +83,9 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( cv.Optional( CONF_ACCELERATION_WAIT_TIME, "0s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_OVERSHOOT_DURATION, "0s" + ): cv.positive_time_period_milliseconds, cv.Optional(CONF_OBSTACLE_ROLLBACK, default="10%"): cv.percentage, }, ) @@ -89,10 +93,25 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( ) +def validate_overshoot(config: ConfigType) -> ConfigType: + if not config[CONF_OVERSHOOT_DURATION].total_milliseconds: + return config + if config[CONF_HAS_BUILT_IN_ENDSTOP]: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect with {CONF_HAS_BUILT_IN_ENDSTOP}" + ) + if CONF_OPEN_ENDSTOP in config and CONF_CLOSE_ENDSTOP in config: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect when both endstop sensors are supplied" + ) + return config + + CONFIG_SCHEMA = cv.All( CONFIG_FEEDBACK_COVER_BASE_SCHEMA, cv.has_none_or_all_keys(CONF_OPEN_SENSOR, CONF_CLOSE_SENSOR), validate_infer_endstop, + validate_overshoot, ) @@ -160,4 +179,7 @@ async def to_code(config: ConfigType) -> None: var.set_direction_change_waittime(config[CONF_DIRECTION_CHANGE_WAIT_TIME]) ) cg.add(var.set_acceleration_wait_time(config[CONF_ACCELERATION_WAIT_TIME])) + if (overshoot := config[CONF_OVERSHOOT_DURATION]).total_milliseconds: + cg.add_define("USE_FEEDBACK_COVER_OVERSHOOT") + cg.add(var.set_overshoot_duration(overshoot)) cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK])) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index fb6c3929b04..8fa56bd370e 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -79,6 +79,11 @@ void FeedbackCover::dump_config() { if (this->acceleration_wait_time_) { ESP_LOGCONFIG(TAG, " Acceleration wait time: %.1fs", this->acceleration_wait_time_ / 1e3f); } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + if (this->overshoot_duration_) { + ESP_LOGCONFIG(TAG, " Overshoot duration: %.1fs", this->overshoot_duration_ / 1e3f); + } +#endif #ifdef USE_BINARY_SENSOR if (this->obstacle_rollback_ && (this->open_obstacle_ != nullptr || this->close_obstacle_ != nullptr)) { ESP_LOGCONFIG(TAG, " Obstacle rollback: %.1f%%", this->obstacle_rollback_ * 100); @@ -234,10 +239,22 @@ void FeedbackCover::loop() { // (stoping from endstop sensor is handled in callback) if (this->current_trigger_operation_ != COVER_OPERATION_IDLE) { if (this->is_at_target_()) { - if (this->has_built_in_endstop_ && - (this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED)) { + const bool at_end = this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED; + if (this->has_built_in_endstop_ && at_end) { // Don't trigger stop, let the cover stop by itself. this->set_current_operation_(COVER_OPERATION_IDLE, true); +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + } else if (at_end && this->overshoot_duration_) { + // The define is global, so a cover without the option still lands here with a zero duration + // Keep driving past the end position for a while so accumulated timing error is squeezed out + if (!this->start_overshoot_time_) { + this->start_overshoot_time_ = now; + } else if (now - this->start_overshoot_time_ > this->overshoot_duration_ || + now - this->start_dir_time_ > this->max_duration_) { + ESP_LOGD(TAG, "'%s' - Overshoot duration reached. Stopping cover.", this->name_.c_str()); + this->start_direction_(COVER_OPERATION_IDLE); + } +#endif } else { this->start_direction_(COVER_OPERATION_IDLE); } @@ -340,6 +357,11 @@ void FeedbackCover::start_direction_(CoverOperation dir) { binary_sensor::BinarySensor *obstacle{nullptr}; #endif +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + // Any new command ends a running overshoot + this->start_overshoot_time_ = 0; +#endif + switch (dir) { case COVER_OPERATION_IDLE: trig = &this->stop_trigger_; diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 3e4600acd2f..b641f6e7163 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -33,6 +33,9 @@ class FeedbackCover final : public cover::Cover, public Component { void set_has_built_in_endstop(bool value) { this->has_built_in_endstop_ = value; } void set_assumed_state(bool value) { this->assumed_state_ = value; } void set_max_duration(uint32_t max_duration) { this->max_duration_ = max_duration; } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + void set_overshoot_duration(uint32_t overshoot_duration) { this->overshoot_duration_ = overshoot_duration; } +#endif void set_obstacle_rollback(float obstacle_rollback) { this->obstacle_rollback_ = obstacle_rollback; } void set_update_interval(uint32_t interval) { this->update_interval_ = interval; } void set_infer_endstop(bool infer_endstop) { this->infer_endstop_ = infer_endstop; } @@ -67,6 +70,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t open_duration_{0}; uint32_t close_duration_{0}; uint32_t max_duration_{UINT32_MAX}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t overshoot_duration_{0}; +#endif optional direction_change_waittime_{}; uint32_t acceleration_wait_time_{0}; bool has_built_in_endstop_{false}; @@ -80,6 +86,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t last_recompute_time_{0}; uint32_t start_dir_time_{0}; uint32_t last_publish_time_{0}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t start_overshoot_time_{0}; +#endif float target_position_{0}; uint32_t update_interval_{1000}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d4aca85962f..0e5e2f4f292 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -73,6 +73,7 @@ #define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN +#define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH diff --git a/tests/components/feedback/common.yaml b/tests/components/feedback/common.yaml index f93d54e8b63..a892aa614fc 100644 --- a/tests/components/feedback/common.yaml +++ b/tests/components/feedback/common.yaml @@ -37,3 +37,16 @@ cover: - logger.log: Close Action stop_action: - logger.log: Stop Action + # Time-only cover with overshoot next to one without: the define is global, so both paths compile + - platform: feedback + name: Feedback Cover Plain + id: feedback_plain + open_duration: 10s + close_duration: 10s + overshoot_duration: 2s + open_action: + - logger.log: Plain Open + close_action: + - logger.log: Plain Close + stop_action: + - logger.log: Plain Stop From eeaa8c78d84082fc7c3cf7f68e2a327759c81bef Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:33:18 +1100 Subject: [PATCH 210/435] [cli] Add line format error message option (#20470) Co-authored-by: Claude Opus 5.5 --- esphome/__main__.py | 36 ++- esphome/components/substitutions/__init__.py | 17 +- esphome/config.py | 103 +++++- esphome/config_validation.py | 18 +- esphome/const.py | 9 + esphome/core/__init__.py | 4 + esphome/voluptuous_schema.py | 10 +- esphome/vscode.py | 13 +- tests/unit_tests/test_config_error_format.py | 312 +++++++++++++++++++ tests/unit_tests/test_main.py | 57 ++++ tests/unit_tests/test_vscode.py | 72 +++++ 11 files changed, 618 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/test_config_error_format.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 905339d2bcc..77c1c9f1ade 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -51,6 +51,7 @@ from esphome.const import ( KEY_ESP32, KEY_VARIANT, SECRETS_FILES, + ErrorFormat, Toolchain, ) from esphome.core import CORE, EsphomeError, coroutine @@ -2205,6 +2206,16 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None: ) +def _error_format(value: str) -> ErrorFormat: + try: + return ErrorFormat(value.lower()) + except ValueError: + choices = ", ".join(ErrorFormat) + raise argparse.ArgumentTypeError( + f"invalid choice: '{value}' (choose from {choices})" + ) from None + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2267,6 +2278,17 @@ def parse_args(argv): ), ) + options_parser.add_argument( + "--error-format", + type=_error_format, + metavar="{" + ",".join(f.value for f in ErrorFormat) + "}", + help=( + "How configuration errors are shown. 'yaml' shows them inside the " + "failing YAML; 'line' prints one 'file:line:column: error: message' " + "line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'." + ), + ) + parser = argparse.ArgumentParser( description=f"ESPHome {const.__version__}", parents=[options_parser] ) @@ -2570,9 +2592,18 @@ def parse_args(argv): args, unknown_args = parser.parse_known_args(arguments) if unknown_args: _LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args) - return args + else: + args = parser.parse_args(arguments) - return parser.parse_args(arguments) + # Read here rather than as the option default, so a bad value is reported + # against the environment variable instead of --error-format + if args.error_format is None: + env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value) + try: + args.error_format = _error_format(env_value) + except argparse.ArgumentTypeError as err: + parser.error(f"ESPHOME_ERROR_FORMAT: {err}") + return args def _warn_if_source_tree_mismatch() -> None: @@ -2645,6 +2676,7 @@ def run_esphome(argv): args = parse_args(argv) CORE.dashboard = args.dashboard CORE.testing_mode = args.testing_mode + CORE.error_format = args.error_format # Create address cache from command-line arguments CORE.address_cache = AddressCache.from_cli_args( diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 93cf588e02a..711dd9e8157 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -68,8 +68,9 @@ def raise_first_undefined( for e, p_path, _ in errors[1:] ) _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) - raise cv.Invalid( - f"Undefined variable in {context_label}: {err.message}\n{format_path(err_path, err_value)}" + raise cv.SourceTraceInvalid( + f"Undefined variable in {context_label}: {err.message}", + format_path(err_path, err_value), ) @@ -223,13 +224,13 @@ def _expand_substitutions( errors=errors, ) except JinjaError as err: - raise cv.Invalid( + raise cv.SourceTraceInvalid( f"{err.error_name()} Error evaluating jinja expression" f" '{value}': {str(err.parent())}." f"\nEvaluation stack: (most recent evaluation last)" f"\n{err.stack_trace_str()}" - f"\nRelevant context:\n{err.context_trace_str()}" - f"\n{format_path(path, orig_value)}", + f"\nRelevant context:\n{err.context_trace_str()}", + format_path(path, orig_value), path, ) from err else: @@ -389,9 +390,9 @@ def resolve_include( return include.load() if include.should_load() else {} except (esphome.core.EsphomeError, cv.Invalid) as err: resolved = f" (expanded from '{original}')" if substituted else "" - raise cv.Invalid( - f"Error including file '{filename}'{resolved}: {err}" - f"\n{format_path(path, original)}", + raise cv.SourceTraceInvalid( + f"Error including file '{filename}'{resolved}: {err}", + format_path(path, original), path + [f"<{filename}>"], ) from err diff --git a/esphome/config.py b/esphome/config.py index 28a688f15ab..682557ecfc9 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -12,6 +12,7 @@ import re from typing import TYPE_CHECKING, Any import voluptuous as vol +import yaml from esphome import core, loader, pins, yaml_util from esphome.components.substitutions import do_substitution_pass @@ -26,8 +27,9 @@ from esphome.const import ( CONF_PACKAGES, CONF_PLATFORM, CONF_SUBSTITUTIONS, + ErrorFormat, ) -from esphome.core import CORE, DocumentRange, EsphomeError +from esphome.core import CORE, DocumentLocation, DocumentRange, EsphomeError # `esphome.core.config` is imported lazily at its two use sites below. # It pulls in `esphome.automation` and `esphome.config_validation`, which @@ -38,7 +40,7 @@ from esphome.loader import ComponentManifest, get_component, get_platform from esphome.log import AnsiFore, color from esphome.types import ConfigFragmentType, ConfigType from esphome.util import OrderedDict, safe_print -from esphome.voluptuous_schema import ExtraKeysInvalid +from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret if TYPE_CHECKING: @@ -285,12 +287,8 @@ class Config(OrderedDict, fv.FinalValidateConfig): last_root = max( i for i, v in enumerate(error.path) if v is cv.ROOT_CONFIG_PATH ) - # can't change the path so re-create the error - error = vol.Invalid( - message=error.error_message, - path=error.path[last_root + 1 :], - error_type=error.error_type, - ) + # Trim in place, as Invalid.prepend() does, to keep the subclass + error._path = error.path[last_root + 1 :] # pylint: disable=protected-access self.errors.append(error) def add_validation_step(self, step: ConfigValidationStep): @@ -1513,6 +1511,7 @@ class InvalidYAMLError(EsphomeError): message = f"Invalid YAML syntax:\n\n{base}" super().__init__(message) self.base_exc = base_exc + self.base_message = base def _load_config( @@ -1555,6 +1554,86 @@ def load_config( raise EsphomeError(f"Error while parsing config: {err}") from err +def get_invalid_range(res: Config, invalid: vol.Invalid) -> DocumentRange | None: + """Return the source range of the YAML that caused *invalid*.""" + return res.get_deepest_document_range_for_path( + invalid.path, isinstance(invalid, KeyInvalid) + ) + + +def _format_location(location: DocumentLocation | None) -> str: + """Return a 1-based `file:line:column`, or the main config file if unknown.""" + if location is None: + return str(CORE.config_path) + return f"{location.document}:{location.line + 1}:{location.column + 1}" + + +def _print_line_message(location: str, kind: str, message: object) -> None: + """Print *message* as `location: kind: text`, one line per message line. + + Lines after the first become `note:` lines, so tools that read one + message per line still see every line with its location. + """ + lines = [line.strip() for line in str(message).splitlines() if line.strip()] + first, *rest = lines or [""] + safe_print(f"{location}: {kind}: {first}") + for line in rest: + safe_print(f"{location}: note: {line}") + + +def _find_marked_yaml_error(err: BaseException) -> yaml.MarkedYAMLError | None: + cause: BaseException | None = err + while cause is not None and not isinstance(cause, yaml.MarkedYAMLError): + cause = cause.__cause__ + return cause + + +def _print_marked_yaml_error(err: yaml.MarkedYAMLError) -> None: + # Errors raised as MarkedYAMLError(message, mark) put the message in context + mark = err.problem_mark or err.context_mark + location = DocumentLocation.from_mark(mark) if mark is not None else None + if not (message := err.problem or err.context): + # str() includes the note, so it is not printed separately + _print_line_message(_format_location(location), "error", str(err)) + return + _print_line_message(_format_location(location), "error", message) + if err.problem and err.context and err.context_mark is not None: + context_location = DocumentLocation.from_mark(err.context_mark) + _print_line_message(_format_location(context_location), "note", err.context) + if err.note: + _print_line_message(_format_location(location), "note", err.note) + + +def _print_line_errors(res: Config) -> None: + for err in res.errors: + doc_range = get_invalid_range(res, err) + location = _format_location( + doc_range.start_mark if doc_range is not None else None + ) + # A YAML error in an included file is wrapped in a validation error + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + _print_line_message(location, "note", "included from here") + elif isinstance(err, cv.SourceTraceInvalid): + # The trace only repeats the location this line already starts with + _print_line_message(location, "error", humanize_error(res, err.detail)) + else: + _print_line_message(location, "error", _format_vol_invalid(err, res)) + if doc_range is None and err.path: + # No source position, so give the config path to help find it + path = "->".join(str(item) for item in err.path) + _print_line_message(location, "note", f"In: {path}") + + +def _print_line_load_error(err: EsphomeError) -> None: + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + else: + # Drop the "Invalid YAML syntax" heading; it is wrong for unreadable files + message = err.base_message if isinstance(err, InvalidYAMLError) else err + _print_line_message(_format_location(None), "error", message) + + def line_info(config, path, highlight=True): """Display line config source.""" if not highlight: @@ -1703,7 +1782,13 @@ def read_config( snapshot_user_config=snapshot_user_config, ) except EsphomeError as err: - _LOGGER.error("Error while reading config: %s", err) + if CORE.error_format == ErrorFormat.LINE: + _print_line_load_error(err) + else: + _LOGGER.error("Error while reading config: %s", err) + return None + if res.errors and CORE.error_format == ErrorFormat.LINE: + _print_line_errors(res) return None if res.errors: if not CORE.verbose: diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 79d7068838a..64d22f9a143 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -113,7 +113,7 @@ from esphome.schema_extractors import ( # Deprecated re-export for external components; remove before 2027.2.0 # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 -from esphome.voluptuous_schema import _Schema +from esphome.voluptuous_schema import KeyInvalid, _Schema from esphome.yaml_util import SensitiveStr, make_data_base if typing.TYPE_CHECKING: @@ -452,6 +452,20 @@ def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schem return schema.extend(overrides) +class SourceTraceInvalid(Invalid): + """An error whose message is followed by where the bad value came from. + + ``detail`` is the message without that trace, for output that already + gives the location. + """ + + def __init__( + self, detail: str, source_trace: str, path: list[typing.Hashable] | None = None + ) -> None: + super().__init__(f"{detail}\n{source_trace}", path) + self.detail = detail + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" @@ -2293,7 +2307,7 @@ def validate_registry_entry(name, registry): if key is None: raise Invalid(f"Key missing from {name}! Got {value}") if key not in registry: - raise Invalid(f"Unable to find {name} with the name '{key}'", [key]) + raise KeyInvalid(f"Unable to find {name} with the name '{key}'", [key]) key2 = next((x for x in value if x != key and x not in ignore_keys), None) if key2 is not None: raise Invalid( diff --git a/esphome/const.py b/esphome/const.py index 7774722795d..115ccad7470 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -27,6 +27,15 @@ class Toolchain(StrEnum): HOST = "host" +class ErrorFormat(StrEnum): + """How configuration errors are printed.""" + + # Errors embedded in a dump of the failing YAML + YAML = "yaml" + # One `file:line:col: error: message` line per error + LINE = "line" + + # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1668263e337..61092ec4272 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( PLATFORM_NRF52, PLATFORM_RP2, PLATFORM_RTL87XX, + ErrorFormat, Toolchain, ) @@ -681,6 +682,8 @@ class EsphomeCore: self.verbose = False # Whether ESPHome was started in quiet mode self.quiet = False + # How configuration errors are printed + self.error_format: ErrorFormat = ErrorFormat.YAML # A list of all known ID classes self.id_classes = {} # The current component being processed during validation @@ -734,6 +737,7 @@ class EsphomeCore: self._config_hash = None self.skip_external_update = False self.toolchain = None + self.error_format = ErrorFormat.YAML PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 904963ba4e8..a31f3252ce3 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -6,10 +6,14 @@ import voluptuous as vol from esphome.schema_extractors import schema_extractor_extended -class ExtraKeysInvalid(vol.Invalid): +class KeyInvalid(vol.Invalid): + """An error caused by a mapping key itself rather than by its value.""" + + +class ExtraKeysInvalid(KeyInvalid): def __init__(self, *arg, **kwargs): self.candidates = kwargs.pop("candidates") - vol.Invalid.__init__(self, *arg, **kwargs) + super().__init__(*arg, **kwargs) def ensure_multiple_invalid(err): @@ -192,7 +196,7 @@ class _Schema(vol.Schema): ) else: errors.append( - vol.Invalid("extra keys not allowed", key_path) + KeyInvalid("extra keys not allowed", key_path) ) # for any required keys left that weren't found and don't have defaults: diff --git a/esphome/vscode.py b/esphome/vscode.py index ba7b4e727b5..e4ff0bb6ea6 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -7,19 +7,13 @@ import sys import traceback from typing import Any -from esphome.config import Config, _format_vol_invalid, validate_config +from esphome.config import _format_vol_invalid, get_invalid_range, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml -def _get_invalid_range(res: Config, invalid: cv.Invalid) -> DocumentRange | None: - return res.get_deepest_document_range_for_path( - invalid.path, invalid.error_message == "extra keys not allowed" - ) - - def _dump_range(range: DocumentRange | None) -> dict | None: if range is None: return None @@ -155,8 +149,9 @@ def read_config(args): else: for err in res.errors: try: - range_ = _get_invalid_range(res, err) + range_ = get_invalid_range(res, err) vs.add_validation_error(range_, _format_vol_invalid(err, res)) except Exception: # noqa: BLE001 # pylint: disable=broad-except - continue + # Report the error without a range rather than drop it + vs.add_validation_error(None, str(err)) print(vs.dump()) diff --git a/tests/unit_tests/test_config_error_format.py b/tests/unit_tests/test_config_error_format.py new file mode 100644 index 00000000000..395253dd708 --- /dev/null +++ b/tests/unit_tests/test_config_error_format.py @@ -0,0 +1,312 @@ +"""Tests for the `--error-format line` output of read_config.""" + +from pathlib import Path + +import pytest +import voluptuous as vol +import yaml + +from esphome import config_validation as cv +from esphome.config import ( + Config, + InvalidYAMLError, + _print_line_errors, + _print_line_load_error, + _print_line_message, + _print_marked_yaml_error, + read_config, +) +from esphome.const import ErrorFormat +from esphome.core import CORE +from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid + + +def _read( + tmp_path: Path, files: dict[str, str], capsys: pytest.CaptureFixture[str] +) -> list[str]: + for name, content in files.items(): + (tmp_path / name).write_text(content) + CORE.config_path = tmp_path / "test.yaml" + CORE.error_format = ErrorFormat.LINE + assert read_config({}) is None + return capsys.readouterr().out.splitlines() + + +def test_validation_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read( + tmp_path, + {"test.yaml": "esphome:\n name: test\nhost:\nlogger:\n levl: DEBUG\n"}, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:5:3: error: [levl] is an invalid option for " + "[logger]. Did you mean [level]?" + ) + ] + + +def test_validation_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n" + ), + "pkg.yaml": "logger:\n levl: DEBUG\n", + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'pkg.yaml'}:2:3: error: [levl] is an invalid option for " + "[logger]. Did you mean [level]?" + ) + ] + + +def test_unknown_action_anchors_on_the_key( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # validate_registry_entry raises KeyInvalid for an unknown action, so the + # location is the key, not the action's value. + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\nlogger:\nbutton:\n" + ' - platform: template\n on_press:\n - loggr.log: "msg"\n' + ) + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:8:9: error: Unable to find action " + "with the name 'loggr.log'." + ) + ] + + +def test_yaml_syntax_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read(tmp_path, {"test.yaml": "esphome:\n name: [1, 2\nhost:\n"}, capsys) + assert lines == [ + f"{tmp_path / 'test.yaml'}:3:5: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'test.yaml'}:2:9: note: while parsing a flow sequence", + ] + + +def test_yaml_syntax_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n" + ), + "pkg.yaml": "logger:\n level: [1, 2\nwifi:\n", + }, + capsys, + ) + assert lines == [ + f"{tmp_path / 'pkg.yaml'}:3:5: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'pkg.yaml'}:2:10: note: while parsing a flow sequence", + f"{tmp_path / 'test.yaml'}:5:3: note: included from here", + ] + + +def test_secret_syntax_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "sub").mkdir() + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n p: !include sub/pkg.yaml\n" + ), + "sub/pkg.yaml": "logger:\n level: !secret lvl\n", + "secrets.yaml": "lvl: [1, 2\nx: 1\n", + }, + capsys, + ) + assert lines == [ + f"{tmp_path / 'secrets.yaml'}:2:2: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'secrets.yaml'}:1:6: note: while parsing a flow sequence", + f"{tmp_path / 'test.yaml'}:5:3: note: included from here", + ] + + +def test_undefined_secret(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read( + tmp_path, + { + "test.yaml": "esphome:\n name: !secret nope\nhost:\n", + "secrets.yaml": "other: 1\n", + }, + capsys, + ) + assert lines == [f"{tmp_path / 'test.yaml'}:2:9: error: Secret 'nope' not defined"] + + +def test_missing_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + CORE.config_path = tmp_path / "missing.yaml" + CORE.error_format = ErrorFormat.LINE + assert read_config({}) is None + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 1 + assert lines[0].startswith( + f"{tmp_path / 'missing.yaml'}: error: Error reading file " + ) + + +def test_source_trace_omitted( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\n" + "packages:\n p: !include ${ undefined_var }.yaml\n" + ), + }, + capsys, + ) + # The "In: packages->p in test.yaml 5:3" trace would repeat the location + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:5:3: error: Error including file " + "'${ undefined_var }.yaml': Cannot load include with unresolved " + "substitutions: ${ undefined_var }.yaml." + ), + ] + + +def test_print_line_message_skips_blank_lines( + capsys: pytest.CaptureFixture[str], +) -> None: + _print_line_message("a.yaml:1:1", "error", "first\n\n second \n") + assert capsys.readouterr().out.splitlines() == [ + "a.yaml:1:1: error: first", + "a.yaml:1:1: note: second", + ] + + +def test_reset_restores_default_format() -> None: + CORE.error_format = ErrorFormat.LINE + CORE.reset() + assert CORE.error_format is ErrorFormat.YAML + + +@pytest.mark.parametrize( + "error", + [ + KeyInvalid("Unable to find action", ["a", cv.ROOT_CONFIG_PATH, "b"]), + ExtraKeysInvalid( + "extra keys not allowed", + ["a", cv.ROOT_CONFIG_PATH, "b"], + candidates=["c"], + ), + ], +) +def test_add_error_keeps_error_class(error: KeyInvalid) -> None: + res = Config() + res.add_error(error) + (added,) = res.errors + assert type(added) is type(error) + assert added.path == ["b"] + assert getattr(added, "candidates", None) == getattr(error, "candidates", None) + + +def test_default_format_logs_load_error( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.config_path = tmp_path / "missing.yaml" + assert read_config({}) is None + assert capsys.readouterr().out == "" + assert "Error while reading config: Invalid YAML syntax" in caplog.text + + +def test_exception_as_error_message( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # "ms" with no number makes the time period validator raise Invalid(ValueError) + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\nsensor:\n - platform: template\n" + " name: foo\n update_interval: ms\n" + ) + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:7:22: error: " + "could not convert string to float: ''." + ) + ] + + +def _mark(line: int, column: int) -> yaml.Mark: + return yaml.Mark("a.yaml", 0, line, column, None, None) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + yaml.MarkedYAMLError( + problem="bad value", problem_mark=_mark(2, 1), note="a hint" + ), + ["a.yaml:3:2: error: bad value", "a.yaml:3:2: note: a hint"], + ), + # No problem or context: the whole error text is used, note included + ( + yaml.MarkedYAMLError(problem_mark=_mark(2, 1), note="a hint"), + [ + 'a.yaml:3:2: error: in "a.yaml", line 3, column 2', + "a.yaml:3:2: note: a hint", + ], + ), + ], +) +def test_marked_yaml_error_note( + capsys: pytest.CaptureFixture[str], + error: yaml.MarkedYAMLError, + expected: list[str], +) -> None: + _print_marked_yaml_error(error) + assert capsys.readouterr().out.splitlines() == expected + + +def test_error_without_range_gives_config_path( + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = Path("main.yaml") + res = Config() + res.add_error(vol.Invalid("bad value", ["sensor", 0, "name"])) + _print_line_errors(res) + assert capsys.readouterr().out.splitlines() == [ + "main.yaml: error: bad value.", + "main.yaml: note: In: sensor->0->name", + ] + + +def test_load_error_with_unprintable_cause( + capsys: pytest.CaptureFixture[str], +) -> None: + class Unprintable(Exception): + def __str__(self) -> str: + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + + CORE.config_path = Path("main.yaml") + _print_line_load_error(InvalidYAMLError(Unprintable())) + assert capsys.readouterr().out.splitlines() == ["main.yaml: error: Unprintable()"] diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 37d8e7486f4..3b8330f1591 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -115,6 +115,7 @@ from esphome.const import ( PLATFORM_HOST, PLATFORM_NRF52, PLATFORM_RP2, + ErrorFormat, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -6462,6 +6463,62 @@ def test_parse_args_logs_states() -> None: assert args.states is True +@pytest.mark.parametrize( + ("argv", "env_value", "expected"), + [ + ([], None, ErrorFormat.YAML), + (["--error-format", "line"], None, ErrorFormat.LINE), + ([], "line", ErrorFormat.LINE), + (["--error-format", "yaml"], "line", ErrorFormat.YAML), + (["--error-format", "LINE"], None, ErrorFormat.LINE), + ([], "Line", ErrorFormat.LINE), + # The command line wins, so a bad environment value is not an error + (["--error-format", "line"], "bogus", ErrorFormat.LINE), + ], +) +def test_parse_args_error_format( + monkeypatch: pytest.MonkeyPatch, + argv: list[str], + env_value: str | None, + expected: ErrorFormat, +) -> None: + """--error-format overrides ESPHOME_ERROR_FORMAT, which overrides the default.""" + if env_value is None: + monkeypatch.delenv("ESPHOME_ERROR_FORMAT", raising=False) + else: + monkeypatch.setenv("ESPHOME_ERROR_FORMAT", env_value) + + args = parse_args(["esphome", *argv, "config", "device.yaml"]) + assert args.error_format is expected + + +@pytest.mark.parametrize( + ("argv", "env_value", "expected_name"), + [ + ([], "bogus", "ESPHOME_ERROR_FORMAT"), + (["--error-format", "bogus"], None, "--error-format"), + ], +) +def test_parse_args_error_format_invalid( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], + env_value: str | None, + expected_name: str, +) -> None: + """A bad value is reported against the setting that supplied it.""" + if env_value is None: + monkeypatch.delenv("ESPHOME_ERROR_FORMAT", raising=False) + else: + monkeypatch.setenv("ESPHOME_ERROR_FORMAT", env_value) + + with pytest.raises(SystemExit): + parse_args(["esphome", *argv, "config", "device.yaml"]) + err = capsys.readouterr().err + assert expected_name in err + assert "invalid choice: 'bogus' (choose from yaml, line)" in err + + @pytest.mark.parametrize("command", ["logs", "run"]) @pytest.mark.parametrize( ("env_value", "expected"), diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 7d401eaa653..178fd840203 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -192,3 +192,75 @@ def test_invalid_stays_plain() -> None: def test_format_unexpected_error_without_traceback() -> None: message = vscode._format_unexpected_error(ValueError("boom")) assert message == "Unexpected error while validating: ValueError: boom" + + +def test_reports_error_with_exception_message(): + source_path = str(Path("dir_path", "x.yaml")) + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +sensor: + - platform: template + name: foo + update_interval: ms # no number, so the validator raises Invalid(ValueError) +"""), + ] + ) + + error = json.loads(output_lines[-1]) + validation_error = error["validation_errors"][0] + assert validation_error["message"] == "could not convert string to float: ''." + assert validation_error["range"]["start_line"] == 6 + + +def test_unknown_action_range_is_the_key(): + source_path = str(Path("dir_path", "x.yaml")) + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +button: + - platform: template + on_press: + - loggr.log: "msg" +"""), + ] + ) + + error = json.loads(output_lines[-1]) + validation_error = error["validation_errors"][0] + assert ( + validation_error["message"] + == "Unable to find action with the name 'loggr.log'." + ) + assert validation_error["range"]["start_line"] == 6 + assert validation_error["range"]["start_col"] == 8 + + +def test_error_kept_when_range_lookup_fails(): + source_path = str(Path("dir_path", "x.yaml")) + with patch.object(vscode, "get_invalid_range", side_effect=RuntimeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +logger: + levl: DEBUG +"""), + ] + ) + + error = json.loads(output_lines[-1]) + (validation_error,) = error["validation_errors"] + assert validation_error["range"] is None + assert "extra keys not allowed" in validation_error["message"] From e6da33d3f011996889385474a8103a2c3f8e1147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 16:49:54 -1000 Subject: [PATCH 211/435] [const] Move the verify_ssl constant to the shared constants (#20501) --- esphome/components/audio_http/media_source.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/http_request/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 3c23517b92f..6c491379c89 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -3,6 +3,7 @@ from pathlib import Path import esphome.codegen as cg from esphome.components import audio, media_source, psram +from esphome.components.const import CONF_VERIFY_SSL import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM import esphome.final_validate as fv @@ -16,7 +17,6 @@ AUTO_LOAD = ["audio"] CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer" CONF_CA_CERTIFICATE_PATH = "ca_certificate_path" CONF_HTTP_REQUEST = "http_request" -CONF_VERIFY_SSL = "verify_ssl" audio_http_ns = cg.esphome_ns.namespace("audio_http") AudioHTTPMediaSource = audio_http_ns.class_( diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index eb999a05ff4..7160ef5f3a4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -58,6 +58,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VERIFY_SSL = "verify_ssl" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 92359714025..ad51d5f39be 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -4,7 +4,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.const import CONF_REQUEST_HEADERS, CONF_VERIFY_SSL from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -53,7 +53,6 @@ HttpRequestResponseTrigger = http_request_ns.class_( CONF_HTTP_REQUEST_ID = "http_request_id" CONF_USERAGENT = "useragent" -CONF_VERIFY_SSL = "verify_ssl" CONF_FOLLOW_REDIRECTS = "follow_redirects" CONF_REDIRECT_LIMIT = "redirect_limit" CONF_BUFFER_SIZE_RX = "buffer_size_rx" From 70e4463644fd35c5aba9e0f833b9cc944507d7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 16:56:35 -1000 Subject: [PATCH 212/435] [const] Move the adc_time and adc_averaging constants to the shared constants (#20503) --- esphome/components/const/__init__.py | 2 ++ esphome/components/ina226/sensor.py | 3 +-- esphome/components/ina2xx_base/__init__.py | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 7160ef5f3a4..51a5e012102 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,6 +8,8 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" +CONF_ADC_AVERAGING = "adc_averaging" +CONF_ADC_TIME = "adc_time" CONF_ALLOWED_IPS = "allowed_ips" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index af1793450c9..53427fc63e1 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -2,6 +2,7 @@ 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, @@ -24,8 +25,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_ADC_AVERAGING = "adc_averaging" -CONF_ADC_TIME = "adc_time" ina226_ns = cg.esphome_ns.namespace("ina226") INA226Component = ina226_ns.class_( diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7fdfd90bb6f..c6239ef6d2e 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -2,7 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import sensor -from esphome.components.const import UNIT_AMPERE_HOUR +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME, UNIT_AMPERE_HOUR import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -36,9 +36,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DOMAIN = "ina2xx_base" -CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_RANGE = "adc_range" -CONF_ADC_TIME = "adc_time" CONF_CHARGE = "charge" CONF_CHARGE_COULOMBS = "charge_coulombs" CONF_ENERGY_JOULES = "energy_joules" From 377a43c5be9889abce9d1c724713a38f15f62030 Mon Sep 17 00:00:00 2001 From: longerCZE <72692179+longerCZE@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:17:22 +0200 Subject: [PATCH 213/435] [toshiba] Add 'seiya' model support (#17830) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Signed-off-by: Petr Helebrant --- esphome/components/toshiba/climate.py | 1 + esphome/components/toshiba/toshiba.cpp | 285 ++++++++++++------ esphome/components/toshiba/toshiba.h | 20 +- tests/components/toshiba/__init__.py | 15 + tests/components/toshiba/benchmark.yaml | 4 + tests/components/toshiba/common_seiya.yaml | 6 + .../toshiba/test-seiya.esp32-idf.yaml | 4 + .../toshiba/test-seiya.esp8266-ard.yaml | 4 + tests/components/toshiba/toshiba_tests.cpp | 165 ++++++++++ 9 files changed, 411 insertions(+), 93 deletions(-) create mode 100644 tests/components/toshiba/__init__.py create mode 100644 tests/components/toshiba/benchmark.yaml create mode 100644 tests/components/toshiba/common_seiya.yaml create mode 100644 tests/components/toshiba/test-seiya.esp32-idf.yaml create mode 100644 tests/components/toshiba/test-seiya.esp8266-ard.yaml create mode 100644 tests/components/toshiba/toshiba_tests.cpp diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index e5f8544f2fe..962e6449f4a 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -16,6 +16,7 @@ MODELS = { "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, "RAS-2819T": Model.MODEL_RAS_2819T, + "SEIYA": Model.MODEL_SEIYA, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index f9f09a98bc4..c75496ad4ff 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1,5 +1,6 @@ #include "toshiba.h" #include "esphome/components/remote_base/toshiba_ac_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include @@ -163,6 +164,41 @@ const uint8_t RAS_2819T_AUTO_DRY_FAN_BYTE = 0x65; const uint8_t RAS_2819T_AUTO_DRY_SUFFIX = 0x3A; const uint8_t RAS_2819T_HEAT_SUFFIX = 0x3B; +// Bit timings differ per protocol family; the frame layout is shared +struct ToshibaTimings { + uint16_t header_mark; + uint16_t header_space; + uint16_t bit_mark; + uint16_t zero_space; + uint16_t one_space; + uint16_t gap_space; + bool gap_after_last; // Seiya ends on a bare mark and only puts the gap between repeats +}; +// Kept in flash on ESP8266, where plain const objects referenced through a function would land in RAM +static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK, + TOSHIBA_HEADER_SPACE, + TOSHIBA_BIT_MARK, + TOSHIBA_ZERO_SPACE, + TOSHIBA_ONE_SPACE, + TOSHIBA_GAP_SPACE, + true}; +static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false}; + +static ToshibaTimings timings_for(Model model) { + ToshibaTimings timings; + progmem_memcpy(&timings, model == MODEL_SEIYA ? &SEIYA_TIMINGS : &TOSHIBA_TIMINGS, sizeof(timings)); + return timings; +} + +// Seiya is the generic frame plus two bytes: a feature marker and a one-shot swing command +static constexpr uint8_t SEIYA_MESSAGE_LENGTH = 11; +static constexpr uint8_t SEIYA_FEATURE_MARKER = 0x21; +static constexpr uint8_t SEIYA_SWING_BOTH = 0x01; +static constexpr uint8_t SEIYA_SWING_OMNI = 0x02; // no ESPHome equivalent; treated as BOTH +static constexpr uint8_t SEIYA_SWING_VERTICAL = 0x08; +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[] = { 0x10, // 18°C @@ -404,12 +440,15 @@ void ToshibaClimate::setup() { this->maximum_temperature_ = this->temperature_max_(); this->swing_modes_ = this->toshiba_swing_modes_(); - // Ensure swing mode is always initialized to a valid value + // Ensure swing mode is always initialized to a valid value. OFF also stands for "not yet + // commanded" on models that cannot switch the swing off, such as Seiya. if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) { - // No swing support for this model or current swing mode not supported, reset to OFF this->swing_mode = climate::CLIMATE_SWING_OFF; } + // A restored swing mode is already in effect, so it must not go out as a fresh swing command + this->last_swing_mode_ = this->swing_mode; + // Ensure mode is valid - ESPHome should only use standard climate modes if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_HEAT && this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT_COOL && @@ -451,8 +490,9 @@ void ToshibaClimate::transmit_state() { } void ToshibaClimate::transmit_generic_() { + const bool seiya = this->model_ == MODEL_SEIYA; uint8_t message[16] = {0}; - uint8_t message_length = 9; + const uint8_t message_length = seiya ? SEIYA_MESSAGE_LENGTH : 9; // Header message[0] = 0xf2; @@ -473,6 +513,40 @@ void ToshibaClimate::transmit_generic_() { message[5] = (temperature - static_cast(TOSHIBA_GENERIC_TEMP_C_MIN)) << 4; // Mode and fan + message[6] = this->encode_mode_fan_(); + + // Zero + message[7] = 0x00; + + if (seiya) { + // Captured OFF frames carry no fan bits + if (this->mode == climate::CLIMATE_MODE_OFF) { + message[6] = TOSHIBA_MODE_OFF; + } + message[8] = SEIYA_FEATURE_MARKER; + // Swing is a momentary command, sent only when the mode changes, like the remote does + message[9] = SEIYA_SWING_NEUTRAL; + if (this->swing_mode != this->last_swing_mode_) { + message[9] = this->seiya_swing_code_(); + this->last_swing_mode_ = this->swing_mode; + } + } + + // The last byte is the xor of all bytes from [4] + for (uint8_t i = 4; i < message_length - 1; i++) { + message[message_length - 1] ^= message[i]; + } + + // Transmit + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + this->encode_(data, message, message_length, 1); + + transmit.perform(); +} + +uint8_t ToshibaClimate::encode_mode_fan_() const { uint8_t mode; switch (this->mode) { case climate::CLIMATE_MODE_OFF: @@ -523,27 +597,20 @@ void ToshibaClimate::transmit_generic_() { fan = TOSHIBA_FAN_SPEED_AUTO; break; } - message[6] = fan | mode; + return fan | mode; +} - // Zero - message[7] = 0x00; - - // If timers bit in the command is set, two extra bytes are added here - - // If power bit is set in the command, one extra byte is added here - - // The last byte is the xor of all bytes from [4] - for (uint8_t i = 4; i < 8; i++) { - message[8] ^= message[i]; +uint8_t ToshibaClimate::seiya_swing_code_() const { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + return SEIYA_SWING_VERTICAL; + case climate::CLIMATE_SWING_HORIZONTAL: + return SEIYA_SWING_HORIZONTAL; + case climate::CLIMATE_SWING_BOTH: + return SEIYA_SWING_BOTH; + default: + return SEIYA_SWING_NEUTRAL; } - - // Transmit - auto transmit = this->transmitter_->transmit(); - auto *data = transmit.get_data(); - - this->encode_(data, message, message_length, 1); - - transmit.perform(); } void ToshibaClimate::transmit_rac_pt1411hwru_() { @@ -1085,14 +1152,16 @@ bool ToshibaClimate::process_ras_2819t_command_(const remote_base::ToshibaAcData bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { // Try modern ToshibaAcProtocol decoder first (handles RAS-2819T and potentially others) - remote_base::ToshibaAcProtocol toshiba_protocol; - auto decode_result = toshiba_protocol.decode(data); + if (this->model_ != MODEL_SEIYA) { + remote_base::ToshibaAcProtocol toshiba_protocol; + auto decode_result = toshiba_protocol.decode(data); - if (decode_result.has_value()) { - auto toshiba_data = decode_result.value(); - // Validate and process RAS-2819T commands - if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { - return this->process_ras_2819t_command_(toshiba_data); + if (decode_result.has_value()) { + auto toshiba_data = decode_result.value(); + // Validate and process RAS-2819T commands + if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { + return this->process_ras_2819t_command_(toshiba_data); + } } } @@ -1101,7 +1170,8 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { uint8_t message_length = TOSHIBA_HEADER_LENGTH, temperature_code = 0; // Validate header - if (!data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE)) { + const ToshibaTimings timings = timings_for(this->model_); + if (!data.expect_item(timings.header_mark, timings.header_space)) { return false; } // Read incoming bits into buffer @@ -1117,6 +1187,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else { // First checksum was valid so continue receiving the remaining bits + // The shortest frame is 9 bytes (length byte 3); longer ones must still fit the buffer + if (message[2] < 3 || message[2] > sizeof(message) - TOSHIBA_HEADER_LENGTH - 2) { + return false; + } message_length = message[2] + 2; } // Decode the remaining bytes @@ -1276,59 +1350,11 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - // Get the mode - switch (message[6] & 0x0F) { - case TOSHIBA_MODE_OFF: - this->mode = climate::CLIMATE_MODE_OFF; - break; - - case TOSHIBA_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - - case TOSHIBA_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - - case TOSHIBA_MODE_FAN_ONLY: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - - case TOSHIBA_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - - case TOSHIBA_MODE_AUTO: - default: - this->mode = climate::CLIMATE_MODE_HEAT_COOL; + this->decode_mode_fan_temperature_(message); + // The marker byte tells a Seiya frame from another checksum-valid 11 byte frame within receiver tolerance + if (this->model_ == MODEL_SEIYA && message_length == SEIYA_MESSAGE_LENGTH && message[8] == SEIYA_FEATURE_MARKER) { + this->seiya_decode_swing_(message[9]); } - - // Get the fan mode - switch (message[6] & 0xF0) { - case TOSHIBA_FAN_SPEED_QUIET: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - - case TOSHIBA_FAN_SPEED_1: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - - case TOSHIBA_FAN_SPEED_3: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - - case TOSHIBA_FAN_SPEED_5: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - - case TOSHIBA_FAN_SPEED_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } - - // Get the target temperature - this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; } this->publish_state(); @@ -1337,31 +1363,36 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { void ToshibaClimate::encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, const uint8_t nbytes, const uint8_t repeat) { + const ToshibaTimings timings = timings_for(this->model_); data->set_carrier_frequency(TOSHIBA_CARRIER_FREQUENCY); for (uint8_t copy = 0; copy <= repeat; copy++) { - data->item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE); + data->item(timings.header_mark, timings.header_space); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - data->mark(TOSHIBA_BIT_MARK); + data->mark(timings.bit_mark); if (message[byte] & (1 << (7 - bit))) { - data->space(TOSHIBA_ONE_SPACE); + data->space(timings.one_space); } else { - data->space(TOSHIBA_ZERO_SPACE); + data->space(timings.zero_space); } } } - data->item(TOSHIBA_BIT_MARK, TOSHIBA_GAP_SPACE); + data->mark(timings.bit_mark); + if (copy < repeat || timings.gap_after_last) { + data->space(timings.gap_space); + } } } bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *message, const uint8_t nbytes) { + const ToshibaTimings timings = timings_for(this->model_); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ONE_SPACE)) { + if (data->expect_item(timings.bit_mark, timings.one_space)) { message[byte] |= 1 << (7 - bit); - } else if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ZERO_SPACE)) { + } else if (data->expect_item(timings.bit_mark, timings.zero_space)) { message[byte] &= static_cast(~(1 << (7 - bit))); } else { return false; @@ -1371,4 +1402,80 @@ bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *mess return true; } +void ToshibaClimate::decode_mode_fan_temperature_(const uint8_t *message) { + switch (message[6] & 0x0F) { + case TOSHIBA_MODE_OFF: + this->mode = climate::CLIMATE_MODE_OFF; + break; + + case TOSHIBA_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + + case TOSHIBA_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + + case TOSHIBA_MODE_FAN_ONLY: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + + case TOSHIBA_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + + case TOSHIBA_MODE_AUTO: + default: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + } + + // Speeds 2 and 4 are not exposed; report the nearest mode + switch (message[6] & 0xF0) { + case TOSHIBA_FAN_SPEED_QUIET: + this->fan_mode = climate::CLIMATE_FAN_QUIET; + break; + + case TOSHIBA_FAN_SPEED_1: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + + case TOSHIBA_FAN_SPEED_2: + case TOSHIBA_FAN_SPEED_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + + case TOSHIBA_FAN_SPEED_4: + case TOSHIBA_FAN_SPEED_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + + case TOSHIBA_FAN_SPEED_AUTO: + default: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; +} + +void ToshibaClimate::seiya_decode_swing_(uint8_t code) { + // A momentary command: the neutral value on an ordinary update means no change, not swing off + switch (code) { + case SEIYA_SWING_BOTH: + case SEIYA_SWING_OMNI: + this->swing_mode = climate::CLIMATE_SWING_BOTH; + break; + case SEIYA_SWING_VERTICAL: + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + break; + case SEIYA_SWING_HORIZONTAL: + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + break; + default: + return; + } + // Keep the tracker in sync so the received command is not sent back out + this->last_swing_mode_ = this->swing_mode; +} + } // namespace esphome::toshiba diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index a853730f31a..f291077e525 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -11,6 +11,7 @@ enum Model { MODEL_RAC_PT1411HWRU_C = 1, // Temperature range is from 16 to 30 MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 + MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte }; // Supported temperature ranges @@ -50,7 +51,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; private: - // RAS-2819T state tracking for swing mode optimization + // Last swing mode sent or received, for models whose swing is a one-shot command climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF}; climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF}; optional last_fan_mode_{}; @@ -71,13 +72,24 @@ class ToshibaClimate final : public climate_ir::ClimateIR { return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } climate::ClimateSwingModeMask toshiba_swing_modes_() { - return (this->model_ == MODEL_GENERIC) - ? climate::ClimateSwingModeMask() - : climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; + if (this->model_ == MODEL_GENERIC) + return climate::ClimateSwingModeMask(); + if (this->model_ == MODEL_SEIYA) { + // No captured code stops the swing, so OFF is not offered; the remote only selects a direction + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}; + } + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; } void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); + // Shared by every model that uses the F2 0D frame + uint8_t encode_mode_fan_() const; + void decode_mode_fan_temperature_(const uint8_t *message); + uint8_t seiya_swing_code_() const; + void seiya_decode_swing_(uint8_t code); + Model model_; }; diff --git a/tests/components/toshiba/__init__.py b/tests/components/toshiba/__init__.py new file mode 100644 index 00000000000..8a083978ece --- /dev/null +++ b/tests/components/toshiba/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config): + # on_receive decodes the generic frame through the shared protocol class + cg.add_define("USE_REMOTE_PROTOCOL_TOSHIBA_AC") + + manifest.to_code = to_code_testing diff --git a/tests/components/toshiba/benchmark.yaml b/tests/components/toshiba/benchmark.yaml new file mode 100644 index 00000000000..ad4129e4979 --- /dev/null +++ b/tests/components/toshiba/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The toshiba, climate_ir and remote_transmitter sources come in through the +# dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/toshiba/common_seiya.yaml b/tests/components/toshiba/common_seiya.yaml new file mode 100644 index 00000000000..8cfd65301c4 --- /dev/null +++ b/tests/components/toshiba/common_seiya.yaml @@ -0,0 +1,6 @@ +climate: + - platform: toshiba + name: "Seiya Climate" + model: seiya + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/toshiba/test-seiya.esp32-idf.yaml b/tests/components/toshiba/test-seiya.esp32-idf.yaml new file mode 100644 index 00000000000..cc4956ffee5 --- /dev/null +++ b/tests/components/toshiba/test-seiya.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + toshiba: !include common_seiya.yaml diff --git a/tests/components/toshiba/test-seiya.esp8266-ard.yaml b/tests/components/toshiba/test-seiya.esp8266-ard.yaml new file mode 100644 index 00000000000..4664f0913b9 --- /dev/null +++ b/tests/components/toshiba/test-seiya.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + toshiba: !include common_seiya.yaml diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp new file mode 100644 index 00000000000..528af888544 --- /dev/null +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -0,0 +1,165 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/toshiba/toshiba.h" + +namespace esphome::toshiba::testing { + +struct Timings { + uint32_t header_mark; + uint32_t header_space; + uint32_t bit_mark; + uint32_t zero_space; + uint32_t one_space; + uint32_t gap_space; + bool gap_after_last; +}; + +static constexpr Timings GENERIC{4380, 4370, 540, 540, 1620, 5480, true}; +static constexpr Timings SEIYA{4630, 4450, 625, 490, 1570, 5830, false}; + +/// The waveform the component must produce for a frame: two copies, MSB first +static remote_base::RawTimings waveform(const std::vector &bytes, const Timings &t) { + remote_base::RawTimings raw; + for (int copy = 0; copy < 2; copy++) { + raw.push_back(t.header_mark); + raw.push_back(-static_cast(t.header_space)); + for (uint8_t byte : bytes) { + for (int bit = 7; bit >= 0; bit--) { + raw.push_back(t.bit_mark); + raw.push_back(-static_cast((byte >> bit) & 1 ? t.one_space : t.zero_space)); + } + } + raw.push_back(t.bit_mark); + if (copy == 0 || t.gap_after_last) { + raw.push_back(-static_cast(t.gap_space)); + } + } + return raw; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + ToshibaClimate climate; + + explicit Fixture(Model model) { + this->climate.set_model(model); + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + optional swing = {}) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + if (swing.has_value()) { + call.set_swing_mode(*swing); + } + call.perform(); + } + + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } +}; + +/// Decodes the first copy of a frame back into bytes so a mismatch is readable +static std::string frame_bytes(const remote_base::RawTimings &raw, size_t nbytes) { + std::string out; + for (size_t byte = 0; byte < nbytes; byte++) { + unsigned value = 0; + for (size_t bit = 0; bit < 8; bit++) { + const size_t space = 3 + 2 * (byte * 8 + bit); + value = (value << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0); + } + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", value); + out += buf; + } + return out; +} + +// Reports the frame bytes instead of gtest's truncated vector dump +static void expect_frame(const remote_base::RawTimings &actual, const remote_base::RawTimings &expected, + size_t nbytes) { + EXPECT_EQ(actual, expected) << "actual " << frame_bytes(actual, nbytes) << "\nexpected " + << frame_bytes(expected, nbytes); +} + +TEST(ToshibaSeiya, EncodesCapturedFrames) { + Fixture f(MODEL_SEIYA); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x70, 0x01, 0x00, 0x21, 0x17, 0x46}, SEIYA), 11); + + f.command(climate::CLIMATE_MODE_OFF, 23, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x07, 0x00, 0x21, 0x17, 0x50}, SEIYA), 11); + + f.command(climate::CLIMATE_MODE_HEAT, 22, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x03, 0x00, 0x21, 0x17, 0x64}, SEIYA), 11); + + // The swing code goes out once, when the swing mode changes + f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x08, 0x7A}, SEIYA), 11); + f.command(climate::CLIMATE_MODE_DRY, 23, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x02, 0x00, 0x21, 0x17, 0x55}, SEIYA), 11); +} + +TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { + Fixture f(MODEL_GENERIC); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x01, 0x00, 0x70}, GENERIC), 9); + + f.command(climate::CLIMATE_MODE_HEAT, 20, climate::CLIMATE_FAN_HIGH); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); +} + +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); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(climate).on_receive(data); +} + +TEST(ToshibaSeiya, DecodesStateAndKeepsSwingOnNeutralByte) { + Fixture f(MODEL_SEIYA); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + + // An ordinary update carries the neutral swing byte and must not touch the swing mode + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x17, 0x65}, SEIYA)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + // A direction code switches the swing mode + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x09, 0x7B}, SEIYA)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); + + // Without the feature marker the swing byte is not a Seiya swing command + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x00, 0x01, 0x52}, SEIYA)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); +} + +TEST(ToshibaSeiya, RejectsBadLengthByte) { + Fixture f(MODEL_SEIYA); + // Length byte 0x01 passes the header checksum but describes a frame shorter than any Toshiba frame + EXPECT_FALSE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x01, 0x50, 0x02}, SEIYA)); +} + +} // namespace esphome::toshiba::testing From fdf17523079b1a3beddaa4e5b5d04b40e4564913 Mon Sep 17 00:00:00 2001 From: RUEMUE Date: Sun, 11 Oct 2026 05:35:42 +0200 Subject: [PATCH 214/435] [toshiba] Add RAS-B10N3KV2 model support (#17550) Co-authored-by: Claude Fable 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Petr Helebrant Co-authored-by: longerCZE <72692179+longerCZE@users.noreply.github.com> Co-authored-by: J. Nick Koston Signed-off-by: Petr Helebrant --- esphome/components/toshiba/climate.py | 1 + esphome/components/toshiba/toshiba.cpp | 15 ++++++++++++++- esphome/components/toshiba/toshiba.h | 3 ++- tests/components/toshiba/common.yaml | 4 ++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 962e6449f4a..69fbb210305 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -16,6 +16,7 @@ MODELS = { "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, "RAS-2819T": Model.MODEL_RAS_2819T, + "RAS-B10N3KV2": Model.MODEL_RAS_B10N3KV2, "SEIYA": Model.MODEL_SEIYA, } diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index c75496ad4ff..57361a4a587 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -183,10 +183,23 @@ static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK, TOSHIBA_GAP_SPACE, true}; static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false}; +// The RAS-B13 remote family does not accept the generic timings; these are IRremoteESP8266's TOSHIBA_AC values +static const ToshibaTimings RAS_B10N3KV2_TIMINGS PROGMEM = {4400, 4300, 580, 490, 1600, 7400, true}; static ToshibaTimings timings_for(Model model) { + const ToshibaTimings *source = &TOSHIBA_TIMINGS; + switch (model) { + case MODEL_SEIYA: + source = &SEIYA_TIMINGS; + break; + case MODEL_RAS_B10N3KV2: + source = &RAS_B10N3KV2_TIMINGS; + break; + default: + break; + } ToshibaTimings timings; - progmem_memcpy(&timings, model == MODEL_SEIYA ? &SEIYA_TIMINGS : &TOSHIBA_TIMINGS, sizeof(timings)); + progmem_memcpy(&timings, source, sizeof(timings)); return timings; } diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index f291077e525..799f690f54c 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -12,6 +12,7 @@ enum Model { MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte + MODEL_RAS_B10N3KV2 = 5, // RAS-B13 remote family, generic frame with IRremoteESP8266's TOSHIBA_AC timings }; // Supported temperature ranges @@ -72,7 +73,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR { return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } climate::ClimateSwingModeMask toshiba_swing_modes_() { - if (this->model_ == MODEL_GENERIC) + if (this->model_ == MODEL_GENERIC || this->model_ == MODEL_RAS_B10N3KV2) return climate::ClimateSwingModeMask(); if (this->model_ == MODEL_SEIYA) { // No captured code stops the swing, so OFF is not offered; the remote only selects a direction diff --git a/tests/components/toshiba/common.yaml b/tests/components/toshiba/common.yaml index ba96c0628d8..2b37e989465 100644 --- a/tests/components/toshiba/common.yaml +++ b/tests/components/toshiba/common.yaml @@ -2,3 +2,7 @@ climate: - platform: toshiba name: Toshiba Climate transmitter_id: xmitr + - platform: toshiba + name: RAS-B10N3KV2 Climate + model: RAS-B10N3KV2 + transmitter_id: xmitr From 6bc19d2a1aafc38f0650d70d4ab0b22dc4b3aac9 Mon Sep 17 00:00:00 2001 From: Mathew Winstone Date: Sat, 10 Oct 2026 23:45:03 -0400 Subject: [PATCH 215/435] [espnow] Add support for ESPNow with ESP8266 (#14407) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/espnow/__init__.py | 31 +++- esphome/components/espnow/automation.h | 4 +- .../components/espnow/espnow_component.cpp | 167 +++++++----------- esphome/components/espnow/espnow_component.h | 35 ++-- esphome/components/espnow/espnow_err.h | 19 +- esphome/components/espnow/espnow_packet.h | 70 ++------ esphome/components/espnow/espnow_platform.h | 34 ++++ .../espnow/espnow_platform_esp32.cpp | 96 ++++++++++ .../espnow/espnow_platform_esp8266.cpp | 105 +++++++++++ esphome/components/espnow/espnow_types.h | 77 ++++++++ .../packet_transport/espnow_transport.cpp | 14 +- .../packet_transport/espnow_transport.h | 5 +- esphome/core/event_pool.h | 3 +- tests/components/espnow/common-esp32.yaml | 9 + tests/components/espnow/common.yaml | 11 +- .../espnow/test-wifi.esp8266-ard.yaml | 2 + tests/components/espnow/test.esp32-idf.yaml | 4 +- tests/components/espnow/test.esp8266-ard.yaml | 2 + tests/unit_tests/components/test_espnow.py | 53 +++++- 19 files changed, 523 insertions(+), 218 deletions(-) create mode 100644 esphome/components/espnow/espnow_platform.h create mode 100644 esphome/components/espnow/espnow_platform_esp32.cpp create mode 100644 esphome/components/espnow/espnow_platform_esp8266.cpp create mode 100644 esphome/components/espnow/espnow_types.h create mode 100644 tests/components/espnow/common-esp32.yaml create mode 100644 tests/components/espnow/test-wifi.esp8266-ard.yaml create mode 100644 tests/components/espnow/test.esp8266-ard.yaml diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index aef8fd17b56..e5abb16b1c3 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -17,6 +17,8 @@ from esphome.const import ( CONF_ON_ERROR, CONF_TRIGGER_ID, CONF_WIFI, + PLATFORM_ESP32, + PLATFORM_ESP8266, ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, TemplateArgsType @@ -24,7 +26,14 @@ import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] -AUTO_LOAD = ["network"] + + +def AUTO_LOAD() -> list[str]: + # network initialises esp_netif and the default event loop on the ESP32. The ESP8266 needs neither, and + # loading it there would pull in mdns, which requires wifi + return ["network"] if CORE.is_esp32 else [] + + DOMAIN = "espnow" peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) @@ -121,15 +130,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ADDRESS): cv.mac_address, } ), - cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastTrigger), - cv.Optional(CONF_ADDRESS): cv.mac_address, - } + # The ESP8266 SDK does not report the destination address, so broadcasts are indistinguishable there + cv.Optional(CONF_ON_BROADCAST): cv.All( + cv.only_on_esp32, + automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + OnBroadcastTrigger + ), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), ), }, ).extend(cv.COMPONENT_SCHEMA), - cv.only_on_esp32, + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), ) @@ -138,6 +153,8 @@ def _validate_variant(config: ConfigType) -> ConfigType: # ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that # supplies the esp_now_* symbols. Fail here with a clear message instead of # letting the build reach an "undefined reference to esp_now_*" link error. + if not CORE.is_esp32: + return config variant = get_esp32_variant() if wifi.variant_has_wifi(variant): return config diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 851b8345c94..88e240a646a 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_component.h" @@ -142,4 +142,4 @@ class OnBroadcastTrigger final : public Trigger #include +#include +#include #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include -#include -#include - #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -49,7 +43,7 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_ERR_ESPNOW_INTERNAL: return LOG_STR("Internal Error"); case ESP_ERR_ESPNOW_NO_MEM: - return LOG_STR("Our of memory"); + return LOG_STR("Out of memory"); case ESP_ERR_ESPNOW_NOT_FOUND: return LOG_STR("Peer not found"); case ESP_ERR_ESPNOW_IF: @@ -57,96 +51,91 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_OK: return LOG_STR("OK"); case ESP_NOW_SEND_FAIL: + case ESP_FAIL: return LOG_STR("Failed"); default: return LOG_STR("Unknown Error"); } } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) -void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) -#else -void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) -#endif -{ +// The analyzer traces a leak on the failed-push path, which cannot happen: the +// pools are sized to the queue capacity (SIZE-1), so allocate() returns nullptr +// before push() can find the ring full. +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void ESPNowComponent::send_reported(const uint8_t *mac_addr, esp_now_send_status_t status) { // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } -// Load new packet data (replaces previous packet) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - packet->load_sent_data(info->des_addr, status); -#else packet->load_sent_data(mac_addr, status); -#endif - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW send event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } -void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { +void ESPNowComponent::packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, + int8_t rssi, uint32_t timestamp) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // but the receive buffer only fits frames up to ``max_payload_size``; copying a // larger frame would overflow packet_.receive.data. - if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + if (size < 0 || static_cast(size) > ESPNOW_MAX_DATA_LEN) { + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } - // Load new packet data (replaces previous packet) - packet->load_received_data(info, data, size); + packet->load_received_data(src_addr, des_addr, data, size, rssi, timestamp); - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW receive event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } +// NOLINTEND(clang-analyzer-unix.Malloc) ESPNowComponent::ESPNowComponent() { global_esp_now = this; } void ESPNowComponent::dump_config() { ESP_LOGCONFIG(TAG, "espnow:"); // Only report driver details once enabled; with enable_on_boot: false the - // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // Wi-Fi driver is not initialized yet and the version call would crash, // and after a failed enable_() the values would be meaningless. if (this->state_ != ESPNOW_STATE_ENABLED) { // OFF here means enable_() failed; the core logs the FAILED marker separately ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } - uint32_t version = 0; - esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, " Own address: %s\n" - " Version: v%" PRIu32 "\n" " Wi-Fi channel: %d", - own_addr_buf, version, this->wifi_channel_); + own_addr_buf, this->wifi_channel_); + uint32_t version = platform::get_version(); + if (version != 0) { + ESP_LOGCONFIG(TAG, " Version: v%" PRIu32, version); + } #ifdef USE_WIFI ESP_LOGCONFIG(TAG, " Wi-Fi enabled: %s", YESNO(this->is_wifi_enabled())); #endif @@ -183,6 +172,10 @@ void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::spanwifi_channel_ != old_channel) { ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_); } + // Peers added while the station was scanning were bound to the channel of that moment, so rebind on every connect + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } #endif @@ -198,41 +191,19 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); - ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); - ESP_ERROR_CHECK(esp_wifi_start()); - ESP_ERROR_CHECK(esp_wifi_disconnect()); - + platform::init_radio(); this->apply_wifi_channel(); } this->get_wifi_channel(); - esp_err_t err = esp_now_init(); + esp_err_t err = platform::init(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Init failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - err = esp_now_register_recv_cb(on_data_received); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = esp_now_register_send_cb(on_send_report); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); + platform::read_mac(this->own_address_); this->state_ = ESPNOW_STATE_ENABLED; @@ -248,12 +219,9 @@ void ESPNowComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = ESPNOW_STATE_DISABLED; - esp_now_unregister_recv_cb(); - esp_now_unregister_send_cb(); - - esp_err_t err = esp_now_deinit(); + esp_err_t err = platform::deinit(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); + ESP_LOGE(TAG, "Deinit failed: %s", esp_err_to_name(err)); } } @@ -271,9 +239,10 @@ void ESPNowComponent::apply_wifi_channel() { } ESP_LOGI(TAG, "Channel set to %d.", this->wifi_channel_); - esp_wifi_set_promiscuous(true); - esp_wifi_set_channel(this->wifi_channel_, WIFI_SECOND_CHAN_NONE); - esp_wifi_set_promiscuous(false); + platform::set_channel(this->wifi_channel_); + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } void ESPNowComponent::loop() { @@ -283,7 +252,7 @@ void ESPNowComponent::loop() { switch (packet->type_) { case ESPNowPacket::RECEIVED: { const ESPNowRecvInfo info = packet->get_receive_info(); - if (!esp_now_is_peer_exist(info.src_addr)) { + if (!platform::peer_exists(info.src_addr)) { bool handled = false; for (auto *handler : this->unknown_peer_handlers_) { if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) { @@ -296,7 +265,7 @@ void ESPNowComponent::loop() { } } // Intentionally left as if instead of else in case the peer is added above - if (esp_now_is_peer_exist(info.src_addr)) { + if (platform::peer_exists(info.src_addr)) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -309,7 +278,8 @@ void ESPNowComponent::loop() { format_hex_pretty_to(hex_buf, packet->packet_.receive.data, std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif - if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + // Without a destination address every packet goes to the receive handlers + if (ESPNOW_REPORTS_DESTINATION && memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { if (handler->on_broadcast(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers @@ -374,24 +344,25 @@ void ESPNowComponent::loop() { } uint8_t ESPNowComponent::get_wifi_channel() { - wifi_second_chan_t dummy; - esp_wifi_get_channel(&this->wifi_channel_, &dummy); + this->wifi_channel_ = platform::get_channel(); return this->wifi_channel_; } +// Same analyzer false positive as above: the send pool is sized to the send queue's capacity +// NOLINTBEGIN(clang-analyzer-unix.Malloc) esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback) { + send_callback_t callback) { if (this->state_ != ESPNOW_STATE_ENABLED) { return ESP_ERR_ESPNOW_NOT_INIT; } else if (this->is_failed()) { return ESP_ERR_ESPNOW_FAILED; - } else if (peer_address == 0ULL) { + } else if (peer_address == nullptr) { return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; - } else if (!esp_now_is_peer_exist(peer_address)) { + } else if (!platform::peer_exists(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { esp_err_t err = this->add_peer(peer_address); if (err != ESP_OK) { @@ -410,7 +381,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl return ESP_ERR_ESPNOW_NO_MEM; } // Load the packet data - packet->load_data(peer_address, payload, size, callback); + packet->load_data(peer_address, payload, size, std::move(callback)); // Push the packet to the send queue this->send_packet_queue_.push(packet); // Loop may be disabled while idle; re-enable it to send the packet @@ -418,6 +389,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl this->enable_loop_soon_any_context(); return ESP_OK; } +// NOLINTEND(clang-analyzer-unix.Malloc) void ESPNowComponent::send_() { ESPNowSendPacket *packet = this->send_packet_queue_.pop(); @@ -426,7 +398,7 @@ void ESPNowComponent::send_() { } this->current_send_packet_ = packet; - esp_err_t err = esp_now_send(packet->address_, packet->data_, packet->size_); + esp_err_t err = platform::send(packet->address_, packet->data_, packet->size_); if (err != ESP_OK) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(packet->address_, addr_buf); @@ -451,13 +423,8 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { return ESP_ERR_INVALID_MAC; } - if (!esp_now_is_peer_exist(peer)) { - esp_now_peer_info_t peer_info = {}; - memset(&peer_info, 0, sizeof(esp_now_peer_info_t)); - peer_info.ifidx = WIFI_IF_STA; - memcpy(peer_info.peer_addr, peer, ESP_NOW_ETH_ALEN); - esp_err_t err = esp_now_add_peer(&peer_info); - + if (!platform::peer_exists(peer)) { + esp_err_t err = platform::add_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); @@ -486,8 +453,8 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { return ESP_ERR_ESPNOW_NOT_INIT; } - if (esp_now_is_peer_exist(peer)) { - esp_err_t err = esp_now_del_peer(peer); + if (platform::peer_exists(peer)) { + esp_err_t err = platform::del_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); @@ -507,4 +474,4 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index 375bd4faefe..b912233eed0 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -4,21 +4,17 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/event_pool.h" #include "esphome/core/lock_free_queue.h" #include "espnow_packet.h" +#include "espnow_types.h" #if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) #include "esphome/components/wifi/wifi_component.h" #endif -#include - -#include -#include - #include #include #include @@ -27,10 +23,6 @@ namespace esphome::espnow { -// Maximum size of the ESPNow event queue - must be power of 2 for lock-free queue -static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; -static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; - using peer_address_t = std::array; enum class ESPNowTriggers : uint8_t { @@ -152,12 +144,10 @@ class ESPNowComponent final : public Component { /// @param payload Data payload to send /// @param callback Callback to call when the send operation is complete /// @return ESP_OK on success, or an error code on failure - esp_err_t send(const uint8_t *peer_address, const std::vector &payload, - const send_callback_t &callback = nullptr) { - return this->send(peer_address, payload.data(), payload.size(), callback); + esp_err_t send(const uint8_t *peer_address, const std::vector &payload, send_callback_t callback = nullptr) { + return this->send(peer_address, payload.data(), payload.size(), std::move(callback)); } - esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback = nullptr); + esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t callback = nullptr); void register_receive_handler(ESPNowReceivedPacketHandler *handler) { this->receive_handlers_.push_back(handler); } void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { @@ -165,14 +155,13 @@ class ESPNowComponent final : public Component { } void register_broadcast_handler(ESPNowBroadcastHandler *handler) { this->broadcast_handlers_.push_back(handler); } - protected: - friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - friend void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status); -#else - friend void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status); -#endif + // Entry points for the radio driver callbacks in espnow_platform_*.cpp; they run outside the main loop and + // only queue the event + void packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, int8_t rssi, + uint32_t timestamp); + void send_reported(const uint8_t *mac_addr, esp_now_send_status_t status); + protected: void enable_(); void send_(); @@ -205,4 +194,4 @@ extern ESPNowComponent *global_esp_now; // NOLINT(cppcoreguidelines-avoid-non-c } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_err.h b/esphome/components/espnow/espnow_err.h index ceda1b7683e..8db38b800ef 100644 --- a/esphome/components/espnow/espnow_err.h +++ b/esphome/components/espnow/espnow_err.h @@ -1,19 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) -#include -#include +#include "espnow_types.h" namespace esphome::espnow { -static const esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); -static const esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); -static const esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); -static const esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); +static constexpr esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); +static constexpr esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); +static constexpr esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); +static constexpr esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index fb125864fb6..d9f4a73f931 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -1,19 +1,17 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_err.h" +#include "espnow_types.h" #include #include #include #include +#include #include -#include -#include -#include - namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; @@ -36,16 +34,10 @@ static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); #endif -struct WifiPacketRxControl { - int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm - uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or - // light sleep is not enabled -}; - struct ESPNowRecvInfo { uint8_t src_addr[ESP_NOW_ETH_ALEN]; /**< Source address of ESPNOW packet */ uint8_t des_addr[ESP_NOW_ETH_ALEN]; /**< Destination address of ESPNOW packet */ - wifi_pkt_rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ + rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ }; using send_callback_t = std::function; @@ -58,34 +50,28 @@ class ESPNowPacket { SENT, }; - // Constructor for received data - ESPNowPacket(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - this->init_received_data_(info, data, size); - }; - -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - // Constructor for sent data - ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { - this->init_sent_data_(info->src_addr, status); - } -#else - // Constructor for sent data - ESPNowPacket(const uint8_t *mac_addr, esp_now_send_status_t status) { this->init_sent_data_(mac_addr, status); } -#endif - // Default constructor for pre-allocation in pool ESPNowPacket() {} void release() {} - void load_received_data(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + void load_received_data(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, uint16_t size, + int8_t rssi, uint32_t timestamp) { this->type_ = RECEIVED; - this->init_received_data_(info, data, size); + memcpy(this->packet_.receive.info.src_addr, src_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.info.des_addr, des_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.data, data, size); + this->packet_.receive.size = size; + + this->packet_.receive.rx_ctrl.rssi = rssi; + this->packet_.receive.rx_ctrl.timestamp = timestamp; + this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); } void load_sent_data(const uint8_t *mac_addr, esp_now_send_status_t status) { this->type_ = SENT; - this->init_sent_data_(mac_addr, status); + memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); + this->packet_.sent.status = status; } // Disable copy to prevent double-delete @@ -112,24 +98,6 @@ class ESPNowPacket { esp_now_packet_type_t type() const { return this->type_; } const ESPNowRecvInfo &get_receive_info() const { return this->packet_.receive.info; } - - private: - void init_received_data_(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - memcpy(this->packet_.receive.info.src_addr, info->src_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.info.des_addr, info->des_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.data, data, size); - this->packet_.receive.size = size; - - this->packet_.receive.rx_ctrl.rssi = info->rx_ctrl->rssi; - this->packet_.receive.rx_ctrl.timestamp = info->rx_ctrl->timestamp; - - this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); - } - - void init_sent_data_(const uint8_t *mac_addr, esp_now_send_status_t status) { - memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); - this->packet_.sent.status = status; - } }; class ESPNowSendPacket { @@ -151,9 +119,9 @@ class ESPNowSendPacket { ESPNowSendPacket(const ESPNowSendPacket &) = delete; ESPNowSendPacket &operator=(const ESPNowSendPacket &) = delete; - void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t &&callback) { this->init_data_(peer_address, payload, size); - this->callback_ = callback; + this->callback_ = std::move(callback); } void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size) { @@ -180,4 +148,4 @@ class ESPNowSendPacket { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform.h b/esphome/components/espnow/espnow_platform.h new file mode 100644 index 00000000000..bc19f34a7cd --- /dev/null +++ b/esphome/components/espnow/espnow_platform.h @@ -0,0 +1,34 @@ +#pragma once + +#if defined(USE_ESP32) || defined(USE_ESP8266) + +#include "espnow_types.h" + +#include + +// The radio and ESP-NOW driver calls that differ between ESP-IDF and the ESP8266 NONOS SDK. Each platform +// implements the whole set in its own source file, so the component itself carries no platform checks. +namespace esphome::espnow::platform { + +// Bring up the station interface when no wifi component owns the radio +void init_radio(); +void set_channel(uint8_t channel); +uint8_t get_channel(); +void read_mac(uint8_t *mac); + +// Start the driver and route its callbacks to the component +esp_err_t init(); +esp_err_t deinit(); +uint32_t get_version(); // 0 where the SDK has no version call + +bool peer_exists(const uint8_t *mac); +esp_err_t add_peer(const uint8_t *mac); +esp_err_t del_peer(const uint8_t *mac); +// Move the registered peers to the radio's current channel; only the NONOS SDK binds peers to a channel +void rebind_peers(); + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size); + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform_esp32.cpp b/esphome/components/espnow/espnow_platform_esp32.cpp new file mode 100644 index 00000000000..b45c09b072e --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp32.cpp @@ -0,0 +1,96 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP32 + +#include "espnow_component.h" + +#include +#include +#include + +#include + +namespace esphome::espnow::platform { + +static void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + global_esp_now->packet_received(info->src_addr, info->des_addr, data, size, info->rx_ctrl->rssi, + info->rx_ctrl->timestamp); +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +static void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) { + global_esp_now->send_reported(info->des_addr, status); +} +#else +static void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) { + global_esp_now->send_reported(mac_addr, status); +} +#endif + +void init_radio() { + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_disconnect()); +} + +void set_channel(uint8_t channel) { + esp_wifi_set_promiscuous(true); + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + esp_wifi_set_promiscuous(false); +} + +uint8_t get_channel() { + uint8_t channel = 0; + wifi_second_chan_t second; + esp_wifi_get_channel(&channel, &second); + return channel; +} + +void read_mac(uint8_t *mac) { esp_wifi_get_mac(WIFI_IF_STA, mac); } + +esp_err_t init() { + esp_err_t err = esp_now_init(); + if (err == ESP_OK) { + err = esp_now_register_recv_cb(on_data_received); + } + if (err == ESP_OK) { + err = esp_now_register_send_cb(on_send_report); + } + return err; +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return esp_now_deinit(); +} + +uint32_t get_version() { + uint32_t version = 0; + esp_now_get_version(&version); + return version; +} + +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(mac); } + +esp_err_t add_peer(const uint8_t *mac) { + esp_now_peer_info_t peer_info = {}; + peer_info.ifidx = WIFI_IF_STA; + memcpy(peer_info.peer_addr, mac, ESP_NOW_ETH_ALEN); + return esp_now_add_peer(&peer_info); +} + +esp_err_t del_peer(const uint8_t *mac) { return esp_now_del_peer(mac); } + +// Peers are added on channel 0, which ESP-IDF treats as whatever channel the radio is on +void rebind_peers() {} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { return esp_now_send(mac, data, size); } + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_platform_esp8266.cpp b/esphome/components/espnow/espnow_platform_esp8266.cpp new file mode 100644 index 00000000000..736e8788375 --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp8266.cpp @@ -0,0 +1,105 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP8266 + +#include "espnow_component.h" + +namespace esphome::espnow::platform { + +// The SDK reports 0 for success and a non-zero value for any failure +static esp_err_t sdk_result(int result) { return result == 0 ? ESP_OK : ESP_FAIL; } + +// Stands in for the destination address, which the SDK does not report +static const uint8_t UNKNOWN_ADDR[ESP_NOW_ETH_ALEN] = {0}; + +static void on_data_received(uint8_t *mac_addr, uint8_t *data, uint8_t size) { + global_esp_now->packet_received(mac_addr, UNKNOWN_ADDR, data, size, 0, 0); +} + +static void on_send_report(uint8_t *mac_addr, uint8_t status) { + global_esp_now->send_reported(mac_addr, status == 0 ? ESP_NOW_SEND_SUCCESS : ESP_NOW_SEND_FAIL); +} + +void init_radio() { + // The NONOS SDK only delivers broadcast frames to ESP-NOW while the station is associated or the + // soft-AP interface is up. Without Wi-Fi, run a hidden, open soft-AP that accepts no clients so + // broadcasts (for example from a WizMote style remote) are received. + softap_config ap{}; + ap.channel = 1; + ap.authmode = AUTH_OPEN; + ap.ssid_hidden = 1; + ap.max_connection = 0; + ap.beacon_interval = 60000; + wifi_set_opmode_current(STATIONAP_MODE); + wifi_softap_set_config_current(&ap); + wifi_softap_dhcps_stop(); + wifi_set_sleep_type(NONE_SLEEP_T); + wifi_station_disconnect(); +} + +void set_channel(uint8_t channel) { + wifi_promiscuous_enable(true); + wifi_set_channel(channel); + wifi_promiscuous_enable(false); + // Keep the soft-AP from init_radio() on the same channel so the SDK never retunes the radio to its own + softap_config ap{}; + if (wifi_get_opmode() == STATIONAP_MODE && wifi_softap_get_config(&ap) && ap.channel != channel) { + ap.channel = channel; + wifi_softap_set_config_current(&ap); + } +} + +uint8_t get_channel() { return wifi_get_channel(); } + +void read_mac(uint8_t *mac) { + // Frames leave from the station interface unless the device runs as a soft-AP only + wifi_get_macaddr(wifi_get_opmode() == SOFTAP_MODE ? SOFTAP_IF : STATION_IF, mac); +} + +esp_err_t init() { + int result = esp_now_init(); + if (result == 0) { + // CONTROLLER sends from the station interface; the other roles send from the soft-AP MAC whenever + // that interface is up, so peers would no longer recognise the sender. + result = esp_now_set_self_role(ESP_NOW_ROLE_CONTROLLER); + } + if (result == 0) { + result = esp_now_register_recv_cb(on_data_received); + } + if (result == 0) { + result = esp_now_register_send_cb(on_send_report); + } + return sdk_result(result); +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return sdk_result(esp_now_deinit()); +} + +uint32_t get_version() { return 0; } + +// The SDK returns 1 when the peer exists, 0 when it does not and a negative value on error +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(const_cast(mac)) > 0; } + +esp_err_t add_peer(const uint8_t *mac) { + return sdk_result(esp_now_add_peer(const_cast(mac), ESP_NOW_ROLE_COMBO, wifi_get_channel(), nullptr, 0)); +} + +esp_err_t del_peer(const uint8_t *mac) { return sdk_result(esp_now_del_peer(const_cast(mac))); } + +void rebind_peers() { + const uint8_t channel = wifi_get_channel(); + for (uint8_t *mac = esp_now_fetch_peer(true); mac != nullptr; mac = esp_now_fetch_peer(false)) { + esp_now_set_peer_channel(mac, channel); + } +} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { + return sdk_result(esp_now_send(const_cast(mac), const_cast(data), size)); +} + +} // namespace esphome::espnow::platform + +#endif // USE_ESP8266 diff --git a/esphome/components/espnow/espnow_types.h b/esphome/components/espnow/espnow_types.h new file mode 100644 index 00000000000..4b9c65fecf3 --- /dev/null +++ b/esphome/components/espnow/espnow_types.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include + +#if defined(USE_ESP32) + +#include +#include + +#elif defined(USE_ESP8266) + +extern "C" { +#include +#include +#include +} + +#endif + +namespace esphome::espnow { + +struct WifiPacketRxControl { + int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm + uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or + // light sleep is not enabled +}; + +#if defined(USE_ESP32) + +// Handlers read rx_ctrl through the ESP-IDF type so existing lambdas keep compiling +using rx_ctrl_t = wifi_pkt_rx_ctrl_t; +static constexpr bool ESPNOW_REPORTS_DESTINATION = true; + +// Maximum size of the ESPNow event queues - must be a power of 2 for the lock-free queue +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; + +#elif defined(USE_ESP8266) + +// The NONOS SDK has no esp_err_t vocabulary. These are the ESP-IDF names and values the shared espnow code +// uses, so it reads the same on both platforms. +using esp_err_t = int32_t; + +static constexpr esp_err_t ESP_OK = 0; +static constexpr esp_err_t ESP_FAIL = -1; +static constexpr esp_err_t ESP_ERR_INVALID_MAC = 0x10B; +static constexpr esp_err_t ESP_ERR_ESPNOW_BASE = 0x3064; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_INIT = ESP_ERR_ESPNOW_BASE + 1; +static constexpr esp_err_t ESP_ERR_ESPNOW_ARG = ESP_ERR_ESPNOW_BASE + 2; +static constexpr esp_err_t ESP_ERR_ESPNOW_NO_MEM = ESP_ERR_ESPNOW_BASE + 3; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_FOUND = ESP_ERR_ESPNOW_BASE + 5; +static constexpr esp_err_t ESP_ERR_ESPNOW_INTERNAL = ESP_ERR_ESPNOW_BASE + 6; +static constexpr esp_err_t ESP_ERR_ESPNOW_IF = ESP_ERR_ESPNOW_BASE + 8; + +inline const char *esp_err_to_name(esp_err_t err) { return err == ESP_OK ? "ESP_OK" : "ESP_FAIL"; } + +static constexpr uint8_t ESP_NOW_ETH_ALEN = 6; +static constexpr uint8_t ESP_NOW_MAX_DATA_LEN = 250; + +// NOLINTNEXTLINE(readability-identifier-naming) +enum esp_now_send_status_t : uint8_t { + ESP_NOW_SEND_SUCCESS = 0, + ESP_NOW_SEND_FAIL = 1, +}; + +using rx_ctrl_t = WifiPacketRxControl; +// The SDK reports neither the destination address nor the signal strength of a received frame +static constexpr bool ESPNOW_REPORTS_DESTINATION = false; + +// Half the ESP32 depth: the heap is far smaller and only one send is ever in flight +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 8; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 8; + +#endif + +} // namespace esphome::espnow diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 7053229db42..93bc181a185 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -1,10 +1,12 @@ #include "espnow_transport.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/application.h" #include "esphome/core/log.h" +#include + namespace esphome::espnow { ESPHOME_LOG_TAG(TAG, "espnow.transport"); @@ -64,9 +66,7 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } @@ -79,12 +79,10 @@ bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *da return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 51069b64153..79938f8df2e 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -2,7 +2,7 @@ #include "../espnow_component.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/component.h" #include "esphome/components/packet_transport/packet_transport.h" @@ -33,9 +33,8 @@ class ESPNowTransport final : public packet_transport::PacketTransport, bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; - std::vector packet_buffer_; }; } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index b53b8064a3d..77b47228a97 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) || \ + defined(USE_ESP8266) #include #include diff --git a/tests/components/espnow/common-esp32.yaml b/tests/components/espnow/common-esp32.yaml new file mode 100644 index 00000000000..a97a5ef8aeb --- /dev/null +++ b/tests/components/espnow/common-esp32.yaml @@ -0,0 +1,9 @@ +espnow: + max_payload_size: 1470 + on_broadcast: + - logger.log: + format: "Broadcast from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 973781fb68b..54b089116f7 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -2,7 +2,6 @@ espnow: id: espnow_component auto_add_peer: false channel: 1 - max_payload_size: 1470 peers: - 11:22:33:44:55:66 on_receive: @@ -50,13 +49,6 @@ espnow: - espnow.set_channel: 6 - espnow.set_channel: channel: !lambda 'return 6;' - on_broadcast: - - logger.log: - format: "Broadcast from: %s = '%s' RSSI: %d" - args: - - format_mac_address_pretty(info.src_addr).c_str() - - format_hex_pretty(data, size).c_str() - - info.rx_ctrl->rssi on_unknown_peer: - logger.log: format: "Unknown peer: %s = '%s' RSSI: %d" @@ -80,8 +72,9 @@ packet_transport: key: "0123456789abcdef0123456789abcdef" sensor: - - platform: internal_temperature + - platform: template id: espnow_temp_sensor + lambda: return 21.5; - platform: packet_transport transport_id: transport1 diff --git a/tests/components/espnow/test-wifi.esp8266-ard.yaml b/tests/components/espnow/test-wifi.esp8266-ard.yaml new file mode 100644 index 00000000000..c45547cd533 --- /dev/null +++ b/tests/components/espnow/test-wifi.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common-wifi.yaml diff --git a/tests/components/espnow/test.esp32-idf.yaml b/tests/components/espnow/test.esp32-idf.yaml index b47e39c3898..1b3ba941b6b 100644 --- a/tests/components/espnow/test.esp32-idf.yaml +++ b/tests/components/espnow/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + espnow: !include common.yaml + espnow_esp32: !include common-esp32.yaml diff --git a/tests/components/espnow/test.esp8266-ard.yaml b/tests/components/espnow/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ea7d1f197e1 --- /dev/null +++ b/tests/components/espnow/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common.yaml diff --git a/tests/unit_tests/components/test_espnow.py b/tests/unit_tests/components/test_espnow.py index 21305c2b332..762e660139c 100644 --- a/tests/unit_tests/components/test_espnow.py +++ b/tests/unit_tests/components/test_espnow.py @@ -7,16 +7,29 @@ from esphome.components.esp32.const import ( VARIANT_ESP32H2, VARIANT_ESP32P4, ) -from esphome.components.espnow import _validate_variant +from esphome.components.espnow import CONFIG_SCHEMA, _validate_variant import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType def _run( - monkeypatch, variant: str, full_config: dict, config: ConfigType + monkeypatch, + variant: str, + full_config: dict, + config: ConfigType, + platform: str = PLATFORM_ESP32, ) -> ConfigType: monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant) + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: platform}) token = fv.full_config.set(full_config) try: return _validate_variant(config) @@ -46,3 +59,39 @@ def test_p4_with_esp32_hosted_passes(monkeypatch) -> None: """The P4 with esp32_hosted present validates; config passes through.""" config = {"id": "espnow"} assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config + + +def test_esp8266_skips_variant_check(monkeypatch) -> None: + """The ESP8266 has one Wi-Fi radio and no variants, so the check does not run.""" + config = {"id": "espnow"} + assert _run(monkeypatch, "", {}, config, platform=PLATFORM_ESP8266) is config + + +def _esp8266_arduino(monkeypatch) -> None: + monkeypatch.setitem( + CORE.data, + KEY_CORE, + {KEY_TARGET_PLATFORM: PLATFORM_ESP8266, KEY_TARGET_FRAMEWORK: "arduino"}, + ) + + +def test_esp8266_accepts_the_v1_payload_size(monkeypatch) -> None: + _esp8266_arduino(monkeypatch) + assert ( + CONFIG_SCHEMA({"channel": 1, "max_payload_size": 250})["max_payload_size"] + == 250 + ) + + +def test_esp8266_rejects_v2_payload_sizes(monkeypatch) -> None: + """The NONOS SDK only speaks ESP-NOW v1, so larger frames are refused at validation.""" + _esp8266_arduino(monkeypatch) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"channel": 1, "max_payload_size": 251}) + + +def test_esp8266_rejects_on_broadcast(monkeypatch) -> None: + """The NONOS SDK does not report a destination address, so broadcasts cannot be told apart.""" + _esp8266_arduino(monkeypatch) + with pytest.raises(cv.Invalid, match="only available on"): + CONFIG_SCHEMA({"channel": 1, "on_broadcast": []}) From 254690100c4768e592017c47550fbf8dbcce69ef Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Sat, 10 Oct 2026 20:52:24 -0700 Subject: [PATCH 216/435] [hub75] add set_rotation and get_rotation methods (#13921) Co-authored-by: J. Nick Koston --- esphome/components/display/display.h | 4 +- esphome/components/hub75/display.py | 6 +-- esphome/components/hub75/hub75.cpp | 40 +++++++++++++++++++ esphome/components/hub75/hub75_component.h | 5 ++- .../hub75/test.esp32-s3-idf-rotate.yaml | 7 ++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index cc75eb70b8f..74b5bcedd94 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -294,7 +294,7 @@ using display_writer_t = DisplayWriter; prefix type "\n" \ "%s Rotations: %d °\n" \ "%s Dimensions: %dpx x %dpx", \ - prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \ + prefix, log_display_obj->get_rotation(), prefix, log_display_obj->get_width(), \ log_display_obj->get_height()); \ } @@ -710,7 +710,7 @@ class Display : public PollingComponent { // Internal method to set display auto clearing. void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } - DisplayRotation get_rotation() const { return this->rotation_; } + virtual DisplayRotation get_rotation() const { return this->rotation_; } /** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays, * returns the type the display is currently configured to. diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 08d068d0f03..823928176a1 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -601,10 +601,8 @@ async def to_code(config: ConfigType) -> None: pins_struct = _build_pins_struct(pin_expressions, e_pin_num) hub75_config = _build_config_struct(config, pins_struct, min_refresh) - # Rotation is handled by the hub75 driver (config_.rotation already set above). - # Force rotation to 0 for ESPHome's Display base class to avoid double-rotation. - if CONF_ROTATION in config: - config[CONF_ROTATION] = 0 + # The driver rotates the panel itself (config_.rotation above), so the base class must not rotate too + config.pop(CONF_ROTATION, None) # Create display and register var = cg.new_Pvariable(config[CONF_ID], hub75_config) diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 674e23cb344..226b59a2912 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -202,6 +202,46 @@ void HUB75Display::set_brightness(uint8_t brightness) { } } +static Hub75Rotation to_hub75_rotation(display::DisplayRotation rotation) { + switch (rotation) { + case display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES: + return Hub75Rotation::ROTATE_90; + case display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES: + return Hub75Rotation::ROTATE_180; + case display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES: + return Hub75Rotation::ROTATE_270; + default: + return Hub75Rotation::ROTATE_0; + } +} + +static display::DisplayRotation to_display_rotation(Hub75Rotation rotation) { + switch (rotation) { + case Hub75Rotation::ROTATE_90: + return display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES; + case Hub75Rotation::ROTATE_180: + return display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES; + case Hub75Rotation::ROTATE_270: + return display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES; + default: + return display::DisplayRotation::DISPLAY_ROTATION_0_DEGREES; + } +} + +// Before setup() creates the driver the configuration holds the rotation, afterwards the driver does +void HUB75Display::set_rotation(display::DisplayRotation rotation) { + const Hub75Rotation hub75_rotation = to_hub75_rotation(rotation); + if (this->driver_ == nullptr) { + this->config_.rotation = hub75_rotation; + return; + } + this->driver_->set_rotation(hub75_rotation); +} + +display::DisplayRotation HUB75Display::get_rotation() const { + return to_display_rotation(this->driver_ == nullptr ? this->config_.rotation : this->driver_->get_rotation()); +} + } // namespace esphome::hub75 #endif diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index dbadc56c9df..122e8960fb0 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -36,6 +36,9 @@ class HUB75Display final : public display::Display { // Brightness control (runtime mutable) void set_brightness(uint8_t brightness); + void set_rotation(display::DisplayRotation rotation) override; + display::DisplayRotation get_rotation() const override; + protected: // Display internal methods int get_width_internal() override { return this->driver_ != nullptr ? this->driver_->get_width() : 0; } @@ -43,7 +46,7 @@ class HUB75Display final : public display::Display { // Member variables Hub75Driver *driver_{nullptr}; - Hub75Config config_; // Immutable configuration + Hub75Config config_; // Fixed after setup(); only the rotation can change before the driver exists // Runtime state (mutable) uint8_t brightness_{128}; diff --git a/tests/components/hub75/test.esp32-s3-idf-rotate.yaml b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml index 9855fcb4e69..1a08549a2fe 100644 --- a/tests/components/hub75/test.esp32-s3-idf-rotate.yaml +++ b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml @@ -37,3 +37,10 @@ display: } <<: !include common.yaml + +script: + - id: rotate_hub75 + then: + - lambda: |- + id(my_hub75).set_rotation(display::DISPLAY_ROTATION_180_DEGREES); + ESP_LOGD("test", "rotation now %d", static_cast(id(my_hub75).get_rotation())); From cb54f6d6cc3ac77e023c36a2f3664ba64a79af57 Mon Sep 17 00:00:00 2001 From: opcod3 Date: Sun, 11 Oct 2026 11:13:21 +0200 Subject: [PATCH 217/435] [daikin] Add presets and half-degree temperature steps (#17891) Co-authored-by: James Nimmo Co-authored-by: J. Nick Koston --- esphome/components/daikin/daikin.cpp | 130 ++++- esphome/components/daikin/daikin.h | 40 +- tests/components/daikin/__init__.py | 8 + tests/components/daikin/benchmark.yaml | 4 + tests/components/daikin/common.yaml | 11 +- .../daikin/daikin_regression_tests.cpp | 477 ++++++++++++++++++ tests/components/daikin/daikin_tests.cpp | 440 ++++++++++++++++ tests/components/daikin/test.esp32-idf.yaml | 4 + tests/components/daikin/test.esp8266-ard.yaml | 4 +- 9 files changed, 1084 insertions(+), 34 deletions(-) create mode 100644 tests/components/daikin/__init__.py create mode 100644 tests/components/daikin/benchmark.yaml create mode 100644 tests/components/daikin/daikin_regression_tests.cpp create mode 100644 tests/components/daikin/daikin_tests.cpp create mode 100644 tests/components/daikin/test.esp32-idf.yaml diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 7a1310b9ac1..0194b34db09 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,25 +1,47 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" namespace esphome::daikin { ESPHOME_LOG_TAG(TAG, "daikin.climate"); +static constexpr uint32_t BOOST_TIMEOUT_ID = 0; + +static uint8_t sum_bytes(const uint8_t *data, size_t len) { + uint8_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum += data[i]; + } + return sum; +} + +void DaikinClimate::setup() { + climate_ir::ClimateIR::setup(); + // A boost preset restored from flash gets its timer too + this->update_boost_timer_(false); +} + +void DaikinClimate::control(const climate::ClimateCall &call) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + climate_ir::ClimateIR::control(call); + this->update_boost_timer_(was_boost); +} + void DaikinClimate::transmit_state() { - uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00, + uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0x00, 0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2, 0x11, 0xDA, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00}; + this->set_presets_(remote_state); remote_state[21] = this->operation_mode_(); remote_state[22] = this->temperature_(); uint16_t fan_speed = this->fan_speed_(); remote_state[24] = fan_speed >> 8; remote_state[25] = fan_speed & 0xff; - // Calculate checksum - for (int i = 16; i < 34; i++) { - remote_state[34] += remote_state[i]; - } + remote_state[7] = sum_bytes(remote_state, 7); + remote_state[34] = sum_bytes(remote_state + 16, 18); auto transmit = this->transmitter_->transmit(); auto *data = transmit.get_data(); @@ -136,18 +158,52 @@ uint8_t DaikinClimate::temperature_() const { case climate::CLIMATE_MODE_DRY: return 0xc0; default: - uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX)); - return temperature << 1; + return static_cast( + roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX) * 2.0f)); } } -bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; +void DaikinClimate::set_presets_(uint8_t *remote_state) const { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + remote_state[29] = DAIKIN_PRESET_POWERFUL_ON; + break; + case climate::CLIMATE_PRESET_SLEEP: + remote_state[29] = DAIKIN_PRESET_QUIET_ON; + break; + case climate::CLIMATE_PRESET_ECO: + remote_state[32] = DAIKIN_PRESET_ECONO_ON; + break; + case climate::CLIMATE_PRESET_ACTIVITY: + remote_state[32] = DAIKIN_PRESET_SENSOR_ON; + break; + case climate::CLIMATE_PRESET_COMFORT: + remote_state[6] = DAIKIN_PRESET_COMFORT_ON; // the only preset carried by the first frame + break; + default: + break; } - if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) - return false; +} + +void DaikinClimate::update_boost_timer_(bool was_boost) { + const bool is_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + if (is_boost && !was_boost) { + this->set_timeout(BOOST_TIMEOUT_ID, DAIKIN_BOOST_DURATION_MS, [this]() { this->cancel_boost_mode_(); }); + } else if (was_boost && !is_boost) { + this->cancel_timeout(BOOST_TIMEOUT_ID); + } +} + +void DaikinClimate::cancel_boost_mode_() { + if (this->preset != climate::CLIMATE_PRESET_BOOST) + return; + ESP_LOGD(TAG, "Boost ended"); + this->preset = climate::CLIMATE_PRESET_NONE; + this->publish_state(); +} + +bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; uint8_t mode = frame[5]; // Temperature is given in degrees celcius * 2 // only update for states that use the temperature @@ -206,16 +262,41 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { this->fan_mode = climate::CLIMATE_FAN_QUIET; break; } + + // Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid + // A comfort frame whose own state frame never came must not attach to a later transmission + const uint32_t now = App.get_loop_component_start_time(); + const PendingComfort comfort = + now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS ? this->pending_comfort_ : PendingComfort::UNKNOWN; + this->pending_comfort_ = PendingComfort::UNKNOWN; + if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (frame[13] & DAIKIN_PRESET_QUIET_ON) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (frame[16] & DAIKIN_PRESET_ECONO_ON) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) { + this->preset = climate::CLIMATE_PRESET_ACTIVITY; + } else if (comfort == PendingComfort::ON) { + this->preset = climate::CLIMATE_PRESET_COMFORT; + } else if (comfort == PendingComfort::OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { + // Without a comfort frame in this transmission a current comfort preset is left alone + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->update_boost_timer_(was_boost); + this->publish_state(); return true; } bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {}; + uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {}; if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { return false; } - for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) { + + uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE; + for (uint8_t pos = 0; pos < frame_size; pos++) { uint8_t byte = 0; for (int8_t bit = 0; bit < 8; bit++) { if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) { @@ -224,7 +305,7 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } } - state_frame[pos] = byte; + frame[pos] = byte; if (pos == 0) { // frame header if (byte != 0x11) @@ -243,11 +324,24 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else if (pos == 4) { // frame type - if (byte != 0x00) + if (byte == DAIKIN_FRAME_TYPE_COMFORT) { + frame_size = DAIKIN_COMFORT_FRAME_SIZE; + } else if (byte != DAIKIN_FRAME_TYPE_STATE) { return false; + } } } - return this->parse_state_frame_(state_frame); + if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) { + return false; + } + + if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) { + // Nothing changes yet: the state frame that follows applies it once it has passed its own checksum + this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::ON : PendingComfort::OFF; + this->pending_comfort_at_ = App.get_loop_component_start_time(); + return true; + } + return this->parse_state_frame_(frame); } } // namespace esphome::daikin diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index b7fa9923076..7f8638101d5 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -27,6 +27,17 @@ const uint8_t DAIKIN_FAN_3 = 0x50; const uint8_t DAIKIN_FAN_4 = 0x60; const uint8_t DAIKIN_FAN_5 = 0x70; +// Presets: bit flags in byte 6 (comfort), byte 29 (powerful, quiet) and byte 32 (sensor, econo) +const uint8_t DAIKIN_PRESET_COMFORT_ON = 0x10; +const uint8_t DAIKIN_PRESET_POWERFUL_ON = 0x01; +const uint8_t DAIKIN_PRESET_QUIET_ON = 0x20; +const uint8_t DAIKIN_PRESET_ECONO_ON = 0x04; +const uint8_t DAIKIN_PRESET_SENSOR_ON = 0x02; +// The unit leaves powerful mode by itself after 20 minutes; mirror that in the entity +const uint32_t DAIKIN_BOOST_DURATION_MS = 20 * 60 * 1000; +// The three frames of one transmission arrive well inside this window +const uint32_t DAIKIN_TRANSMISSION_MS = 1000; + // IR Transmission const uint32_t DAIKIN_IR_FREQUENCY = 38000; const uint32_t DAIKIN_HEADER_MARK = 3360; @@ -36,27 +47,44 @@ const uint32_t DAIKIN_ONE_SPACE = 1370; const uint32_t DAIKIN_ZERO_SPACE = 360; const uint32_t DAIKIN_MESSAGE_SPACE = 32300; -// State Frame size +// Frame types (byte 4) and sizes +const uint8_t DAIKIN_FRAME_TYPE_STATE = 0x00; +const uint8_t DAIKIN_FRAME_TYPE_COMFORT = 0xC5; const uint8_t DAIKIN_STATE_FRAME_SIZE = 19; +const uint8_t DAIKIN_COMFORT_FRAME_SIZE = 8; class DaikinClimate final : public climate_ir::ClimateIR { public: DaikinClimate() - : climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_QUIET, 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}) {} + : climate_ir::ClimateIR( + DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true, + {climate::CLIMATE_FAN_QUIET, 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}, + {climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_COMFORT, + climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_ACTIVITY, climate::CLIMATE_PRESET_NONE}) {} protected: + void setup() override; + void control(const climate::ClimateCall &call) override; // Transmit via IR the state of this climate controller. void transmit_state() override; uint8_t operation_mode_() const; uint16_t fan_speed_() const; uint8_t temperature_() const; + void set_presets_(uint8_t *remote_state) const; + /// Arms or cancels the boost timer when the preset moves into or out of boost. + void update_boost_timer_(bool was_boost); + void cancel_boost_mode_(); // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); + + /// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame + enum class PendingComfort : uint8_t { UNKNOWN, OFF, ON }; + PendingComfort pending_comfort_{PendingComfort::UNKNOWN}; + uint32_t pending_comfort_at_{0}; }; } // namespace esphome::daikin diff --git a/tests/components/daikin/__init__.py b/tests/components/daikin/__init__.py new file mode 100644 index 00000000000..c0e24478320 --- /dev/null +++ b/tests/components/daikin/__init__.py @@ -0,0 +1,8 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/daikin/benchmark.yaml b/tests/components/daikin/benchmark.yaml new file mode 100644 index 00000000000..640cc34b136 --- /dev/null +++ b/tests/components/daikin/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The daikin, climate_ir and remote_transmitter sources come in through the +# dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/daikin/common.yaml b/tests/components/daikin/common.yaml index fd738416862..acca04faf4a 100644 --- a/tests/components/daikin/common.yaml +++ b/tests/components/daikin/common.yaml @@ -1,9 +1,4 @@ climate: - - platform: heatpumpir - protocol: daikin - horizontal_default: middle - vertical_default: middle - name: HeatpumpIR Climate - min_temperature: 18 - max_temperature: 30 - transmitter_id: xmitr + - platform: daikin + name: Daikin AC + receiver_id: rcvr diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp new file mode 100644 index 00000000000..fd695b9ca48 --- /dev/null +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -0,0 +1,477 @@ +// Regression tests for the behaviour the daikin climate had before presets were added. +// Every expected byte is worked out by hand from the original daikin.cpp, and this file +// only uses the API that both the original and the current component provide. +#include +#include +#include +#include +#include + +#include "esphome/components/daikin/daikin.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::daikin::regression { + +static constexpr size_t MESSAGE_SIZE = 35; +using Message = std::array; + +static constexpr int32_t HEADER_MARK = 3360; +static constexpr int32_t HEADER_SPACE = 1760; +static constexpr int32_t BIT_MARK = 520; +static constexpr int32_t ONE_SPACE = 1370; +static constexpr int32_t ZERO_SPACE = 360; +static constexpr int32_t MESSAGE_SPACE = 32300; +static constexpr uint32_t CARRIER = 38000; + +// Sum of the constant bytes 16..33 of the third frame (11 DA 27 00 00 .. 06 60 00 00 C0 00 00), +// 0x238 truncated to 8 bits. Byte 34 is this plus bytes 21, 22, 24 and 25. +static constexpr uint8_t FRAME3_CONSTANT_SUM = 0x38; + +struct Capture { + remote_base::RawTimings raw; + uint32_t carrier; +}; + +class RecordingTransmitter : public remote_base::RemoteTransmitterBase { + public: + RecordingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector sent; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->sent.push_back({this->temp_.get_data(), this->temp_.get_carrier_frequency()}); + } +}; + +static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) { + for (size_t byte = 0; byte < count; byte++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + EXPECT_EQ(raw[pos], BIT_MARK) << "bit mark at " << pos; + const int32_t space = -raw[pos + 1]; + if (space == ONE_SPACE) { + value |= 1 << bit; + } else { + EXPECT_EQ(space, ZERO_SPACE) << "bit space at " << pos + 1; + } + pos += 2; + } + out[byte] = value; + } +} + +static void expect_pair(const remote_base::RawTimings &raw, size_t &pos, int32_t mark, int32_t space) { + EXPECT_EQ(raw[pos], mark) << "mark at " << pos; + EXPECT_EQ(raw[pos + 1], -space) << "space at " << pos + 1; + pos += 2; +} + +/// Decodes the three frames (8, 8 and 19 bytes) and checks every header, bit and gap timing +static Message decode(const remote_base::RawTimings &raw) { + Message msg{}; + // 3 headers + 3 trailers of two timings each, plus two timings per bit + constexpr size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE; + EXPECT_EQ(raw.size(), expected_size); + if (raw.size() != expected_size) + return msg; + size_t pos = 0; + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data(), 8); + expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE); + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data() + 8, 8); + expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE); + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data() + 16, 19); + expect_pair(raw, pos, BIT_MARK, 0); + return msg; +} + +/// Receiver timings for a 19-byte frame +static remote_base::RawTimings timings_for(const std::vector &bytes) { + remote_base::RawTimings raw; + raw.push_back(HEADER_MARK); + raw.push_back(-HEADER_SPACE); + for (uint8_t byte : bytes) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(BIT_MARK); + raw.push_back(-((byte >> bit) & 1 ? ONE_SPACE : ZERO_SPACE)); + } + } + raw.push_back(BIT_MARK); + raw.push_back(-MESSAGE_SPACE); + return raw; +} + +/// A 19-byte state frame (type 0x00) with its checksum in byte 18 +static std::vector state_frame(uint8_t mode, uint8_t temperature, uint8_t fan, uint8_t swing) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0x00, mode, temperature, 0x00, fan, swing, + 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00}; + uint8_t total = 0; + for (size_t i = 0; i < 18; i++) + total += f[i]; + f[18] = total; + return f; +} + +struct Rig { + RecordingTransmitter transmitter; + DaikinClimate climate; + + Rig() { + // All instances share one preference key; clear it so setup starts from defaults + if (global_preferences != nullptr) + global_preferences->reset(); + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_daikin_regression"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + Message send(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.perform(); + EXPECT_FALSE(this->transmitter.sent.empty()); + if (this->transmitter.sent.empty()) + return Message{}; + return decode(this->transmitter.sent.back().raw); + } + + bool receive(const std::vector &bytes) { + const remote_base::RawTimings raw = timings_for(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + return static_cast(this->climate).on_receive(data); + } +}; + +/// Checks every byte that the original encoder never changes, and both checksums +static void expect_fixed_bytes(const Message &m) { + const uint8_t frame1[8] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7}; + const uint8_t frame2[8] = {0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2}; + for (size_t i = 0; i < 8; i++) { + EXPECT_EQ(m[i], frame1[i]) << "byte " << i; + EXPECT_EQ(m[8 + i], frame2[i]) << "byte " << 8 + i; + } + EXPECT_EQ(m[16], 0x11); + EXPECT_EQ(m[17], 0xDA); + EXPECT_EQ(m[18], 0x27); + EXPECT_EQ(m[19], 0x00); + EXPECT_EQ(m[20], 0x00); + EXPECT_EQ(m[23], 0x00); + EXPECT_EQ(m[26], 0x00); + EXPECT_EQ(m[27], 0x06); + EXPECT_EQ(m[28], 0x60); + EXPECT_EQ(m[29], 0x00); + EXPECT_EQ(m[30], 0x00); + EXPECT_EQ(m[31], 0xC0); + EXPECT_EQ(m[32], 0x00); + EXPECT_EQ(m[33], 0x00); + // 0x11 + 0xDA + 0x27 + 0xC5 = 0x1D7 + EXPECT_EQ(m[7], 0xD7); + const uint8_t expected34 = FRAME3_CONSTANT_SUM + m[21] + m[22] + m[24] + m[25]; + EXPECT_EQ(m[34], expected34); +} + +struct ModeCase { + climate::ClimateMode mode; + uint8_t byte21; + uint8_t byte22; + uint8_t byte34; +}; + +// At 24 degrees, fan auto (0xA0, 0x00), swing off: +// OFF 0x00, 24 << 1 = 0x30, 0x38 + 0x00 + 0x30 + 0xA0 = 0x108 -> 0x08 +// COOL 0x31, 0x30, 0x38 + 0x31 + 0x30 + 0xA0 = 0x139 -> 0x39 +// HEAT 0x41, 0x30, 0x38 + 0x41 + 0x30 + 0xA0 = 0x149 -> 0x49 +// DRY 0x21, 0xC0, 0x38 + 0x21 + 0xC0 + 0xA0 = 0x1B9 -> 0xB9 +// FAN_ONLY 0x61, 0x32, 0x38 + 0x61 + 0x32 + 0xA0 = 0x16B -> 0x6B +// HEAT_COOL 0x01, 0x30, 0x38 + 0x01 + 0x30 + 0xA0 = 0x109 -> 0x09 +static const ModeCase MODE_CASES[] = { + {climate::CLIMATE_MODE_OFF, 0x00, 0x30, 0x08}, {climate::CLIMATE_MODE_COOL, 0x31, 0x30, 0x39}, + {climate::CLIMATE_MODE_HEAT, 0x41, 0x30, 0x49}, {climate::CLIMATE_MODE_DRY, 0x21, 0xC0, 0xB9}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x61, 0x32, 0x6B}, {climate::CLIMATE_MODE_HEAT_COOL, 0x01, 0x30, 0x09}, +}; + +TEST(DaikinRegression, EncodesEveryMode) { + for (const auto &c : MODE_CASES) { + Rig r; + const Message m = r.send(c.mode, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(static_cast(c.mode)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], c.byte21); + EXPECT_EQ(m[22], c.byte22); + EXPECT_EQ(m[24], 0xA0); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], c.byte34); + } +} + +struct FanCase { + climate::ClimateFanMode fan; + uint8_t b24; + uint8_t byte34; +}; + +// Cool at 24 (0x31, 0x30), swing off: b34 = 0x38 + 0x31 + 0x30 + b24 = 0x99 + b24 +static const FanCase FAN_CASES[] = { + {climate::CLIMATE_FAN_QUIET, 0xB0, 0x49}, // 0x99 + 0xB0 = 0x149 + {climate::CLIMATE_FAN_AUTO, 0xA0, 0x39}, // 0x99 + 0xA0 = 0x139 + {climate::CLIMATE_FAN_LOW, 0x30, 0xC9}, // 0x99 + 0x30 + {climate::CLIMATE_FAN_MEDIUM, 0x50, 0xE9}, // 0x99 + 0x50 + {climate::CLIMATE_FAN_HIGH, 0x70, 0x09}, // 0x99 + 0x70 = 0x109 +}; + +TEST(DaikinRegression, EncodesEveryFanMode) { + for (const auto &c : FAN_CASES) { + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_COOL, 24.0f, c.fan, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(static_cast(c.fan)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x31); + EXPECT_EQ(m[22], 0x30); + EXPECT_EQ(m[24], c.b24); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], c.byte34); + } +} + +struct SwingCase { + climate::ClimateSwingMode swing; + uint8_t b24; + uint8_t b25; + uint8_t byte34; +}; + +// Heat at 22 (0x41, 0x2C), fan medium (0x50): b34 = 0x38 + 0x41 + 0x2C + b24 + b25 = 0xA5 + ... +static const SwingCase SWING_CASES[] = { + {climate::CLIMATE_SWING_OFF, 0x50, 0x00, 0xF5}, // 0xA5 + 0x50 + {climate::CLIMATE_SWING_VERTICAL, 0x5F, 0x00, 0x04}, // 0xA5 + 0x5F = 0x104 + {climate::CLIMATE_SWING_HORIZONTAL, 0x50, 0x0F, 0x04}, // 0xA5 + 0x50 + 0x0F = 0x104 + {climate::CLIMATE_SWING_BOTH, 0x5F, 0x0F, 0x13}, // 0xA5 + 0x5F + 0x0F = 0x113 +}; + +TEST(DaikinRegression, EncodesEverySwingMode) { + for (const auto &c : SWING_CASES) { + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_HEAT, 22.0f, climate::CLIMATE_FAN_MEDIUM, c.swing); + SCOPED_TRACE(static_cast(c.swing)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x41); + EXPECT_EQ(m[22], 0x2C); + EXPECT_EQ(m[24], c.b24); + EXPECT_EQ(m[25], c.b25); + EXPECT_EQ(m[34], c.byte34); + } +} + +TEST(DaikinRegression, EncodesSwingWithQuietAndHighFan) { + { + // Dry, quiet, vertical: 0x21, 0xC0, 0xB0 | 0x0F = 0xBF; 0x38 + 0x21 + 0xC0 + 0xBF = 0x1D8 -> 0xD8 + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_DRY, 24.0f, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_VERTICAL); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x21); + EXPECT_EQ(m[22], 0xC0); + EXPECT_EQ(m[24], 0xBF); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], 0xD8); + } + { + // Fan only, low, horizontal: 0x61, 0x32, 0x30, 0x0F; 0x38 + 0x61 + 0x32 + 0x30 + 0x0F = 0x10A -> 0x0A + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_HORIZONTAL); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x61); + EXPECT_EQ(m[22], 0x32); + EXPECT_EQ(m[24], 0x30); + EXPECT_EQ(m[25], 0x0F); + EXPECT_EQ(m[34], 0x0A); + } + { + // Heat at 30, high, both: 0x41, 0x3C, 0x7F, 0x0F; 0x38 + 0x41 + 0x3C + 0x7F + 0x0F = 0x143 -> 0x43 + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_HEAT, 30.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x41); + EXPECT_EQ(m[22], 0x3C); + EXPECT_EQ(m[24], 0x7F); + EXPECT_EQ(m[25], 0x0F); + EXPECT_EQ(m[34], 0x43); + } +} + +struct TemperatureCase { + float target; + uint8_t byte22; +}; + +// Clamped to 10..30, rounded, then doubled. The call layer does not clamp, so 5 and 35 reach the encoder. +static const TemperatureCase TEMPERATURE_CASES[] = { + {5.0f, 0x14}, {10.0f, 0x14}, {11.0f, 0x16}, {18.0f, 0x24}, {21.0f, 0x2A}, + {24.0f, 0x30}, {29.0f, 0x3A}, {30.0f, 0x3C}, {35.0f, 0x3C}, +}; + +TEST(DaikinRegression, EncodesWholeDegreesWithClamp) { + for (const auto &c : TEMPERATURE_CASES) { + for (auto mode : {climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL}) { + Rig r; + const Message m = r.send(mode, c.target, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(c.target); + SCOPED_TRACE(static_cast(mode)); + expect_fixed_bytes(m); + EXPECT_EQ(m[22], c.byte22); + } + } +} + +TEST(DaikinRegression, HeatCoolAt18Checksum) { + // 0x01, 18 << 1 = 0x24, medium 0x50: 0x38 + 0x01 + 0x24 + 0x50 = 0xAD + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_HEAT_COOL, 18.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x01); + EXPECT_EQ(m[22], 0x24); + EXPECT_EQ(m[24], 0x50); + EXPECT_EQ(m[34], 0xAD); +} + +TEST(DaikinRegression, FrameTimingsAndCarrier) { + Rig r; + r.send(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + ASSERT_EQ(r.transmitter.sent.size(), 1u); + const Capture &c = r.transmitter.sent.back(); + EXPECT_EQ(c.carrier, CARRIER); + ASSERT_EQ(c.raw.size(), 572u); + // Header, then 64 bits of frame 1, its trailer and the 32300 gap, then frame 2's header + EXPECT_EQ(c.raw[0], 3360); + EXPECT_EQ(c.raw[1], -1760); + EXPECT_EQ(c.raw[2 + 128], 520); + EXPECT_EQ(c.raw[2 + 128 + 1], -32300); + EXPECT_EQ(c.raw[2 + 128 + 2], 3360); + EXPECT_EQ(c.raw[2 + 128 + 3], -1760); + // Frame 2's trailer and gap, then frame 3's header + EXPECT_EQ(c.raw[134 + 128], 520); + EXPECT_EQ(c.raw[134 + 128 + 1], -32300); + EXPECT_EQ(c.raw[134 + 128 + 2], 3360); + EXPECT_EQ(c.raw[134 + 128 + 3], -1760); + // Frame 3 ends with a bit mark and a zero space + EXPECT_EQ(c.raw[570], 520); + EXPECT_EQ(c.raw[571], 0); + // Byte 0 is 0x11, LSB first: bit 0 is a one (1370), bit 1 a zero (360) + EXPECT_EQ(c.raw[2], 520); + EXPECT_EQ(c.raw[3], -1370); + EXPECT_EQ(c.raw[4], 520); + EXPECT_EQ(c.raw[5], -360); +} + +struct ReceiveCase { + float target; + uint8_t mode_byte; + uint8_t temperature; + uint8_t fan_byte; + uint8_t swing_byte; + climate::ClimateMode mode; + climate::ClimateFanMode fan; + climate::ClimateSwingMode swing; +}; + +// Byte 5 mode (low bit power), byte 6 temperature doubled, byte 8 fan high nibble and vertical swing +// low nibble, byte 9 horizontal swing low nibble. Fan nibbles: 3 and 4 low, 5 medium, 6 and 7 high, +// A auto, B quiet. +static const ReceiveCase RECEIVE_CASES[] = { + {24.0f, 0x31, 0x30, 0xA0, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF}, + {22.0f, 0x41, 0x2C, 0x3F, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_LOW, + climate::CLIMATE_SWING_VERTICAL}, + {20.0f, 0x01, 0x28, 0x50, 0x0F, climate::CLIMATE_MODE_HEAT_COOL, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_SWING_HORIZONTAL}, + {10.0f, 0x31, 0x14, 0x7F, 0x0F, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH}, + {30.0f, 0x41, 0x3C, 0xB0, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_OFF}, + {18.0f, 0x31, 0x24, 0x40, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF}, + {18.0f, 0x31, 0x24, 0x60, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF}, +}; + +TEST(DaikinRegression, DecodesStateFrames) { + for (const auto &c : RECEIVE_CASES) { + Rig r; + SCOPED_TRACE(static_cast(c.mode_byte)); + SCOPED_TRACE(static_cast(c.fan_byte)); + EXPECT_TRUE(r.receive(state_frame(c.mode_byte, c.temperature, c.fan_byte, c.swing_byte))); + EXPECT_EQ(r.climate.mode, c.mode); + EXPECT_FLOAT_EQ(r.climate.target_temperature, c.target); + EXPECT_EQ(r.climate.fan_mode, c.fan); + EXPECT_EQ(r.climate.swing_mode, c.swing); + } +} + +TEST(DaikinRegression, DecodesDryAndFanOnlyWithoutTouchingTemperature) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + ASSERT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + // Dry carries 0xC0 in the temperature byte; the target stays at 24 + EXPECT_TRUE(r.receive(state_frame(0x21, 0xC0, 0x7F, 0x0F))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + // Fan only carries 0x32; the target still stays at 24 + EXPECT_TRUE(r.receive(state_frame(0x61, 0x32, 0xBF, 0x00))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_QUIET); + EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(DaikinRegression, DecodesPowerOffAsOff) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + ASSERT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + // Cool with the power bit clear, at 26: mode becomes off and the temperature is not applied + EXPECT_TRUE(r.receive(state_frame(0x30, 0x34, 0x50, 0x00))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); +} + +TEST(DaikinRegression, RejectsBadChecksum) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + auto frame = state_frame(0x41, 0x2C, 0x50, 0x00); + frame[18] ^= 0x01; + EXPECT_FALSE(r.receive(frame)); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_AUTO); +} + +TEST(DaikinRegression, RejectsWrongHeaderByte) { + for (size_t index = 0; index < 5; index++) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + auto frame = state_frame(0x41, 0x2C, 0x50, 0x00); + frame[index] ^= 0x01; + // Recompute the checksum so only the header byte is wrong + uint8_t total = 0; + for (size_t i = 0; i < 18; i++) + total += frame[i]; + frame[18] = total; + SCOPED_TRACE(index); + EXPECT_FALSE(r.receive(frame)); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + } +} + +} // namespace esphome::daikin::regression diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp new file mode 100644 index 00000000000..5a71cf41b7f --- /dev/null +++ b/tests/components/daikin/daikin_tests.cpp @@ -0,0 +1,440 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/daikin/daikin.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::daikin::testing { + +static constexpr size_t MESSAGE_SIZE = 35; +using Message = std::array; +static constexpr uint32_t BOOST_TIMEOUT_ID = 0; + +// The host scheduler reads the real clock, so the 20 minute boost timer cannot elapse in a test. +// Explicit instantiation skips access checks, which lets the test run the timer's callback itself. +template struct BoostCallback { + friend void run_boost_callback(DaikinClimate &climate) { (climate.*M)(); } +}; +void run_boost_callback(DaikinClimate &climate); +template struct BoostCallback<&DaikinClimate::cancel_boost_mode_>; + +static uint8_t sum(const uint8_t *data, size_t len) { + uint8_t total = 0; + for (size_t i = 0; i < len; i++) + total += data[i]; + return total; +} + +static std::string hex(const uint8_t *data, size_t len) { + std::string out; + for (size_t i = 0; i < len; i++) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", data[i]); + out += buf; + } + return out; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +/// Reads `count` LSB-first bytes starting at raw[pos], advancing pos past them +static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) { + for (size_t byte = 0; byte < count; byte++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_BIT_MARK)) << "bit mark at " << pos; + const int32_t space = -raw[pos + 1]; + if (space == static_cast(DAIKIN_ONE_SPACE)) { + value |= 1 << bit; + } else { + EXPECT_EQ(space, static_cast(DAIKIN_ZERO_SPACE)) << "bit space at " << pos + 1; + } + pos += 2; + } + out[byte] = value; + } +} + +static void expect_header(const remote_base::RawTimings &raw, size_t &pos) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_HEADER_MARK)) << "header mark at " << pos; + EXPECT_EQ(raw[pos + 1], -static_cast(DAIKIN_HEADER_SPACE)) << "header space at " << pos + 1; + pos += 2; +} + +static void expect_trailer(const remote_base::RawTimings &raw, size_t &pos, uint32_t space) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_BIT_MARK)) << "trailer mark at " << pos; + EXPECT_EQ(raw[pos + 1], -static_cast(space)) << "trailer space at " << pos + 1; + pos += 2; +} + +/// Decodes a captured transmission back into its 35 bytes, checking headers and frame gaps +static Message decode(const remote_base::RawTimings &raw) { + Message msg{}; + // Three frames of 8, 8 and 19 bytes, each a header, two timings per bit and a trailer + const size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE; + EXPECT_EQ(raw.size(), expected_size); + if (raw.size() != expected_size) + return msg; + size_t pos = 0; + expect_header(raw, pos); + read_bytes(raw, pos, msg.data(), 8); + expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE); + expect_header(raw, pos); + read_bytes(raw, pos, msg.data() + 8, 8); + expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE); + expect_header(raw, pos); + read_bytes(raw, pos, msg.data() + 16, 19); + expect_trailer(raw, pos, 0); + return msg; +} + +/// Timings for one received frame (8 or 19 bytes) as the receiver would deliver it +static remote_base::RawTimings frame_timings(const std::vector &bytes) { + remote_base::RawTimings raw; + raw.push_back(DAIKIN_HEADER_MARK); + raw.push_back(-static_cast(DAIKIN_HEADER_SPACE)); + for (uint8_t byte : bytes) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(DAIKIN_BIT_MARK); + raw.push_back(-static_cast((byte >> bit) & 1 ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE)); + } + } + raw.push_back(DAIKIN_BIT_MARK); + raw.push_back(-static_cast(DAIKIN_MESSAGE_SPACE)); + return raw; +} + +/// The 0xC5 frame, with a valid checksum unless one is given +static std::vector comfort_frame(uint8_t comfort) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, comfort, 0x00}; + f[7] = sum(f.data(), 7); + return f; +} + +/// The 19-byte state frame: cool at `temperature` (half degrees), fan auto, no swing +static std::vector state_frame(uint8_t byte13, uint8_t byte16, uint8_t temperature = 44) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0x00, DAIKIN_MODE_ON | DAIKIN_MODE_COOL, + temperature, 0x00, 0xA0, 0x00, 0x00, 0x06, + 0x60, byte13, 0x00, 0xC0, byte16, 0x00, + 0x00}; + f[18] = sum(f.data(), 18); + return f; +} + +struct Fixture { + CapturingTransmitter transmitter; + DaikinClimate climate; + + /// Pass keep_preferences to restore what an earlier fixture saved + explicit Fixture(bool keep_preferences = false) { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (!keep_preferences && global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does; the boost timer needs its scheduler + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_daikin"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + ~Fixture() { + // Drop a pending boost timer while the climate is alive; App outlives each test + App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID); + App.scheduler.call(millis()); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimatePreset preset) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_preset(preset); + call.perform(); + } + + Message last_message() const { return decode(this->transmitter.frames.back()); } + + bool receive(const std::vector &bytes) { + const remote_base::RawTimings raw = frame_timings(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(data); + } + + /// Fires the boost timer as the scheduler would if it is pending; returns whether it was + bool fire_boost_timer() { + if (!App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID)) + return false; + run_boost_callback(this->climate); + return true; + } +}; + +static void expect_checksums(const Message &m) { + EXPECT_EQ(m[7], sum(m.data(), 7)) << hex(m.data(), MESSAGE_SIZE); + EXPECT_EQ(m[34], sum(m.data() + 16, 18)) << hex(m.data(), MESSAGE_SIZE); +} + +TEST(Daikin, EncodesHalfDegree) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_NONE); + ASSERT_EQ(f.transmitter.frames.size(), 1u); + const Message m = f.last_message(); + EXPECT_EQ(m[16 + 5], DAIKIN_MODE_ON | DAIKIN_MODE_COOL); + EXPECT_EQ(m[22], 45); + EXPECT_EQ(m[6], 0x00); + EXPECT_EQ(m[29], 0x00); + EXPECT_EQ(m[32], 0x00); + expect_checksums(m); +} + +TEST(Daikin, EncodesEachPresetInItsOwnByte) { + struct Case { + climate::ClimatePreset preset; + uint8_t byte6, byte29, byte32; + }; + const Case cases[] = { + {climate::CLIMATE_PRESET_NONE, 0x00, 0x00, 0x00}, {climate::CLIMATE_PRESET_BOOST, 0x00, 0x01, 0x00}, + {climate::CLIMATE_PRESET_SLEEP, 0x00, 0x20, 0x00}, {climate::CLIMATE_PRESET_ECO, 0x00, 0x00, 0x04}, + {climate::CLIMATE_PRESET_ACTIVITY, 0x00, 0x00, 0x02}, {climate::CLIMATE_PRESET_COMFORT, 0x10, 0x00, 0x00}, + }; + Fixture f; + uint8_t plain_first_checksum = 0; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.preset)); + f.command(climate::CLIMATE_MODE_COOL, 22.5f, c.preset); + const Message m = f.last_message(); + EXPECT_EQ(m[6], c.byte6); + EXPECT_EQ(m[29], c.byte29); + EXPECT_EQ(m[32], c.byte32); + EXPECT_EQ(m[22], 45); + expect_checksums(m); + if (c.preset == climate::CLIMATE_PRESET_NONE) { + plain_first_checksum = m[7]; + } else if (c.preset == climate::CLIMATE_PRESET_COMFORT) { + EXPECT_NE(m[7], plain_first_checksum); + EXPECT_EQ(static_cast(m[7] - plain_first_checksum), 0x10); + } else { + EXPECT_EQ(m[7], plain_first_checksum); + } + } +} + +TEST(Daikin, ReceivedComfortFrameSetsComfort) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); +} + +TEST(Daikin, ReceivedPowerfulGivesBoost) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); +} + +TEST(Daikin, ReceivedStateWithoutBitsClearsEco) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, ReceivedOtherPresets) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x20, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x02))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ACTIVITY); +} + +TEST(Daikin, ReceivedHalfDegree) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00, 45))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f); +} + +TEST(Daikin, ReceivedFrameRoundTripsTransmittedState) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_COMFORT); + const Message m = f.last_message(); + f.command(climate::CLIMATE_MODE_HEAT, 18.0f, climate::CLIMATE_PRESET_NONE); + EXPECT_TRUE(f.receive(std::vector(m.begin(), m.begin() + 8))); + EXPECT_TRUE(f.receive(std::vector(m.begin() + 16, m.end()))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, BoostTimerCancelledWhenRemoteLeavesBoost) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + // No boost timer may remain to end a boost that is already over + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); +} + +TEST(Daikin, BoostTimerCancelledWhenEntityLeavesBoost) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_SLEEP); + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); +} + +TEST(Daikin, ReceivedBoostEndsWhenTimerFires) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, RepeatedBoostFrameKeepsOneTimer) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_TRUE(f.fire_boost_timer()); + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, BoostToComfortTimerDoesNotChangePreset) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + // The state frame saw boost as the previous preset and cancelled the timer + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, ComfortFrameAloneChangesNothing) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + + // The comfort frame is only recorded until a valid state frame follows + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + std::vector bad = state_frame(0x00, 0x00); + bad[18] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, LostStateFrameKeepsComfortUntilNextTransmission) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + + // Comfort off arrives but its state frame is lost: nothing changes + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + + // The next transmission says comfort off again and its state frame arrives + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, PendingComfortExpiresAfterOneSecond) { + Fixture f; + // LoopBlockingGuard publishes the dispatch time the component reads as the loop clock + { LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000); } + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + // The state frame of that transmission is lost; the next one arrives after the window + { LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000 + DAIKIN_TRANSMISSION_MS + 1); } + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, StateFrameWithoutComfortFrameKeepsComfort) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + // A state frame whose 0xC5 frame was lost leaves a current comfort preset alone + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, RestoredBoostArmsTheTimer) { + { + Fixture first; + first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + ASSERT_EQ(first.climate.preset, climate::CLIMATE_PRESET_BOOST); + } + Fixture restored(true); + EXPECT_EQ(restored.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_EQ(restored.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_TRUE(App.scheduler.cancel_timeout(&restored.climate, BOOST_TIMEOUT_ID)); +} + +TEST(Daikin, FreshClimateHasNoBoostTimer) { + { + Fixture first; + first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + } + // A fixture that clears the saved state restores nothing and arms no timer + Fixture fresh; + EXPECT_NE(fresh.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_FALSE(App.scheduler.cancel_timeout(&fresh.climate, BOOST_TIMEOUT_ID)); +} + +TEST(Daikin, RejectsBadStateChecksum) { + Fixture f; + f.command(climate::CLIMATE_MODE_HEAT, 20.0f, climate::CLIMATE_PRESET_SLEEP); + std::vector bad = state_frame(0x01, 0x00, 45); + bad[18] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); + EXPECT_FALSE(f.fire_boost_timer()); +} + +TEST(Daikin, RejectsBadComfortChecksum) { + Fixture f; + std::vector bad = comfort_frame(0x10); + bad[7] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +} // namespace esphome::daikin::testing diff --git a/tests/components/daikin/test.esp32-idf.yaml b/tests/components/daikin/test.esp32-idf.yaml new file mode 100644 index 00000000000..62ac9ac7ab9 --- /dev/null +++ b/tests/components/daikin/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + daikin: !include common.yaml diff --git a/tests/components/daikin/test.esp8266-ard.yaml b/tests/components/daikin/test.esp8266-ard.yaml index 4bed2f03e55..6d9ed4c4360 100644 --- a/tests/components/daikin/test.esp8266-ard.yaml +++ b/tests/components/daikin/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + daikin: !include common.yaml From 84694b602a74cf190fac54370e3dec4a3e116228 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 02:14:38 -0700 Subject: [PATCH 218/435] [ld2412] Fix crash when only some gate thresholds are configured (#18194) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: J. Nick Koston --- esphome/components/ld2412/ld2412.cpp | 117 +++-- esphome/components/ld2412/ld2412.h | 27 +- .../ld2412/number/gate_threshold_number.cpp | 2 +- .../uart_mock_ld2412_footer_in_payload.yaml | 74 +++ ...uart_mock_ld2412_requery_after_giveup.yaml | 61 +++ .../uart_mock_ld2412_truncated_answer.yaml | 72 +++ tests/integration/test_uart_mock_ld2412.py | 425 +++++++++++++++--- 7 files changed, 690 insertions(+), 88 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index bd43d06cc27..c6137f29f7c 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -10,6 +10,9 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include +#include + namespace esphome::ld2412 { ESPHOME_LOG_TAG(TAG, "ld2412"); @@ -73,8 +76,10 @@ enum PeriodicDataValue : uint8_t { }; enum AckData : uint8_t { + FRAME_LENGTH = 4, // little-endian count of the bytes between it and the footer COMMAND = 6, COMMAND_STATUS = 7, + ACK_PAYLOAD = 10, }; // Memory-efficient lookup tables @@ -191,6 +196,9 @@ static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0x static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5}; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE; +// The length field counts the command word, the status word and the gates +static constexpr uint16_t GATE_SENS_ACK_LENGTH = GATE_SENS_ACK_SIZE - COMMAND - HEADER_FOOTER_SIZE; static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; @@ -240,10 +248,10 @@ void LD2412Component::dump_config() { LOG_NUMBER(" ", "MaxDistanceGate", this->max_distance_gate_number_); LOG_NUMBER(" ", "MinDistanceGate", this->min_distance_gate_number_); LOG_NUMBER(" ", "Timeout", this->timeout_number_); - for (number::Number *n : this->gate_move_threshold_numbers_) { + for (number::Number *n : this->gate_move_thresholds_.numbers) { LOG_NUMBER(" ", "Move Thresholds", n); } - for (number::Number *n : this->gate_still_threshold_numbers_) { + for (number::Number *n : this->gate_still_thresholds_.numbers) { LOG_NUMBER(" ", "Still Thresholds", n); } #endif @@ -460,6 +468,13 @@ void set_number_value(number::Number *n, float value) { n->publish_state(value); } } + +// NaN marks a value the module never received +static inline bool has_value(number::Number *n) { return n != nullptr && n->has_state() && !std::isnan(n->state); } + +// Timeout ids 0 (still) and 1 (motion) for a write waiting on a re-query +static constexpr uint32_t GATE_QUERY_TIMEOUT_ID = 0; +static constexpr uint32_t GATE_QUERY_TIMEOUT_MS = 2000; #endif bool LD2412Component::handle_ack_data_() { @@ -595,19 +610,34 @@ bool LD2412Component::handle_ack_data_() { ESP_LOGV(TAG, "Handled set light control command"); break; - case CMD_QUERY_MOTION_GATE_SENS: { -#ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[10 + i]); - } -#endif - break; - } - + case CMD_QUERY_MOTION_GATE_SENS: case CMD_QUERY_STATIC_GATE_SENS: { #ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[10 + i]); + const uint16_t declared = encode_uint16(this->buffer_data_[FRAME_LENGTH + 1], this->buffer_data_[FRAME_LENGTH]); + if (declared != GATE_SENS_ACK_LENGTH || this->buffer_pos_ > GATE_SENS_ACK_SIZE) { + // A truncated answer merged with the next frame, or a length this driver does not know + ESP_LOGW(TAG, "Dropping gate threshold answer of %u bytes", this->buffer_pos_); + return true; + } + if (this->buffer_pos_ < GATE_SENS_ACK_SIZE) { + return false; // the footer pattern appeared inside the payload, keep reading + } + const bool motion = this->buffer_data_[COMMAND] == CMD_QUERY_MOTION_GATE_SENS; + GateThresholds &group = this->gate_thresholds_(motion); + // True only while a held-back write waits for this answer + const bool pending = this->cancel_timeout(GATE_QUERY_TIMEOUT_ID + motion); + for (size_t i = 0; i < TOTAL_GATES; i++) { + group.last_read[i] = this->buffer_data_[ACK_PAYLOAD + i]; + // The pending write's user values must survive the answer + if (!pending || !has_value(group.numbers[i])) { + set_number_value(group.numbers[i], group.last_read[i]); + } + } + group.read = true; + if (pending) { + this->set_config_mode_(true); + this->send_gate_thresholds_(motion); + this->set_config_mode_(false); } #endif break; @@ -747,6 +777,11 @@ void LD2412Component::set_engineering_mode(bool enable) { void LD2412Component::factory_reset() { this->set_config_mode_(true); this->send_command_(CMD_FACTORY_RESET, nullptr, 0); +#ifdef USE_NUMBER + // Reset discards the thresholds; the next write must wait for the re-read + this->gate_move_thresholds_.read = false; + this->gate_still_thresholds_.read = false; +#endif this->set_timeout(2000, [this]() { this->restart_and_read_all_info(); }); } @@ -805,24 +840,48 @@ void LD2412Component::set_basic_config() { } #ifdef USE_NUMBER -void LD2412Component::set_gate_threshold() { - if (this->gate_move_threshold_numbers_.empty() && this->gate_still_threshold_numbers_.empty()) { - return; // No gate threshold numbers set; nothing to do here +void LD2412Component::send_gate_thresholds_(bool motion) { + GateThresholds &group = this->gate_thresholds_(motion); + const uint8_t command = motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS; + uint8_t value[TOTAL_GATES]; + uint8_t configured = 0; + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + number::Number *n = group.numbers[i]; + if (has_value(n)) { + value[i] = static_cast(n->state); + configured++; + } else { + value[i] = group.last_read[i]; + } } - uint8_t value[TOTAL_GATES] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + if (configured < TOTAL_GATES && !group.read) { + // Unread gates would go out as zero, which is maximum sensitivity + ESP_LOGW(TAG, "Command %02X held back until the module reports its gate thresholds", command); + this->send_command_(motion ? CMD_QUERY_MOTION_GATE_SENS : CMD_QUERY_STATIC_GATE_SENS, nullptr, 0); + this->set_timeout(GATE_QUERY_TIMEOUT_ID + motion, GATE_QUERY_TIMEOUT_MS, + [this, motion]() { this->gate_query_timeout_(motion); }); + return; + } + this->send_command_(command, value, sizeof(value)); +} + +void LD2412Component::gate_query_timeout_(bool motion) { + ESP_LOGE(TAG, "Command %02X not sent, the module did not report its gate thresholds", + motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS); + // The values on hand never reached the module or are stale, so forgetting them loses nothing + for (number::Number *n : this->gate_thresholds_(motion).numbers) { + if (n != nullptr) { + n->publish_state(NAN); + } + } +} + +void LD2412Component::set_gate_threshold(number::Number *n) { + // Found by pointer: 14 compares per write beat a flag per number + const auto &motion_numbers = this->gate_move_thresholds_.numbers; + const bool motion = std::find(motion_numbers.begin(), motion_numbers.end(), n) != motion_numbers.end(); this->set_config_mode_(true); - if (!this->gate_move_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_move_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_MOTION_GATE_SENS, value, sizeof(value)); - } - if (!this->gate_still_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_still_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_STATIC_GATE_SENS, value, sizeof(value)); - } + this->send_gate_thresholds_(motion); this->set_config_mode_(false); } diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index a52402c2ea3..c427d024f39 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -85,11 +85,13 @@ class LD2412Component final : public Component, public uart::UARTDevice { void set_light_out_control(); void set_basic_config(); #ifdef USE_NUMBER - void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { this->gate_move_threshold_numbers_[gate] = n; } - void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { - this->gate_still_threshold_numbers_[gate] = n; + void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { + this->gate_move_thresholds_.numbers[gate] = n; } - void set_gate_threshold(); + void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { + this->gate_still_thresholds_.numbers[gate] = n; + } + void set_gate_threshold(number::Number *n); void get_gate_threshold(); #endif #ifdef USE_SENSOR @@ -118,6 +120,19 @@ class LD2412Component final : public Component, public uart::UARTDevice { void query_light_control_(); void restart_(); void query_dynamic_background_correction_(); +#ifdef USE_NUMBER + // One command carries all 14 gates, so unconfigured gates get the module's last value + struct GateThresholds { + std::array numbers{}; + std::array last_read{}; + bool read{false}; // answered since boot or the last factory reset + }; + GateThresholds &gate_thresholds_(bool motion) { + return motion ? this->gate_move_thresholds_ : this->gate_still_thresholds_; + } + void send_gate_thresholds_(bool motion); + void gate_query_timeout_(bool motion); +#endif uint8_t light_function_ = 0; uint8_t light_threshold_ = 0; @@ -129,8 +144,8 @@ class LD2412Component final : public Component, public uart::UARTDevice { bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; #ifdef USE_NUMBER - std::array gate_move_threshold_numbers_{}; - std::array gate_still_threshold_numbers_{}; + GateThresholds gate_move_thresholds_; + GateThresholds gate_still_thresholds_; #endif #ifdef USE_SENSOR std::array, TOTAL_GATES> gate_move_sensors_{}; diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index a0a525a8107..1af7b1e7f20 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2412 { void GateThresholdNumber::control(float value) { this->publish_state(value); - this->parent_->set_gate_threshold(); + this->parent_->set_gate_threshold(this); } } // namespace esphome::ld2412 diff --git a/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml b/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml new file mode 100644 index 00000000000..ae3c2e45d54 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml @@ -0,0 +1,74 @@ +esphome: + name: uart-mock-ld2412-footer + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + # Both answers hold the footer 04 03 02 01 inside the gate bytes, so the parser must read on to the real footer + responses: + # Gates 5 to 8 hold 04 03 02 01 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x12, 0x00, + 0x13, 0x01, + 0x00, 0x00, + 0x00, 0x23, 0x23, 0x23, 0x23, 0x04, 0x03, + 0x02, 0x01, 0x19, 0x19, 0x19, 0x19, 0x19, + 0x04, 0x03, 0x02, 0x01, + ] + # Gates 10 to 13 hold 04 03 02 01, right before the real footer + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x14, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x12, 0x00, + 0x14, 0x01, + 0x00, 0x00, + 0x00, 0x23, 0x23, 0x23, 0x23, 0x19, 0x19, + 0x19, 0x19, 0x19, 0x04, 0x03, 0x02, 0x01, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +# Only 2 of the 14 gates have a number; gate 5 holds a footer byte in the motion answer +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" + +button: + - platform: ld2412 + ld2412_id: ld2412_dev + factory_reset: + name: "Factory Reset" diff --git a/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml b/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml new file mode 100644 index 00000000000..82a3c01de70 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml @@ -0,0 +1,61 @@ +esphome: + name: uart-mock-ld2412-regiveup + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Only the third motion query is answered: the setup query and the first write's re-query go unanswered, +# so that write is given up. The still query is never answered +uart_mock: + id: mock_uart + baud_rate: 256000 + on_tx: + - then: + - lambda: |- + // The length and command word arrive as one write of their own + static const std::vector MOTION_QUERY = {0x02, 0x00, 0x13, 0x00}; + static int motion_queries = 0; + if (data != MOTION_QUERY || ++motion_queries != 3) { + return; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, // header + 0x12, 0x00, // length 18 + 0x13, 0x01, // answer to command 0x0013 + 0x00, 0x00, // status: success + 0x19, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, // gates 0 to 6 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, // gates 7 to 13 + 0x04, 0x03, 0x02, 0x01, // footer + }); + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" diff --git a/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml b/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml new file mode 100644 index 00000000000..9950e78042a --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml @@ -0,0 +1,72 @@ +esphome: + name: uart-mock-ld2412-truncated + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + responses: + # The motion answer is two gate bytes short, so the parser reads on into the next acknowledgement + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, # motion header + 0x12, 0x00, # length 18, but only 16 bytes follow + 0x13, 0x01, # answer to command 0x0013 + 0x00, 0x00, # status: success + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, # gates 0 to 5 + 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, # gates 6 to 11, gates 12 and 13 are missing + 0x04, 0x03, 0x02, 0x01, # motion footer + 0xFD, 0xFC, 0xFB, 0xFA, # next frame: the config-mode end acknowledgement + 0x04, 0x00, # length 4 + 0xFE, 0x01, # answer to command 0x00FE + 0x00, 0x00, # status: success + 0x04, 0x03, 0x02, 0x01, # its footer; the merged 40-byte frame gets dropped + ] + # A complete answer after setup proves the parser recovered + injections: + - delay: 1500ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, # header + 0x12, 0x00, # length 18 + 0x13, 0x01, # answer to command 0x0013 + 0x00, 0x00, # status: success + 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, # gates 0 to 6 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, # gates 7 to 13 + 0x04, 0x03, 0x02, 0x01, # footer + ] + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index ea2ec38b2a3..e9a69ca870a 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -20,19 +20,120 @@ test_uart_mock_ld2412_engineering_truncated (truncated engineering mode): 2. Truncated engineering frame (24 bytes) is rejected — gate/light sensors must not receive garbage from stale buffer data or frame footer bytes 3. Recovery frame with different values proves the component survived + +The gate threshold tests (footer_in_payload, truncated_answer, requery_after_giveup) cover +answers that hold the frame footer or are truncated, writes that fill the gates without a +number from the module's values, and writes held back until the module reports them. """ from __future__ import annotations import asyncio -from pathlib import Path +from collections.abc import Callable +import math -from aioesphomeapi import ButtonInfo +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + NumberInfo, + NumberState, +) import pytest -from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .state_utils import ( + InitialStateHelper, + SensorStateCollector, + StateWaiter, + find_entity, + require_entity, +) from .types import APIClientConnectedFactory, RunCompiledFunction +# Length and command word of each frame as the uart mock logs it; the payload is logged on its own TX line +MOTION_COMMAND = "10:00:03:00" +STILL_COMMAND = "10:00:04:00" +MOTION_QUERY = "02:00:13:00" +STILL_QUERY = "02:00:14:00" +CONFIG_MODE_LEFT = "02:00:FE:00" +FACTORY_RESET = "02:00:A2:00" +# Logged in place of the motion command when the write is held back or given up +MOTION_HELD_BACK = "Command 03 held back" +MOTION_GIVEN_UP = "Command 03 not sent, the module did not report" + +THRESHOLD_NUMBERS = ( + "gate_0_move_threshold", + "gate_0_still_threshold", + "gate_5_move_threshold", + "gate_5_still_threshold", +) + + +def _is_tx(line: str) -> bool: + return "uart_mock" in line and "TX " in line + + +def _threshold_numbers(entities: list[EntityInfo]) -> dict[str, NumberInfo]: + """Return the gate 0 and gate 5 threshold numbers by name.""" + return { + name: require_entity(entities, name, NumberInfo) for name in THRESHOLD_NUMBERS + } + + +async def _wait_initial( + client: APIClient, + entities: list[EntityInfo], + on_state: Callable[[EntityState], None] = lambda s: None, +) -> InitialStateHelper: + """Subscribe to states and wait until every entity has sent its initial state.""" + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + return initial_state_helper + + +def _number_state_of(key: int) -> Callable[[EntityState], bool]: + """Return a predicate that matches a number state with a value for ``key``.""" + return lambda s: isinstance(s, NumberState) and not s.missing_state and s.key == key + + +def _is_nan_of(key: int) -> Callable[[EntityState], bool]: + """Return a predicate that matches the number ``key`` being marked unknown.""" + return lambda s: _number_state_of(key)(s) and math.isnan(s.state) + + +class _LogEvents: + """Record the TX lines and the log lines holding one of ``texts``, in the order they were logged.""" + + def __init__(self, *texts: str) -> None: + self._texts = texts + self.lines: list[str] = [] + self._awaited: dict[str, asyncio.Future[None]] = {} + + def on_line(self, line: str) -> None: + if _is_tx(line) or any(text in line for text in self._texts): + self.lines.append(line) + for text, future in self._awaited.items(): + if text in line and not future.done(): + future.set_result(None) + + def next_line_with(self, text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + self._awaited[text] = future + return future + + def index_after(self, start: int, text: str) -> int: + return next( + i for i, line in enumerate(self.lines) if i > start and text in line + ) + + def count(self, text: str) -> int: + return sum(text in line for line in self.lines) + @pytest.mark.asyncio async def test_uart_mock_ld2412( @@ -41,14 +142,6 @@ async def test_uart_mock_ld2412( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2412 data parsing with happy path, garbage, overflow, and recovery.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - loop = asyncio.get_running_loop() # Track overflow warning in logs @@ -61,7 +154,7 @@ async def test_uart_mock_ld2412( if "Max command length exceeded" in line and not overflow_seen.done(): overflow_seen.set_result(True) # Capture all TX log lines from uart_mock - if "uart_mock" in line and "TX " in line: + if _is_tx(line): tx_log_lines.append(line) collector = SensorStateCollector( @@ -97,16 +190,7 @@ async def test_uart_mock_ld2412( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) # Start the UART mock scenario now that we're subscribed start_btn = find_entity(entities, "start_scenario", ButtonInfo) @@ -176,12 +260,6 @@ async def test_uart_mock_ld2412_engineering( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2412 engineering mode with per-gate energy, light, and multi-byte distance.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) collector = SensorStateCollector( sensor_names=[ @@ -220,15 +298,7 @@ async def test_uart_mock_ld2412_engineering( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) # Start the UART mock scenario now that we're subscribed start_btn = find_entity(entities, "start_scenario", ButtonInfo) @@ -288,12 +358,6 @@ async def test_uart_mock_ld2412_engineering_truncated( check but reads indices 17-45 from stale buffer data, publishing garbage values (e.g. frame footer bytes 0xF8=248 as gate energy). """ - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) loop = asyncio.get_running_loop() @@ -341,15 +405,7 @@ async def test_uart_mock_ld2412_engineering_truncated( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) start_btn = find_entity(entities, "start_scenario", ButtonInfo) assert start_btn is not None, "Start Scenario button not found" @@ -397,3 +453,268 @@ async def test_uart_mock_ld2412_engineering_truncated( f"truncated frame likely leaked stale buffer data. " f"All values: {collector.sensor_states['gate_0_move_energy']}" ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_footer_in_payload( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test threshold answers that hold the frame footer 04 03 02 01, and the writes built from them.""" + # Gate 0 set to 77 (0x4D); the other 13 gates must match the motion answer byte for byte + expected_motion = "4D:23:23:23:23:04:03:02:01:19:19:19:19:19" + # Gate 5 set to 77, the rest matches the still answer + expected_still = "00:23:23:23:23:4D:19:19:19:19:04:03:02:01" + # Gate 0 set to 50 (0x32) after the reset + expected_motion_after_reset = "32:23:23:23:23:04:03:02:01:19:19:19:19:19" + events = _LogEvents(MOTION_HELD_BACK) + + async with ( + run_compiled(yaml_config, line_callback=events.on_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + initial_state_helper = await _wait_initial(client, entities) + numbers = _threshold_numbers(entities) + + # Gate 5 move is the first footer byte inside the motion answer + for name, value in { + "gate_0_move_threshold": 0.0, + "gate_5_move_threshold": 4.0, + "gate_0_still_threshold": 0.0, + "gate_5_still_threshold": 25.0, + }.items(): + state = initial_state_helper.initial_states[numbers[name].key] + assert state.state == pytest.approx(value), name + + motion_written = events.next_line_with(expected_motion) + client.number_command(numbers["gate_0_move_threshold"].key, 77.0) + try: + await asyncio.wait_for(motion_written, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for the motion threshold command.\n" + f" expected motion payload: {expected_motion}" + ) + + assert events.count(STILL_COMMAND) == 0, ( + "still threshold command was sent for a motion write" + ) + + still_written = events.next_line_with(expected_still) + client.number_command(numbers["gate_5_still_threshold"].key, 77.0) + try: + await asyncio.wait_for(still_written, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for the still threshold command.\n" + f" expected still payload: {expected_still}" + ) + + assert events.count(MOTION_COMMAND) == 1 + assert events.count(STILL_COMMAND) == 1 + assert events.count(MOTION_HELD_BACK) == 0 + + # The reset discards the module's thresholds, so the next write is held back and re-queried + reset_sent = events.next_line_with(FACTORY_RESET) + client.button_command(require_entity(entities, "factory_reset", ButtonInfo).key) + try: + await asyncio.wait_for(reset_sent, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for the factory reset command") + + held_back = events.next_line_with(MOTION_HELD_BACK) + written_after_reset = events.next_line_with(expected_motion_after_reset) + client.number_command(numbers["gate_0_move_threshold"].key, 50.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back, written_after_reset), timeout=5.0 + ) + except TimeoutError: + pytest.fail( + "Timeout waiting for the write after the factory reset.\n" + f" expected motion payload: {expected_motion_after_reset}\n" + f" logged: {events.lines}" + ) + reset_at = events.index_after(-1, FACTORY_RESET) + warning_at = events.index_after(reset_at, MOTION_HELD_BACK) + query_at = events.index_after(warning_at, MOTION_QUERY) + events.index_after(query_at, expected_motion_after_reset) + + entities_after, _ = await client.list_entities_services() + assert len(entities_after) == len(entities) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_truncated_answer( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a threshold answer merged with the next frame is dropped and the parser recovers.""" + loop = asyncio.get_running_loop() + + dropped = loop.create_future() + warnings: list[str] = [] + + def line_callback(line: str) -> None: + if "[W][ld2412" in line: + warnings.append(line) + if "Dropping gate threshold answer" in line and not dropped.done(): + dropped.set_result(True) + + # Gate bytes of the truncated answer + merged_values = set(range(0x30, 0x3C)) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + numbers = _threshold_numbers(entities) + + states: dict[int, list[float]] = {} + waiter = StateWaiter() + gate_0_read = waiter.expect( + _number_state_of(numbers["gate_0_move_threshold"].key), + label="gate 0 move threshold", + ) + gate_5_read = waiter.expect( + _number_state_of(numbers["gate_5_move_threshold"].key), + label="gate 5 move threshold", + ) + + def record(state: EntityState) -> None: + if isinstance(state, NumberState) and not state.missing_state: + states.setdefault(state.key, []).append(state.state) + waiter.on_state(state) + + initial_state_helper = await _wait_initial(client, entities, record) + + # The late answer may already have been parsed when the client connected + for state in initial_state_helper.initial_states.values(): + record(state) + + try: + _, gate_0_state, gate_5_state = await asyncio.gather( + asyncio.wait_for(dropped, timeout=5.0), gate_0_read, gate_5_read + ) + except TimeoutError: + pytest.fail( + "Timeout waiting for the threshold answers.\n" + f" ld2412 warnings: {warnings}\n" + f" states: {states}" + ) + + assert gate_0_state.state == pytest.approx(10.0) + assert gate_5_state.state == pytest.approx(15.0) + + for number in numbers.values(): + for value in states.get(number.key, []): + assert int(value) not in merged_values, ( + f"{number.object_id} took {value} from the merged frame" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_requery_after_giveup( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a write held back until the module reports its thresholds: given up once, then sent.""" + # Gate 0 takes the module value 25 (0x19), gate 5 the user value 30 (0x1E) + expected_motion = "19:0B:0C:0D:0E:1E:10:11:12:13:14:15:16:17" + events = _LogEvents(MOTION_HELD_BACK, MOTION_GIVEN_UP) + + async with ( + run_compiled(yaml_config, line_callback=events.on_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + numbers = _threshold_numbers(entities) + gate_0_key = numbers["gate_0_move_threshold"].key + gate_5_key = numbers["gate_5_move_threshold"].key + + gate_5_values: list[float] = [] + waiter = StateWaiter() + + def on_state(state: EntityState) -> None: + if _number_state_of(gate_5_key)(state): + gate_5_values.append(state.state) + waiter.on_state(state) + + initial_state_helper = await _wait_initial(client, entities, on_state) + + for key in (gate_0_key, gate_5_key): + assert initial_state_helper.initial_states[key].missing_state + + held_back = events.next_line_with(MOTION_HELD_BACK) + config_mode_left = events.next_line_with(CONFIG_MODE_LEFT) + given_up = events.next_line_with(MOTION_GIVEN_UP) + unknown = [ + waiter.expect(_is_nan_of(key), label="NaN") + for key in (gate_0_key, gate_5_key) + ] + client.number_command(gate_5_key, 20.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back, config_mode_left), timeout=5.0 + ) + except TimeoutError: + pytest.fail(f"Timeout waiting for the write to be handled: {events.lines}") + # The unanswered query times out after 2 s + try: + await asyncio.wait_for(given_up, timeout=3.0) + await asyncio.gather(*unknown) + except TimeoutError as err: + pytest.fail(f"Timeout waiting for the first write to be given up: {err}") + + warning_at = events.index_after(-1, MOTION_HELD_BACK) + left_at = events.index_after(warning_at, CONFIG_MODE_LEFT) + write_window = events.lines[warning_at:left_at] + assert any(MOTION_QUERY in line for line in write_window), ( + "motion thresholds were not queried again after the command was held back" + ) + assert not any(STILL_QUERY in line for line in write_window), ( + "still thresholds were queried for a motion write" + ) + assert events.count(MOTION_COMMAND) == 0, ( + "motion threshold command was sent without a value for the gates that have no number" + ) + + held_back_again = events.next_line_with(MOTION_HELD_BACK) + motion_written = events.next_line_with(expected_motion) + gate_0_refilled = waiter.expect( + lambda s: _number_state_of(gate_0_key)(s) and not math.isnan(s.state), + label="gate 0 move threshold from the module", + ) + gate_5_values.clear() + client.number_command(gate_5_key, 30.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back_again, motion_written), timeout=5.0 + ) + gate_0_state = await gate_0_refilled + except TimeoutError as err: + pytest.fail( + f"Timeout waiting for the second write: {err}\n" + f" expected motion payload: {expected_motion}\n" + f" logged: {events.lines}" + ) + + warning_at = events.index_after(left_at, MOTION_HELD_BACK) + query_at = events.index_after(warning_at, MOTION_QUERY) + write_at = events.index_after(query_at, expected_motion) + assert MOTION_COMMAND in events.lines[write_at - 1] + + # One query each from setup, the given-up write and the second write + assert events.count(MOTION_QUERY) == 3 + assert events.count(STILL_COMMAND) == 0, ( + "still threshold command was sent for a motion write" + ) + + assert gate_0_state.state == pytest.approx(25.0) + assert gate_5_values + assert all(value == pytest.approx(30.0) for value in gate_5_values) From 681b48165432c78f61fe89f16f369d23f7557229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 00:02:00 -1000 Subject: [PATCH 219/435] [tests] Reset shared App state before every host test (#20510) --- tests/components/main.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/main.cpp b/tests/components/main.cpp index aa0ceb5abe6..f1ceb732f91 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -1,6 +1,7 @@ #include #include "esphome/components/logger/logger.h" +#include "esphome/core/application.h" /* This special main.cpp replaces the default one. @@ -19,6 +20,11 @@ void original_setup() { // =========== AUTO GENERATED CODE END ============ } +// The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python +class ResetAppListener : public ::testing::EmptyTestEventListener { + void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; } +}; + void setup() { // Log functions call global_logger->log_vprintf_() without a null check, // so we must set up a Logger before any test that triggers logging. @@ -27,6 +33,7 @@ void setup() { test_logger.pre_setup(); ::testing::InitGoogleTest(); + ::testing::UnitTest::GetInstance()->listeners().Append(new ResetAppListener); int exit_code = RUN_ALL_TESTS(); // A test folder that never reached the build would otherwise pass as an empty run if (::testing::UnitTest::GetInstance()->total_test_count() == 0) { From b6096c9437854e41f954a7c94745432e58dc2f6d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:04:57 +1100 Subject: [PATCH 220/435] [ci] Reset sdkconfig after ESP-IDF component discovery (#20511) Co-authored-by: Claude Opus 5.5 --- esphome/espidf/toolchain.py | 9 ++++++- tests/unit_tests/test_espidf_toolchain.py | 29 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 871e74d36d3..698080eebc2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -353,7 +353,11 @@ def _clean_foreign_target_tree() -> None: clean_build(clear_pio_cache=False) if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): return - # Restart sdkconfig from ESPHome's options + _reset_sdkconfig() + + +def _reset_sdkconfig() -> None: + """Restart the project sdkconfig from ESPHome's own options.""" internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") if internal.is_file(): shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) @@ -656,6 +660,9 @@ def _configure_project(verbose: bool = False) -> int: if not discovered: _LOGGER.error("Component discovery found no built-in ESP-IDF components") return 1 + # The discovery configure builds every IDF component, and their Kconfig + # selects (e.g. esp-tls turning TLS on) would otherwise stay in sdkconfig + _reset_sdkconfig() if (rc := _write_project_and_reconfigure(discovered, verbose)) != 0: _LOGGER.error("Reconfigure with discovered components failed") return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index c73858ed529..3cba1a7357d 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -608,6 +608,35 @@ def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None: ] +def test_run_compile_discovery_resets_sdkconfig(setup_core: Path) -> None: + """Options the discovery configure forced on (e.g. esp-tls selecting TLS) + are dropped before the configure with the real component list.""" + _setup_build(setup_core) + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.parent.mkdir(parents=True) + internal.write_text("CONFIG_MBEDTLS_TLS_ENABLED=n\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + seen: list[str] = [] + + def record_write(minimal: bool = False, builtin_components=None) -> None: + if minimal: + sdkconfig.write_text("CONFIG_MBEDTLS_TLS_ENABLED=y\n") + else: + seen.append(sdkconfig.read_text()) + + with ( + patch.object(toolchain, "load_cached_builtin_components", return_value=None), + patch.object(toolchain, "save_cached_builtin_components"), + patch( + "esphome.build_gen.espidf.get_available_components", return_value=["lwip"] + ), + patch("esphome.build_gen.espidf.write_project", side_effect=record_write), + patch.object(toolchain, "run_reconfigure", return_value=0), + ): + assert toolchain._configure_project() == 0 + assert seen == ["CONFIG_MBEDTLS_TLS_ENABLED=n\n"] + + def test_run_compile_discovery_failure_stops_before_full_write( setup_core: Path, ) -> None: From 3e7fafb7051bc84860db9d6cadca22d591b3eea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 00:09:55 -1000 Subject: [PATCH 221/435] [daikin] Prefix the PendingComfort enumerators (#20512) --- esphome/components/daikin/daikin.cpp | 14 ++++++++------ esphome/components/daikin/daikin.h | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 0194b34db09..8b8d16677a1 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -266,9 +266,10 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { // Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid // A comfort frame whose own state frame never came must not attach to a later transmission const uint32_t now = App.get_loop_component_start_time(); - const PendingComfort comfort = - now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS ? this->pending_comfort_ : PendingComfort::UNKNOWN; - this->pending_comfort_ = PendingComfort::UNKNOWN; + const PendingComfort comfort = now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS + ? this->pending_comfort_ + : PendingComfort::PENDING_COMFORT_UNKNOWN; + this->pending_comfort_ = PendingComfort::PENDING_COMFORT_UNKNOWN; if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) { this->preset = climate::CLIMATE_PRESET_BOOST; } else if (frame[13] & DAIKIN_PRESET_QUIET_ON) { @@ -277,9 +278,9 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { this->preset = climate::CLIMATE_PRESET_ECO; } else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) { this->preset = climate::CLIMATE_PRESET_ACTIVITY; - } else if (comfort == PendingComfort::ON) { + } else if (comfort == PendingComfort::PENDING_COMFORT_ON) { this->preset = climate::CLIMATE_PRESET_COMFORT; - } else if (comfort == PendingComfort::OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { + } else if (comfort == PendingComfort::PENDING_COMFORT_OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { // Without a comfort frame in this transmission a current comfort preset is left alone this->preset = climate::CLIMATE_PRESET_NONE; } @@ -337,7 +338,8 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) { // Nothing changes yet: the state frame that follows applies it once it has passed its own checksum - this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::ON : PendingComfort::OFF; + this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::PENDING_COMFORT_ON + : PendingComfort::PENDING_COMFORT_OFF; this->pending_comfort_at_ = App.get_loop_component_start_time(); return true; } diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index 7f8638101d5..3124ba0145e 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -82,8 +82,8 @@ class DaikinClimate final : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); /// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame - enum class PendingComfort : uint8_t { UNKNOWN, OFF, ON }; - PendingComfort pending_comfort_{PendingComfort::UNKNOWN}; + enum class PendingComfort : uint8_t { PENDING_COMFORT_UNKNOWN, PENDING_COMFORT_OFF, PENDING_COMFORT_ON }; + PendingComfort pending_comfort_{PendingComfort::PENDING_COMFORT_UNKNOWN}; uint32_t pending_comfort_at_{0}; }; From 2681126986199d41a9b0f3a64bb8f67c37d89c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:31:14 +0200 Subject: [PATCH 222/435] [teleinfo] Fix out-of-bounds read when splitting a frame into groups (#20513) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 6b68dacd31b..adf56707844 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -116,8 +116,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_index_ - 1))) && - ((buf_finger - buf_) < buf_index_)) { // NOLINT(clang-diagnostic-sign-compare) + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -168,8 +167,8 @@ void TeleInfo::loop() { continue; } - /* Advance buf_finger to end of group */ - buf_finger += field_len + 1 + 1 + 1; + /* Resume after the group's 0xd; grp_end < buf_end, so this stays in bounds. */ + buf_finger = grp_end + 1; publish_value_(std::string(tag_), std::string(val_)); } From 1402cb7b64a1f1d3adfe4f62b04abdbfd321efb8 Mon Sep 17 00:00:00 2001 From: Georg Seema Date: Sun, 11 Oct 2026 13:32:39 +0300 Subject: [PATCH 223/435] [tuya] Add forced minute boundary time sync (#18603) Co-authored-by: J. Nick Koston 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/tuya/__init__.py | 25 ++++++++-- esphome/components/tuya/tuya.cpp | 46 +++++++++++++++---- esphome/components/tuya/tuya.h | 7 ++- tests/component_tests/tuya/__init__.py | 0 tests/component_tests/tuya/test_init.py | 23 ++++++++++ .../tuya/test-force-time-sync.esp32-idf.yaml | 16 +++++++ 6 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 tests/component_tests/tuya/__init__.py create mode 100644 tests/component_tests/tuya/test_init.py create mode 100644 tests/components/tuya/test-force-time-sync.esp32-idf.yaml diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index bf392c78268..c05db51cdf9 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -4,6 +4,7 @@ from esphome.components import time, uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["uart"] DOMAIN = "tuya" @@ -13,6 +14,7 @@ CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" CONF_ON_DATAPOINT_UPDATE = "on_datapoint_update" CONF_DATAPOINT_TYPE = "datapoint_type" CONF_STATUS_PIN = "status_pin" +CONF_FORCE_TIME_SYNC = "force_time_sync" tuya_ns = cg.esphome_ns.namespace("tuya") TuyaDatapointType = tuya_ns.enum("TuyaDatapointType", is_class=True) @@ -43,11 +45,23 @@ DATAPOINT_TYPES = { CONF_TUYA_ID = "tuya_id" -CONFIG_SCHEMA = ( + + +def _validate_time_options(config: ConfigType) -> ConfigType: + if config[CONF_FORCE_TIME_SYNC] and CONF_TIME_ID not in config: + raise cv.Invalid( + f"{CONF_FORCE_TIME_SYNC} requires {CONF_TIME_ID}", + path=[CONF_FORCE_TIME_SYNC], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Tuya), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional(CONF_FORCE_TIME_SYNC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS): cv.ensure_list( cv.uint8_t ), @@ -63,7 +77,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + _validate_time_options, ) @@ -71,9 +86,11 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) + if (time_id := config.get(CONF_TIME_ID)) is not None: + time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) + if config[CONF_FORCE_TIME_SYNC]: + cg.add(var.set_force_time_sync(True)) if CONF_STATUS_PIN in config: status_pin_ = await cg.gpio_pin_expression(config[CONF_STATUS_PIN]) cg.add(var.set_status_pin(status_pin_)) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b23dcc946bd..c6316f59fce 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -23,11 +23,13 @@ ESPHOME_LOG_TAG(TAG, "tuya"); static constexpr uint32_t HEARTBEAT_INTERVAL_ID = 0; static constexpr uint32_t WIFI_STATUS_INTERVAL_ID = 1; static constexpr uint32_t DATAPOINT_DUMP_TIMEOUT_ID = 2; +static constexpr uint32_t FORCE_TIME_SYNC_INTERVAL_ID = 3; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static constexpr uint32_t FORCE_TIME_SYNC_CHECK_INTERVAL = 1000; static bool network_is_connected() { #ifdef USE_NETWORK @@ -39,6 +41,13 @@ static bool network_is_connected() { void Tuya::setup() { this->set_interval(HEARTBEAT_INTERVAL_ID, 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); +#ifdef USE_TIME + if (this->force_time_sync_) { + this->register_local_time_sync_callback_(); + this->set_interval(FORCE_TIME_SYNC_INTERVAL_ID, FORCE_TIME_SYNC_CHECK_INTERVAL, + [this] { this->check_force_time_sync_(); }); + } +#endif if (this->status_pin_ != nullptr) { this->status_pin_->digital_write(false); } @@ -99,6 +108,11 @@ void Tuya::dump_config() { } LOG_PIN(" Status Pin: ", this->status_pin_); ESP_LOGCONFIG(TAG, " Product: '%s'", this->product_.c_str()); +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + ESP_LOGCONFIG(TAG, " Force time sync: %s", YESNO(this->force_time_sync_)); + } +#endif } bool Tuya::validate_message_() { @@ -306,12 +320,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff #ifdef USE_TIME if (this->time_id_ != nullptr) { this->send_local_time_(); - - if (!this->time_sync_callback_registered_) { - // tuya mcu supports time, so we let them know when our time changed - this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); - this->time_sync_callback_registered_ = true; - } + this->register_local_time_sync_callback_(); } else #endif { @@ -616,7 +625,23 @@ void Tuya::send_wifi_status_() { } #ifdef USE_TIME -void Tuya::send_local_time_() { +void Tuya::register_local_time_sync_callback_() { + if (this->time_sync_callback_registered_) { + return; + } + this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); + this->time_sync_callback_registered_ = true; +} + +void Tuya::check_force_time_sync_() { + ESPTime now = this->time_id_->now(); + if (!now.is_valid() || now.minute == this->last_force_time_sync_minute_) + return; + this->last_force_time_sync_minute_ = now.minute; + this->send_local_time_(true); +} + +void Tuya::send_local_time_(bool periodic) { std::vector payload; ESPTime now = this->time_id_->now(); if (now.is_valid()) { @@ -631,7 +656,12 @@ void Tuya::send_local_time_() { if (day_of_week == 0) { day_of_week = 7; } - ESP_LOGD(TAG, "Sending local time"); + // The forced send repeats every minute, so it stays out of the debug log + if (periodic) { + ESP_LOGV(TAG, "Sending local time"); + } else { + ESP_LOGD(TAG, "Sending local time"); + } payload = std::vector{0x01, year, month, day_of_month, hour, minute, second, day_of_week}; } else { // By spec we need to notify MCU that the time was not obtained if this is a response to a query diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index f074d301629..1e2e12200dd 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -113,6 +113,7 @@ class Tuya final : public Component, public uart::UARTDevice { TuyaInitState get_init_state(); #ifdef USE_TIME void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } + void set_force_time_sync(bool force_time_sync) { this->force_time_sync_ = force_time_sync; } #endif void add_ignore_mcu_update_on_datapoints(uint8_t ignore_mcu_update_on_datapoints) { this->ignore_mcu_update_on_datapoints_.push_back(ignore_mcu_update_on_datapoints); @@ -144,9 +145,13 @@ class Tuya final : public Component, public uart::UARTDevice { uint8_t get_wifi_rssi_(); #ifdef USE_TIME - void send_local_time_(); + void register_local_time_sync_callback_(); + void check_force_time_sync_(); + void send_local_time_(bool periodic = false); void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; + bool force_time_sync_{false}; + uint8_t last_force_time_sync_minute_{0xFF}; // 0xFF: no minute seen yet bool time_sync_callback_registered_{false}; bool gmt_time_sync_callback_registered_{false}; #endif diff --git a/tests/component_tests/tuya/__init__.py b/tests/component_tests/tuya/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/tuya/test_init.py b/tests/component_tests/tuya/test_init.py new file mode 100644 index 00000000000..6984b9aa1b4 --- /dev/null +++ b/tests/component_tests/tuya/test_init.py @@ -0,0 +1,23 @@ +"""Tests for Tuya time synchronization configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.tuya import CONF_FORCE_TIME_SYNC, CONFIG_SCHEMA +from esphome.const import CONF_TIME_ID + + +def test_force_time_sync_accepts_time_id() -> None: + config = CONFIG_SCHEMA( + { + CONF_FORCE_TIME_SYNC: True, + CONF_TIME_ID: "tuya_time", + } + ) + + assert config[CONF_FORCE_TIME_SYNC] is True + + +def test_force_time_sync_requires_time_id() -> None: + with pytest.raises(cv.Invalid, match="force_time_sync requires time_id"): + CONFIG_SCHEMA({CONF_FORCE_TIME_SYNC: True}) diff --git a/tests/components/tuya/test-force-time-sync.esp32-idf.yaml b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml new file mode 100644 index 00000000000..df0fe110b44 --- /dev/null +++ b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml @@ -0,0 +1,16 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + status_pin: GPIO12 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + common: !include common.yaml + +time: + - platform: sntp + id: tuya_time + +tuya: + time_id: tuya_time + force_time_sync: true From 3df03e4be962687ddc6feeb1c8184534a0c526eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:31:07 +0200 Subject: [PATCH 224/435] [emontx] Read UART bytes with read_byte() (#20514) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index ae92b737316..17497984c39 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -18,7 +18,9 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } void EmonTx::loop() { // Read all available data to prevent UART buffer overflow while (this->available() > 0) { - uint8_t received = this->read(); + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == '\r') { continue; // Ignore CR From 6001cde2342aecea184d16824cf9b0472a46484a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:41:02 +0200 Subject: [PATCH 225/435] [emontx] Discard the rest of a line that overflows the buffer (#20515) Co-authored-by: Claude Opus 5.5 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/emontx/emontx.cpp | 13 +++-- esphome/components/emontx/emontx.h | 1 + tests/components/emontx/common.h | 45 +++++++++++++++ tests/components/emontx/emontx_test.cpp | 75 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 tests/components/emontx/common.h create mode 100644 tests/components/emontx/emontx_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 17497984c39..675744b5f58 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -22,11 +22,11 @@ void EmonTx::loop() { if (!this->read_byte(&received)) break; - if (received == '\r') { - continue; // Ignore CR - } else if (received == '\n') { + if (received == '\n') { // End of line - process the buffer - if (this->buffer_pos_ > 0) { + if (this->discarding_line_) { + this->discarding_line_ = false; + } else if (this->buffer_pos_ > 0) { // Null-terminate for safe logging and c_str() use size_t len = this->buffer_pos_; this->buffer_[len] = '\0'; @@ -44,9 +44,12 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } } + } else if (received == '\r' || this->discarding_line_) { + // Ignore CR, and the rest of a line that overflowed the buffer } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH); + ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; + this->discarding_line_ = true; } else { this->buffer_[this->buffer_pos_++] = static_cast(received); } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index c56005e7ad8..f97c5aa6cb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -55,6 +55,7 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; + bool discarding_line_{false}; // Set after an overflow until the end of that line std::array buffer_{}; }; diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h new file mode 100644 index 00000000000..4af5a98572d --- /dev/null +++ b/tests/components/emontx/common.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/emontx/emontx.h" + +namespace esphome::emontx::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(const char *data) { this->rx.insert(this->rx.end(), data, data + strlen(data)); } + void push_rx(size_t count, uint8_t byte) { this->rx.insert(this->rx.end(), count, byte); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +} // namespace esphome::emontx::testing diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp new file mode 100644 index 00000000000..373b54d7857 --- /dev/null +++ b/tests/components/emontx/emontx_test.cpp @@ -0,0 +1,75 @@ +#include + +#include +#include + +#include "esphome/components/emontx/emontx.h" +#include "common.h" + +namespace esphome::emontx::testing { + +namespace { +class EmonTxTest : public ::testing::Test { + protected: + void SetUp() override { + 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_++; }); + } + + MockUARTComponent uart_; + EmonTx sut_; + std::vector lines_; + int json_count_{0}; +}; +} // namespace + +TEST_F(EmonTxTest, LinePassedToDataCallbacks) { + this->uart_.push_rx("MSG:1\r\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:1"); + EXPECT_EQ(this->json_count_, 0); +} + +TEST_F(EmonTxTest, JsonLineParsed) { + this->uart_.push_rx("{\"P1\":100}\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->json_count_, 1); +} + +TEST_F(EmonTxTest, OverflowedLineDiscardedUntilLineFeed) { + // One byte more than the buffer holds, then more of the same line. + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->uart_.push_rx("tail\n"); + this->uart_.push_rx("MSG:2\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:2"); +} + +TEST_F(EmonTxTest, OverflowedLineTailNotParsedAsJson) { + // The tail after the overflow looks like a JSON line and must not be parsed. + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->uart_.push_rx("{\"P1\":100}\n"); + this->sut_.loop(); + + EXPECT_TRUE(this->lines_.empty()); + EXPECT_EQ(this->json_count_, 0); +} + +TEST_F(EmonTxTest, OverflowAcrossLoopCalls) { + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->sut_.loop(); + this->uart_.push_rx("tail\nMSG:3\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:3"); +} + +} // namespace esphome::emontx::testing From 493db0c2bb750a2fced3fadad0d0aabb0f0c87e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:45:19 -1000 Subject: [PATCH 226/435] [esp32] Add allow_insecure_tls() for components that skip certificate verification (#20506) --- esphome/components/esp32/__init__.py | 11 +++++++++++ esphome/components/http_request/__init__.py | 10 ++-------- esphome/components/nextion/display.py | 5 +---- tests/component_tests/esp32/test_esp32.py | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9bceaf5676d..422413a269f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2071,6 +2071,17 @@ def require_vfs_termios() -> None: CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True +def allow_insecure_tls() -> None: + """Let esp-tls connect without verifying the server certificate. + + The two sdkconfig flags are global, so a component asking for an unverified + connection switches them on for the whole build; they are never switched off + here because another component may have asked first. + """ + add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) + add_idf_sdkconfig_option("CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True) + + def require_certificate_bundle() -> None: """Enable the mbedTLS root certificate bundle for this build. diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index ad51d5f39be..a2c181a5ae0 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -226,14 +226,8 @@ async def to_code(config: ConfigType) -> None: # use_full_certificate_bundle: true esp32.require_certificate_bundle() - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_INSECURE", - not config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", - not config.get(CONF_VERIFY_SSL), - ) + else: + esp32.allow_insecure_tls() if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CORE.is_rp2 and CORE.using_arduino: diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3a70be21caf..983a4777e81 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -261,10 +261,7 @@ async def to_code(config): if CORE.is_esp32: esp32.request_http_client() - esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True - ) + esp32.allow_insecure_tls() elif CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 9072a906293..bb751dd677c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -30,6 +30,7 @@ from esphome.components.esp32 import ( _reconcile_network_sdkconfig, _reconcile_vfs_fatfs_sdkconfig, _user_sdkconfig_wants_tls, + allow_insecure_tls, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -2400,3 +2401,20 @@ def test_nvs_cache_in_psram_explicit_true_on_valid_board_is_quiet( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is True assert "nvs_cache_in_psram" not in caplog.text + + +def test_allow_insecure_tls_only_switches_the_flags_on( + set_core_config: SetCoreConfigCallable, +) -> None: + """A component that verifies certificates must not undo another one's request.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {}} + + allow_insecure_tls() + # A second caller, or a verifying component that no longer touches the flags, leaves them on + allow_insecure_tls() + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == { + "CONFIG_ESP_TLS_INSECURE": True, + "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY": True, + } From 7f7ae4c8fc7bd7a6d9640ea27b9e33b4332e7d37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:47:12 -1000 Subject: [PATCH 227/435] [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) --- tests/integration/test_uart_tcp_bridge.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_uart_tcp_bridge.py b/tests/integration/test_uart_tcp_bridge.py index 33d32f4c07e..d2e1603bd15 100644 --- a/tests/integration/test_uart_tcp_bridge.py +++ b/tests/integration/test_uart_tcp_bridge.py @@ -84,13 +84,16 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 1) + # The accept log precedes the stale discard in the same loop pass, so + # prove the device is past it with a client to UART round trip before + # writing the bytes the client must see. + writer.write(b"down1") + await writer.drain() + assert await read_uart(5) == b"down1" os.write(controller_fd, b"live!") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live!", ( "First bytes to the client were not the live payload" ) - writer.write(b"down1") - await writer.drain() - assert await read_uart(5) == b"down1" # Drop the client; bytes while no client is connected are discarded # when the next one is accepted. @@ -101,13 +104,13 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 2) + writer.write(b"down2") + await writer.drain() + assert await read_uart(5) == b"down2" os.write(controller_fd, b"live2") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live2", ( "Second client received stale bytes from the gap" ) - writer.write(b"down2") - await writer.drain() - assert await read_uart(5) == b"down2" writer.close() # A peer outside the allow list is rejected and closed. From c5cbc4a4e080beb27a6eda36758df97f550d305d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:50:42 +0200 Subject: [PATCH 228/435] [emontx] Test lines of exactly MAX_LINE_LENGTH bytes (#20517) Co-authored-by: Claude Opus 5.5 --- tests/components/emontx/emontx_test.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 373b54d7857..20d3adf0dc0 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -41,6 +41,25 @@ TEST_F(EmonTxTest, JsonLineParsed) { EXPECT_EQ(this->json_count_, 1); } +// The CR check must come before the overflow check, or a full line ending in CRLF would overflow. +TEST_F(EmonTxTest, FullLengthLineWithCrLfDelivered) { + this->uart_.push_rx(MAX_LINE_LENGTH, 'a'); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], std::string(MAX_LINE_LENGTH, 'a')); +} + +TEST_F(EmonTxTest, FullLengthLineWithLfDelivered) { + this->uart_.push_rx(MAX_LINE_LENGTH, 'a'); + this->uart_.push_rx("\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], std::string(MAX_LINE_LENGTH, 'a')); +} + TEST_F(EmonTxTest, OverflowedLineDiscardedUntilLineFeed) { // One byte more than the buffer holds, then more of the same line. this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); From 1312d5eec018cead1926f6e6e5c78cc3a016e365 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 11 Oct 2026 18:51:41 +0200 Subject: [PATCH 229/435] [modbus] Pass the request's address to client device callbacks (#20504) --- esphome/components/modbus/modbus.cpp | 10 +- esphome/components/modbus/modbus.h | 15 +++ .../modbus/modbus_client_hub_test.cpp | 109 ++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 80065546cf7..a79a7c3bd8c 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -950,7 +950,7 @@ bool ModbusDeviceCommand::sent() { // on_sent() is not a terminal, so nothing is consumed. if (this->device == nullptr) return false; - this->device->on_sent(this->frame.pdu()); + this->device->on_sent(this->frame.address(), this->frame.pdu()); return true; } @@ -958,7 +958,7 @@ bool ModbusDeviceCommand::notify_retired() { if (!this->decrement_pending()) return false; // nothing owed - stop the sweep draining this entry if (this->device != nullptr) - this->device->on_not_sent(this->frame.pdu()); + this->device->on_not_sent(this->frame.address(), this->frame.pdu()); return true; // consumed one debt (delivered, or silent when device-less) - keep draining to zero } @@ -969,7 +969,7 @@ bool ModbusDeviceCommand::response(std::span response_pdu) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_response(this->frame.pdu(), response_pdu); + this->device->on_response(this->frame.address(), this->frame.pdu(), response_pdu); return true; } @@ -979,7 +979,7 @@ bool ModbusDeviceCommand::error(ExceptionCode exception_code) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_error(this->frame.pdu(), exception_code); + this->device->on_error(this->frame.address(), this->frame.pdu(), exception_code); return true; } @@ -1004,7 +1004,7 @@ bool ModbusDeviceCommand::timed_out() { return false; // resolved, no one to tell // A cleared frame that timed out still honors a retry: the clear is address-scoped (any device may // call it) while the retry is the owning device's call via on_no_response - the bus obeys the owner. - if (this->device->on_no_response(this->frame.pdu())) + if (this->device->on_no_response(this->frame.address(), this->frame.pdu())) this->increment_pending(); // granted retry = re-request (capped) return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 298cd9f5278..011813a20d3 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -437,6 +437,21 @@ class ModbusClientDevice { return this->on_modbus_no_response(); #pragma GCC diagnostic pop } + /// Overloads of the hooks above with the device address of the request first, taken from the request frame, so it + /// stays correct after set_address(). The hub calls these; the defaults forward to the hooks above. + /// Override one form per hook: overriding an address form drops its forward, so the other form is no longer called. + virtual void on_response(uint8_t address, std::span request_pdu, + std::span response_pdu) { + this->on_response(request_pdu, response_pdu); + } + virtual void on_error(uint8_t address, std::span request_pdu, ExceptionCode exception_code) { + this->on_error(request_pdu, exception_code); + } + virtual void on_not_sent(uint8_t address, std::span request_pdu) { this->on_not_sent(request_pdu); } + virtual void on_sent(uint8_t address, std::span request_pdu) { this->on_sent(request_pdu); } + virtual bool on_no_response(uint8_t address, std::span request_pdu) { + return this->on_no_response(request_pdu); + } // Remove before 2027.2.0 ESPDEPRECATED("Override on_not_sent() instead. Removed in 2027.2.0", "2026.8.0") virtual void on_modbus_not_sent() {} diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 3bdfa094e0e..1e351b4971a 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -2436,4 +2436,113 @@ TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { EXPECT_EQ(next->priority(), CommandPriority::WRITE); // 0x16 wins selection over the queued read EXPECT_EQ(next->frame.pdu()[0], 0x16); } +namespace { +// Overrides only the address overloads and records the address each one is given. +class AddressRecordingDevice : public ModbusClientDevice { + public: + AddressRecordingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(uint8_t address, std::span request_pdu) override { this->sent_ = address; } + void on_response(uint8_t address, std::span request_pdu, + std::span response_pdu) override { + this->response_ = address; + } + void on_error(uint8_t address, std::span request_pdu, ExceptionCode exception_code) override { + this->error_ = address; + } + bool on_no_response(uint8_t address, std::span request_pdu) override { + this->no_response_ = address; + return this->retry_; + } + void on_not_sent(uint8_t address, std::span request_pdu) override { this->not_sent_ = address; } + int sent_{-1}; + int response_{-1}; + int error_{-1}; + int no_response_{-1}; + int not_sent_{-1}; + bool retry_{false}; +}; +} // namespace + +// Each callback gets the address its request was sent to, even when the device has changed its own +// address before the outcome arrives. +TEST(ModbusClientHubAddress, CallbacksReportTheRequestAddressAfterSetAddress) { + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + const uint8_t exception_response[] = {0x83, 0x02}; + { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, 0x02); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, 0x02); + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(device.response_, 0x02); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x03); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.force_send_next(); + hub.receive_frame_for_test(0x03, exception_response); + EXPECT_EQ(device.error_, 0x03); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x04); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_, 0x04); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x06); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.clear_tx_queue_for_address(0x06); + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_, 0x06); + } +} + +// A granted retry resends the same frame, so the retry's callbacks keep the original address too. +TEST(ModbusClientHubAddress, GrantedRetryKeepsTheRequestAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, 0x04); + device.retry_ = true; + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.send_next_for_test(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_, 0x04); + + device.sent_ = -1; + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, 0x04); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x04, ok_response); + EXPECT_EQ(device.response_, 0x04); +} + +// A broadcast reports address 0 from on_sent, its only callback. +TEST(ModbusClientHubAddress, BroadcastReportsAddressZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, BROADCAST_ADDRESS); + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.queue_pdu(write)); + device.set_address(0x05); + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, BROADCAST_ADDRESS); +} } // namespace esphome::modbus::testing From 5186728d53d0e930adc5db8f59ab7acedc18bdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:52:41 +0200 Subject: [PATCH 230/435] [emontx] Pass on_json/on_data data without copies and reject deferring actions (#20516) --- esphome/components/emontx/__init__.py | 24 +++++++-- .../emontx/test_synchronous_automations.py | 54 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/emontx/test_synchronous_automations.py diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 0262f8681f1..971f9769246 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -44,13 +44,29 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] +def _synchronous_automation(value: ConfigType) -> ConfigType: + """Reject deferring actions: the received line and parsed JSON are only valid while the + automation runs, and DelayAction and friends keep the trigger arguments for later.""" + if automation.has_non_synchronous_actions(value): + raise cv.Invalid( + "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and " + "on_data: the received data is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + +def _synchronous_automation_schema() -> cv.All: + return cv.All(automation.validate_automation({}), _synchronous_automation) + + # Main configuration schema CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(EmonTx), - cv.Optional(CONF_ON_JSON): automation.validate_automation({}), - cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), + cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), } ) .extend(cv.COMPONENT_SCHEMA) @@ -104,10 +120,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_JSON, "add_on_json_callback", - [(cg.JsonObject, "json"), (cg.std_string, "raw_json")], + [(cg.JsonObject, "json"), (cg.StringRef, "raw_json")], ), automation.CallbackAutomation( - CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")] + CONF_ON_DATA, "add_on_data_callback", [(cg.StringRef, "data")] ), ) diff --git a/tests/component_tests/emontx/test_synchronous_automations.py b/tests/component_tests/emontx/test_synchronous_automations.py new file mode 100644 index 00000000000..c190c14ab19 --- /dev/null +++ b/tests/component_tests/emontx/test_synchronous_automations.py @@ -0,0 +1,54 @@ +"""Tests that emontx on_json/on_data automations reject deferring actions. + +The triggers pass views into the receive buffer and the parsed JSON document, which are only valid +while the automation runs, so the deferring-actions guard is a safety property. +""" + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import CONF_ON_JSON, CONFIG_SCHEMA +from esphome.const import CONF_ON_DATA +from esphome.core import Lambda +from esphome.types import ConfigType + +TRIGGER_KEYS = [CONF_ON_JSON, CONF_ON_DATA] + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _trigger_schema(trigger_key: str) -> Callable[[list[ConfigType]], list[ConfigType]]: + """The validator CONFIG_SCHEMA applies to this trigger key.""" + return CONFIG_SCHEMA.schema[cv.Optional(trigger_key)] + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_synchronous_automation_accepted(trigger_key: str) -> None: + _trigger_schema(trigger_key)([{"then": [SYNCHRONOUS_ACTION]}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_deferring_action_rejected(trigger_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)([{"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_nested_deferring_action_rejected(trigger_key: str) -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)([{"then": actions}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_deferring_action_in_second_automation_rejected(trigger_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)( + [{"then": [SYNCHRONOUS_ACTION]}, {"then": [DEFERRING_ACTION]}] + ) From e9073f7298f70cf85efb095a7898a4672511b3e0 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:38:37 +0000 Subject: [PATCH 231/435] [systa_bus] Add text_sensor (#20518) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/systa_bus/__init__.py | 2 + .../components/systa_bus/sensor/__init__.py | 9 +++- esphome/components/systa_bus/systa_bus.h | 3 +- .../systa_bus/text_sensor/__init__.py | 45 +++++++++++++++++++ .../text_sensor/systa_bus_text_sensor.cpp | 27 +++++++++++ .../text_sensor/systa_bus_text_sensor.h | 20 +++++++++ esphome/core/defines.h | 2 +- tests/components/systa_bus/common.yaml | 6 +++ 8 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 esphome/components/systa_bus/text_sensor/__init__.py create mode 100644 esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp create mode 100644 esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index 49ac214d400..ae44017112b 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -17,6 +17,8 @@ SystaBus = systa_bus_ns.class_("SystaBus", cg.Component, uart.UARTDevice) CONF_SYSTA_BUS_ID = "systa_bus_id" +CONF_SYSTASOLAR_AQUA = "systasolar_aqua" + CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(SystaBus), diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py index 3def5fba12f..e4aa0684c78 100644 --- a/esphome/components/systa_bus/sensor/__init__.py +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -13,11 +13,16 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_SYSTA_BUS_ID, SystaBus, register_systa_bus_listener, systa_bus_ns +from .. import ( + CONF_SYSTA_BUS_ID, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) SystaSolarAquaSensor = systa_bus_ns.class_("SystaSolarAquaSensor", cg.Component) -CONF_SYSTASOLAR_AQUA = "systasolar_aqua" CONF_TEMPERATURE_TSA = "temperature_tsa" CONF_TEMPERATURE_TSE = "temperature_tse" CONF_TEMPERATURE_TWU = "temperature_twu" diff --git a/esphome/components/systa_bus/systa_bus.h b/esphome/components/systa_bus/systa_bus.h index 99ca31590f5..b46f769db00 100644 --- a/esphome/components/systa_bus/systa_bus.h +++ b/esphome/components/systa_bus/systa_bus.h @@ -14,7 +14,8 @@ static constexpr uint8_t START_BYTE = 0xfc; static constexpr uint8_t FRAME_OVERHEAD = 3; // start byte, length, checksum // SystaSolar Aqua sensor data, 25 bytes: [0..1] type, [2..3] not decoded, big-endian int16 in 0.1 degrees at -// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..23] not decoded. +// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..14] not decoded, +// [15] error code (zero for no error), [16..23] not decoded. static constexpr uint16_t MESSAGE_TYPE_AQUA_SENSOR_DATA = 0xfc16; // The bus only buffers known message types, so the buffer is sized by the longest of them diff --git a/esphome/components/systa_bus/text_sensor/__init__.py b/esphome/components/systa_bus/text_sensor/__init__.py new file mode 100644 index 00000000000..4b5cb52b087 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODEL +from esphome.types import ConfigType + +from .. import ( + CONF_SYSTA_BUS_ID, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) + +SystaSolarAquaTextSensor = systa_bus_ns.class_("SystaSolarAquaTextSensor", cg.Component) + +CONF_ERROR_CODE = "error_code" + + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_SYSTASOLAR_AQUA: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaSolarAquaTextSensor), + cv.GenerateID(CONF_SYSTA_BUS_ID): cv.use_id(SystaBus), + cv.Optional(CONF_ERROR_CODE): text_sensor.text_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + lower=True, + space="_", +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if config[CONF_MODEL] == CONF_SYSTASOLAR_AQUA and CONF_ERROR_CODE in config: + sens = await text_sensor.new_text_sensor(config[CONF_ERROR_CODE]) + cg.add(var.set_error_code_text_sensor(sens)) + + 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/text_sensor/systa_bus_text_sensor.cpp b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp new file mode 100644 index 00000000000..cba3caa8a6d --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp @@ -0,0 +1,27 @@ +#include "systa_bus_text_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::systa_bus { + +ESPHOME_LOG_TAG(TAG, "systa_bus.text_sensor"); + +void SystaSolarAquaTextSensor::dump_config() { + ESP_LOGCONFIG(TAG, "SystaSolar Aqua:"); + LOG_TEXT_SENSOR(" ", "Error code", this->error_code_text_sensor_); +} + +void SystaSolarAquaTextSensor::handle_message(std::span message) { + if (get_message_type(message) == MESSAGE_TYPE_AQUA_SENSOR_DATA) { + if (this->error_code_text_sensor_ != nullptr) { + if (message[15] == 0) { + this->error_code_text_sensor_->publish_state("--"); + } else { + char buf[4]; + int len = snprintf(buf, sizeof(buf), "%02d", message[15]); + this->error_code_text_sensor_->publish_state(buf, static_cast(len)); + } + } + } +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h new file mode 100644 index 00000000000..f0c12b5dce5 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/text_sensor/text_sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaTextSensor : public SystaBusListener, public Component { + public: + void dump_config() override; + void set_error_code_text_sensor(text_sensor::TextSensor *sensor) { this->error_code_text_sensor_ = sensor; } + void handle_message(std::span message) override; + + protected: + text_sensor::TextSensor *error_code_text_sensor_{nullptr}; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0e5e2f4f292..de621f891a1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -188,7 +188,7 @@ #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC #define USE_REMOTE_PROTOCOL_TOTO -#define SYSTA_BUS_LISTENER_COUNT 1 +#define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/tests/components/systa_bus/common.yaml b/tests/components/systa_bus/common.yaml index ea97e2d840b..b4ae2410fde 100644 --- a/tests/components/systa_bus/common.yaml +++ b/tests/components/systa_bus/common.yaml @@ -13,3 +13,9 @@ sensor: name: TW2 pump_speed: name: Pump Speed + +text_sensor: + - platform: systa_bus + model: systasolar_aqua + error_code: + name: Error Code From 8e8261c39e7a6ea998f111fd768263f7a6125a46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 08:14:56 -1000 Subject: [PATCH 232/435] [core] Add this week's review lessons to AGENTS.md (#20522) --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8194cd50407..6bdd9467c01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix - Enumerator names: prefix every value of an `enum class` with the enum name converted to `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with @@ -177,6 +178,8 @@ file does, and it is the authority when they disagree. The most useful starting Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm + rebuilds the callback and re-queues a scheduler item, and the period drifts. * **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a `static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other components, so number them from 0 and keep all of a component's ids together in one place. @@ -191,6 +194,18 @@ file does, and it is the authority when they disagree. The most useful starting // Bad - this is exactly what the base class already does float get_setup_priority() const override { return setup_priority::DATA; } ``` + * **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope + constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the + table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`); + for a table of structs copy the whole entry out and return the copy, never a pointer into flash. + On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with + `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. + * **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 + runtime check of something the build already knows. * **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they can be stored in flash rather than RAM. ```cpp @@ -534,6 +549,7 @@ file does, and it is the authority when they disagree. The most useful starting `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. + An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** * **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`. @@ -569,6 +585,13 @@ file does, and it is the authority when they disagree. The most useful starting * **Testing:** * **Python:** Run unit tests with `pytest`. * **C++:** Use `clang-tidy` for static analysis. + * **C++ unit tests:** gtests in `tests/components//*.cpp`, run with + `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`). + * **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: ``` tests/ @@ -615,6 +638,7 @@ file does, and it is the authority when they disagree. The most useful starting name: My Sensor ``` Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time. + `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead. * **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use: ```bash From 86af0af0e4161a157aa73b3528fe3a543e2cc20c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 08:48:47 -1000 Subject: [PATCH 233/435] [core] Add sum_bytes() and use it for the additive byte checksums components hand-roll (#20507) --- esphome/components/ballu/ballu.cpp | 5 +---- esphome/components/bl0939/bl0939.cpp | 6 +----- esphome/components/bl0940/bl0940.cpp | 8 ++------ esphome/components/bl0942/bl0942.cpp | 9 +++------ esphome/components/cse7761/cse7761.cpp | 16 ++++------------ esphome/components/cse7766/cse7766.cpp | 5 +---- esphome/components/daikin/daikin.cpp | 9 +-------- esphome/components/daikin_arc/daikin_arc.cpp | 5 +---- esphome/components/daikin_brc/daikin_brc.cpp | 6 ++---- esphome/components/daly_bms/daly_bms.cpp | 7 +------ esphome/components/delonghi/delonghi.cpp | 6 ++---- esphome/components/fs3000/fs3000.cpp | 7 +------ .../fujitsu_general/fujitsu_general.cpp | 6 +----- esphome/components/hm3301/hm3301.cpp | 9 +-------- .../improv_serial/improv_serial_component.cpp | 11 ++--------- esphome/components/mhz19/mhz19.cpp | 8 +------- esphome/components/mitsubishi/mitsubishi.cpp | 4 +--- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 3 +-- esphome/components/mk2pvrouter/mk2pvrouter.cpp | 9 +-------- esphome/components/pm1006/pm1006.cpp | 6 +----- esphome/components/pn532_i2c/pn532_i2c.cpp | 6 +----- esphome/components/pn532_spi/pn532_spi.cpp | 8 ++------ esphome/components/pzem004t/pzem004t.cpp | 4 +--- esphome/components/sds011/sds011.cpp | 6 +----- .../components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 12 +++--------- esphome/components/sm300d2/sm300d2.cpp | 8 +------- esphome/components/sonoff_d1/sonoff_d1.cpp | 8 +------- esphome/components/systa_bus/systa_bus.cpp | 7 +------ esphome/components/tcl112/tcl112.cpp | 4 +--- esphome/components/teleinfo/teleinfo.cpp | 10 +++++----- esphome/components/toshiba/toshiba.cpp | 7 +------ esphome/components/tuya/tuya.cpp | 10 +++------- esphome/components/ufm01/ufm01.cpp | 9 ++------- esphome/components/wts01/wts01.cpp | 5 +---- esphome/core/helpers.h | 8 ++++++++ tests/components/core/helpers_test.cpp | 8 ++++++++ 36 files changed, 69 insertions(+), 196 deletions(-) diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index 1553f336186..e18325303c8 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; - // Calculate checksum and compare with signal value. - for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) - checksum += remote_state[i]; + const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1); if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index b0b9bb1c176..0e2da59ab1a 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -51,12 +51,8 @@ void BL0939::loop() { } bool BL0939::validate_checksum(const DataPacket *data) { - uint8_t checksum = BL0939_READ_COMMAND; // Whole package but checksum - for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) { - checksum += data->raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index a8b470de7ec..ac1386df03a 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -44,13 +44,9 @@ void BL0940::loop() { } bool BL0940::validate_checksum_(DataPacket *data) { - uint8_t checksum = this->read_command_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 7d0eff8c9da..7da8dcca546 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -70,13 +70,10 @@ void BL0942::loop() { } bool BL0942::validate_checksum_(DataPacket *data) { - uint8_t checksum = BL0942_READ_COMMAND | this->address_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^ + 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 695b7f33d6c..c4073d27821 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -81,11 +81,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[3] = data & 0xFF; len = 4; } - uint8_t crc = 0; - for (uint32_t i = 0; i < len; i++) { - crc += buffer[i]; - } - buffer[len] = ~crc; + buffer[len] = ~sum_bytes(buffer, len); len++; } @@ -115,16 +111,12 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { } rcvd--; + if (static_cast(~sum_bytes(buffer, rcvd, 0xA5 + reg)) != buffer[rcvd]) { + return false; + } uint32_t result = 0; - // CRC check - uint8_t crc = 0xA5 + reg; for (uint32_t i = 0; i < rcvd; i++) { result = (result << 8) | buffer[i]; - crc += buffer[i]; - } - crc = ~crc; - if (crc != buffer[rcvd]) { - return false; } *value = result; diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 92769011563..0bf077b7468 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -67,10 +67,7 @@ bool CSE7766Component::check_byte_() { } if (index == CSE7766_RAW_DATA_SIZE - 1) { - uint8_t checksum = 0; - for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) { - checksum += this->raw_data_[i]; - } + const uint8_t checksum = sum_bytes(this->raw_data_ + 2, CSE7766_RAW_DATA_SIZE - 3); if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) { ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 8b8d16677a1..370c04fbf66 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,6 +1,7 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::daikin { @@ -8,14 +9,6 @@ ESPHOME_LOG_TAG(TAG, "daikin.climate"); static constexpr uint32_t BOOST_TIMEOUT_ID = 0; -static uint8_t sum_bytes(const uint8_t *data, size_t len) { - uint8_t sum = 0; - for (size_t i = 0; i < len; i++) { - sum += data[i]; - } - return sum; -} - void DaikinClimate::setup() { climate_ir::ClimateIR::setup(); // A boost preset restored from flash gets its timer too diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index e79135b1859..fa01df73d85 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -246,10 +246,7 @@ climate::ClimateTraits DaikinArcClimate::traits() { } bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGI(TAG, "checksum error"); return false; diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 5d8ef52dc01..cfb3521a66d 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -1,5 +1,6 @@ #include "daikin_brc.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::daikin_brc { @@ -160,10 +161,7 @@ uint8_t DaikinBrcClimate::temperature_() { } bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_BRC_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_BRC_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_BRC_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGCONFIG(TAG, "Bad CheckSum %x", checksum); return false; diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index a486037266e..04ef798b69e 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -129,12 +129,7 @@ void DalyBmsComponent::decode_data_(std::vector data) { while ((it = std::find(it, data.end(), 0xA5)) != data.end()) { if (data.end() - it >= DALY_FRAME_SIZE && it[1] == 0x01) { - uint8_t checksum; - int sum = 0; - for (int i = 0; i < 12; i++) { - sum += it[i]; - } - checksum = sum; + const uint8_t checksum = sum_bytes(&*it, 12); if (checksum == it[12]) { switch (it[2]) { diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index eec4d7f55b1..3ed794daea0 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -1,5 +1,6 @@ #include "delonghi.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::delonghi { @@ -102,10 +103,7 @@ uint8_t DelonghiClimate::temperature_() { } bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DELONGHI_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DELONGHI_STATE_FRAME_SIZE - 1); if (frame[DELONGHI_STATE_FRAME_SIZE - 1] != checksum) { return false; } diff --git a/esphome/components/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index 81d6647642e..b5e1c98bcb2 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -42,12 +42,7 @@ void FS3000Component::update() { } // checksum passes if the modulo 256 sum of the five bytes is 0 - uint8_t checksum = 0; - for (uint8_t i : data) { - checksum += i; - } - - if (checksum != 0) { + if (sum_bytes(data, sizeof(data)) != 0) { this->status_set_warning(); ESP_LOGW(TAG, "Checksum failure when reading from FS3000"); return; diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 9a928dac01b..af411d133a8 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -224,11 +224,7 @@ void FujitsuGeneralClimate::transmit_(uint8_t const *message, uint8_t length) { } uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { - uint8_t checksum = 0; - for (uint8_t i = 7; i < FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 1; ++i) { - checksum += message[i]; - } - return 256 - checksum; + return -sum_bytes(message + 7, FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 8); } uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index bfed99b21b5..ddfe77d3ac1 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -80,14 +80,7 @@ void HM3301Component::update() { this->status_clear_warning(); } -bool HM3301Component::validate_checksum_(const uint8_t *data) { - uint8_t sum = 0; - for (int i = 0; i < 28; i++) { - sum += data[i]; - } - - return sum == data[28]; -} +bool HM3301Component::validate_checksum_(const uint8_t *data) { return sum_bytes(data, 28) == data[28]; } uint16_t HM3301Component::get_sensor_value_(const uint8_t *data, uint8_t i) { return (uint16_t) data[i * 2] << 8 | data[i * 2 + 1]; diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 10454678669..906781e17bf 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -95,16 +95,9 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2; // Only transmit the full buffer length if there is no data (only state/error byte is provided in this case) const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE; - // Calculate checksum for message - uint8_t checksum = 0; - for (uint8_t i = 0; i < header_checksum_len; i++) { - checksum += this->tx_header_[i]; - } + uint8_t checksum = sum_bytes(this->tx_header_, header_checksum_len); if (there_is_data) { - // Include data in checksum - for (size_t i = 0; i < size; i++) { - checksum += data[i]; - } + checksum = sum_bytes(data, size, checksum); } this->tx_header_[TX_CHECKSUM_IDX] = checksum; diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 5be6da88eff..0f623bfd726 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -31,13 +31,7 @@ static const LogString *detection_range_to_log_string(MHZ19DetectionRange range) } #endif -uint8_t mhz19_checksum(const uint8_t *command) { - uint8_t sum = 0; - for (uint8_t i = 1; i < MHZ19_REQUEST_LENGTH; i++) { - sum += command[i]; - } - return 0xFF - sum + 0x01; -} +uint8_t mhz19_checksum(const uint8_t *command) { return -sum_bytes(command + 1, MHZ19_REQUEST_LENGTH - 1); } void MHZ19Component::setup() { if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) { diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 515cd81fdfe..9fba222d635 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -242,9 +242,7 @@ void MitsubishiClimate::transmit_state() { } // Checksum - for (int i = 0; i < 17; i++) { - remote_state[17] += remote_state[i]; - } + remote_state[17] = sum_bytes(remote_state, 17); ESP_LOGD(TAG, "sending: %02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X", remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index c6c7b57c5fa..3261d9d5694 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -31,7 +30,7 @@ static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { - return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); + return static_cast(0xFC - sum_bytes(bytes, length)); } template diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index eecaa1a1d41..b759758019a 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -36,14 +36,7 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, // Calculates the CRC (checksum) for a given group of characters. uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - uint8_t crc_tmp{0}; - const auto effective_len = grp_len - CRC_SUFFIX_LEN; - for (size_t i = 0; i < effective_len; i++) { - crc_tmp += grp[i]; - } - crc_tmp &= CRC_MASK; - crc_tmp += CRC_OFFSET; - return crc_tmp; + return (sum_bytes(reinterpret_cast(grp), grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index ebd508b5352..2f57c028439 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -43,11 +43,7 @@ void PM1006Component::loop() { } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional PM1006Component::check_byte_() const { diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 1687edd5116..5326aeb145c 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -71,11 +71,7 @@ bool PN532I2C::read_response(uint8_t command, pn532::PN532Frame &data) { // frame: status, preamble, start code (2), LEN, LCS, TFI, command response code, data, DCS, postamble constexpr size_t tfi_offset = 6; - uint8_t checksum = 0; - for (size_t i = 0; i < len + 1U; i++) { - checksum += data[tfi_offset + i]; - } - checksum = ~checksum + 1; + const uint8_t checksum = -sum_bytes(data.data() + tfi_offset, len + 1U); if (data[tfi_offset + len + 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[tfi_offset + len + 1], checksum); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index e86153be86a..34c6a3e44f7 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -127,12 +127,8 @@ bool PN532Spi::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Response data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - uint8_t checksum = header[5] + header[6]; // TFI + Command response code - for (int i = 0; i < len - 1; i++) { - uint8_t dat = data[i]; - checksum += dat; - } - checksum = ~checksum + 1; + // TFI + Command response code + data + const uint8_t checksum = -sum_bytes(data.data(), len - 1, header[5] + header[6]); if (data[len - 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len - 1], checksum); diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 7809e803c9d..606a03e0c23 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -34,9 +34,7 @@ void PZEM004T::loop() { // 1-5: data // 6: checksum (sum of other bytes) // see https://github.com/olehs/PZEM004T - uint8_t sum = 0; - for (int i = 0; i < 6; i++) - sum += resp[i]; + const uint8_t sum = sum_bytes(resp.data(), 6); if (sum != resp[6]) { ESP_LOGV(TAG, "PZEM004T invalid checksum! 0x%02X != 0x%02X", sum, resp[6]); diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 3d46f0613e0..147365251b5 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -115,11 +115,7 @@ void SDS011Component::sds011_write_command_(const uint8_t *command_data) { } uint8_t SDS011Component::sds011_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional SDS011Component::check_byte_() const { diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 31c8e89af3a..1bc3e0aeb42 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -251,17 +251,11 @@ void MR24HPC1Component::loop() { } } -// Calculate CRC check digit -static uint8_t get_frame_crc_sum(const uint8_t *data, int len) { - unsigned int crc_sum = 0; - for (int i = 0; i < len - 3; i++) { - crc_sum += data[i]; - } - return crc_sum & 0xff; -} +// Frames are at least 10 bytes, so the three trailing bytes can always be left out +static uint8_t get_frame_crc_sum(const uint8_t *data, size_t len) { return sum_bytes(data, len - 3); } // Check that the check digit is correct -static int get_frame_check_status(uint8_t *data, int len) { +static int get_frame_check_status(const uint8_t *data, size_t len) { uint8_t crc_sum = get_frame_crc_sum(data, len); uint8_t verified = data[len - 3]; return (verified == crc_sum) ? 1 : 0; diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 0660e0f20e4..a7959a09997 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -83,13 +83,7 @@ void SM300D2Sensor::update() { this->humidity_sensor_->publish_state(humidity); } -uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { - uint8_t sum = 0; - for (int i = 0; i < (SM300D2_RESPONSE_LENGTH - 1); i++) { - sum += *ptr++; - } - return sum; -} +uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { return sum_bytes(ptr, SM300D2_RESPONSE_LENGTH - 1); } void SM300D2Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SM300D2:"); diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index 02282fef9ca..c72210e932f 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -52,13 +52,7 @@ ESPHOME_LOG_TAG(TAG, "sonoff_d1"); static constexpr size_t SONOFF_D1_ACK_SIZE = 7; static constexpr size_t SONOFF_D1_MAX_CMD_SIZE = 17; -uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { - uint8_t crc = 0; - for (size_t i = 2; i < len - 1; i++) { - crc += cmd[i]; - } - return crc; -} +uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { return sum_bytes(cmd + 2, len - 3); } void SonoffD1Output::populate_checksum_(uint8_t *cmd, const size_t len) { // Update the checksum diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 4c537b75cbf..6c780d07a92 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -10,12 +10,7 @@ ESPHOME_LOG_TAG(TAG, "systa_bus"); void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } -static bool checksum(std::span data) { - uint8_t csum = 0; - for (uint8_t i : data) - csum += i; - return csum == 0; -} +static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 0; } void SystaBus::loop() { uint8_t c; diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index 5e7f0ef29c7..15111338b24 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -168,11 +168,9 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; // Calculate & set the checksum for the current internal state of the remote. // Stored the checksum value in the last byte. - for (uint8_t checksum_byte = 0; checksum_byte < TCL112_STATE_LENGTH - 1; checksum_byte++) - checksum += remote_state[checksum_byte]; + const uint8_t checksum = sum_bytes(remote_state, TCL112_STATE_LENGTH - 1); if (checksum != remote_state[TCL112_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); return false; diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index adf56707844..91a0d0e16f5 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -24,12 +24,12 @@ static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int ma /* TeleInfo methods */ bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { int grp_len = grp_end - grp; + if (grp_len < this->checksum_area_end_) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = 0; - int i; - - for (i = 0; i < grp_len - checksum_area_end_; i++) - crc_tmp += grp[i]; + uint8_t crc_tmp = sum_bytes(reinterpret_cast(grp), grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 57361a4a587..406a1bc55db 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1052,8 +1052,6 @@ bool ToshibaClimate::compare_rac_pt1411hwru_packets_(const uint8_t *message1, co } bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { - uint8_t checksum = 0; - switch (this->is_valid_rac_pt1411hwru_header_(message)) { case RAC_PT1411HWRU_MESSAGE_HEADER0: case RAC_PT1411HWRU_CS_HEADER: @@ -1065,10 +1063,7 @@ bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { break; case RAC_PT1411HWRU_MESSAGE_HEADER1: - for (uint8_t i = 0; i < RAC_PT1411HWRU_MESSAGE_LENGTH - 1; i++) { - checksum += message[i]; - } - if (checksum == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { + if (sum_bytes(message, RAC_PT1411HWRU_MESSAGE_LENGTH - 1) == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { return true; } break; diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index c6316f59fce..025b97ee727 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -152,9 +152,7 @@ bool Tuya::validate_message_() { // Byte 6+LEN: CHECKSUM - sum of all bytes (including header) modulo 256 uint8_t rx_checksum = new_byte; - uint8_t calc_checksum = 0; - for (uint32_t i = 0; i < 6 + length; i++) - calc_checksum += data[i]; + const uint8_t calc_checksum = sum_bytes(data, 6 + length); if (rx_checksum != calc_checksum) { ESP_LOGW(TAG, "Tuya Received invalid message checksum %02X!=%02X", rx_checksum, calc_checksum); @@ -531,10 +529,8 @@ void Tuya::send_raw_command_(TuyaCommand command) { if (!command.payload.empty()) this->write_array(command.payload.data(), command.payload.size()); - uint8_t checksum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; - for (auto &data : command.payload) - checksum += data; - this->write_byte(checksum); + const uint8_t header_sum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; + this->write_byte(sum_bytes(command.payload.data(), command.payload.size(), header_sum)); } void Tuya::process_command_queue_() { diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index cd4388372cd..c68842ab6c1 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -68,9 +68,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp } static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { - uint8_t sum = 0; - for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) - sum += data[i]; + const uint8_t sum = sum_bytes(data, FRAME_CHECKSUM_INDEX); return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && @@ -83,10 +81,7 @@ static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) return false; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += data[i]; - return data[21] == (sum & 0xFF); + return data[21] == sum_bytes(data, 21); } static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index 2888fc0abb3..b7b9e5cfa99 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -58,10 +58,7 @@ void WTS01Sensor::process_packet_() { // Based on Tasmota implementation // Format: 55 01 01 04 01 11 16 12 95 // header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum - uint8_t calculated_checksum = 0; - for (uint8_t i = 0; i < PACKET_SIZE - 1; i++) { - calculated_checksum += this->buffer_[i]; - } + const uint8_t calculated_checksum = sum_bytes(this->buffer_, PACKET_SIZE - 1); uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1]; if (calculated_checksum != received_checksum) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a2ede273413..c54d1bc16f0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -879,6 +880,13 @@ template T remap(U value, U min, U max, T min_out, T max return (value - min) * (max_out - min_out) / (max - min) + min_out; } +/// Add up the bytes of \p data with size \p len, truncated to 8 bits: the checksum many serial and IR +/// protocols use. Prefer this over a bare std::accumulate, whose result type follows the seed, so a plain 0 +/// sums in int and compares wrong against a checksum byte. +constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + /// Calculate a CRC-8 checksum of \p data with size \p len. uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 521ae10e5c3..d0058a634b2 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,6 +55,14 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(HelpersTest, SumBytes) { + static constexpr uint8_t FRAME[] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x10}; + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME)), 0xE7); // the Daikin comfort frame checksum + EXPECT_EQ(sum_bytes(FRAME, 0), 0); + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME), 0x19), 0x00); // wraps at 8 bits + static_assert(sum_bytes(FRAME, 2) == 0xEB, "usable in constant expressions"); +} + TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; StaticVector big = small; From c8a300164b71e38d585156f1c920ec8f3e207418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:53:37 +0200 Subject: [PATCH 234/435] [core] Add validators that reject deferring actions in an automation (#20523) Co-authored-by: Claude Opus 5.5 --- esphome/automation.py | 46 +++++++++++++++ tests/unit_tests/test_automation.py | 91 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/esphome/automation.py b/esphome/automation.py index 6b6af4ed72e..0105d1e1072 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1048,6 +1048,52 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: return False +def validate_no_deferring_actions( + context: str, data: str = "the trigger data" +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that rejects deferring actions in a validated automation. + + Use it for triggers whose arguments only live while the callback runs (views into a reused + buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later. + + :param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``. + :param data: What is only valid while the automation runs, for the error message. + """ + + def validator(value: ConfigType) -> ConfigType: + if has_non_synchronous_actions(value): + raise cv.Invalid( + f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in " + f"{context}: {data} is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + return validator + + +def validate_synchronous_automation( + context: str, + data: str = "the trigger data", + extra_schema=None, + extra_validators=None, + single: bool = False, +) -> cv.All: + """``validate_automation()`` that also rejects deferring actions. + + See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are + passed to ``validate_automation()``. + """ + return cv.All( + validate_automation( + extra_schema=extra_schema, + extra_validators=extra_validators, + single=single, + ), + validate_no_deferring_actions(context, data), + ) + + async def _new_automation( args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 3cd70c082d5..351afe541d6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -36,12 +36,15 @@ from esphome.automation import ( register_simple_condition, string_ref_literal, templatable_bytes, + validate_no_deferring_actions, + validate_synchronous_automation, ) import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN 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 from esphome.util import Registry, RegistryEntry @@ -213,6 +216,94 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False +def test_validate_no_deferring_actions_accepts_synchronous( + mock_registry: dict[str, RegistryEntry], +) -> None: + value = [{CONF_THEN: [{"logger.log": "hello"}]}] + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_accepts_lambda_form( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A returning lambda has no action list, so there is nothing to reject.""" + value = Lambda("return true;") + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_rejects_deferring( + mock_registry: dict[str, RegistryEntry], +) -> None: + validator = validate_no_deferring_actions("on_test", "the test data") + with pytest.raises( + cv.Invalid, + match=r"not allowed in on_test: the test data is only valid while", + ): + validator([{CONF_THEN: [{"logger.log": "hello"}, {"delay": "1s"}]}]) + + +def test_validate_no_deferring_actions_default_data( + mock_registry: dict[str, RegistryEntry], +) -> None: + with pytest.raises(cv.Invalid, match=r"on_test: the trigger data is only valid"): + validate_no_deferring_actions("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_accepts_synchronous() -> None: + result = validate_synchronous_automation("on_test")( + [{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}] + ) + assert len(result) == 1 + + +def test_validate_synchronous_automation_rejects_deferring() -> None: + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_rejects_nested_deferring() -> None: + actions = [ + { + "if": { + "condition": {"lambda": Lambda("return true;")}, + CONF_THEN: [{"delay": "1s"}], + } + } + ] + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: actions}]) + + +def test_validate_synchronous_automation_passes_options() -> None: + """extra_schema and single are passed through to validate_automation().""" + validator = validate_synchronous_automation( + "on_test", extra_schema={cv.Optional("retry"): cv.string}, single=True + ) + result = validator( + {"retry": "yes", CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]} + ) + assert isinstance(result, dict) + assert result["retry"] == "yes" + with pytest.raises(cv.Invalid, match="Deferring actions"): + validator({CONF_THEN: [{"delay": "1s"}]}) + + +def test_validate_synchronous_automation_passes_extra_validators() -> None: + """extra_validators is passed through to validate_automation() and runs on each automation.""" + seen: list[ConfigType] = [] + + def extra_validator(value: ConfigType) -> ConfigType: + seen.append(value) + return value + + validator = validate_synchronous_automation( + "on_test", extra_validators=extra_validator + ) + validator([{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}]) + assert len(seen) == 1 + assert CONF_THEN in seen[0] + + def _build_forwarder( automation_name: str, args: list[tuple[str, str]], From 6322cef8e2310b1e053fc016fa1e85272de64de1 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:06:40 -0400 Subject: [PATCH 235/435] [tmp102] Add hardware configuration options (#20063) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/tmp102/__init__.py | 90 ++++++- esphome/components/tmp102/sensor.py | 65 +++-- esphome/components/tmp102/tmp102.cpp | 138 ++++++++++- esphome/components/tmp102/tmp102.h | 28 ++- esphome/core/defines.h | 1 + tests/component_tests/tmp102/__init__.py | 1 + .../tmp102/config/advanced.yaml | 23 ++ .../component_tests/tmp102/config/basic.yaml | 15 ++ tests/component_tests/tmp102/test_config.py | 95 ++++++++ tests/components/tmp102/__init__.py | 13 + tests/components/tmp102/common.yaml | 13 + tests/components/tmp102/test.esp32-idf.yaml | 3 +- tests/components/tmp102/test.esp8266-ard.yaml | 3 +- tests/components/tmp102/test.rp2040-ard.yaml | 3 +- tests/components/tmp102/test_tmp102.cpp | 228 ++++++++++++++++++ 15 files changed, 684 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/tmp102/__init__.py create mode 100644 tests/component_tests/tmp102/config/advanced.yaml create mode 100644 tests/component_tests/tmp102/config/basic.yaml create mode 100644 tests/component_tests/tmp102/test_config.py create mode 100644 tests/components/tmp102/__init__.py create mode 100644 tests/components/tmp102/test_tmp102.cpp diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 8510f18769a..6fd5f481e72 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,11 +1,85 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. +"""Shared TMP102 types and configuration validation.""" -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +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.types import ConfigType DOMAIN = "tmp102" + +tmp102_ns = cg.esphome_ns.namespace("tmp102") +TMP102Component = tmp102_ns.class_( + "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor +) + +CONF_EXTENDED_MODE = "extended_mode" +CONF_ONE_SHOT_MODE = "one_shot_mode" +CONF_TEMPERATURE_HIGH = "temperature_high" +CONF_TEMPERATURE_LOW = "temperature_low" +CONF_ALERT_POLARITY = "alert_polarity" +CONF_THERMOSTAT_MODE = "thermostat_mode" +CONF_FAULT_QUEUE = "fault_queue" + +CONVERSION_RATES = {"0.25Hz": 0, "1Hz": 1, "4Hz": 2, "8Hz": 3} +THERMOSTAT_MODES = {"comparator": 0, "interrupt": 1} +ALERT_POLARITIES = {"active_low": 0, "active_high": 1} +FAULT_QUEUE_BITS = {1: 0, 2: 1, 4: 2, 6: 3} + +TMP102_CONVERSION_FACTOR = 0.0625 +TMP102_DEFAULT_THIGH = 80.0 +TMP102_DEFAULT_TLOW = 75.0 + + +def encode_temperature(temperature: float, extended: bool) -> int: + """Encode a temperature as a TMP102 limit-register word.""" + steps = round(temperature / TMP102_CONVERSION_FACTOR) + return (steps << (3 if extended else 4)) & 0xFFFF + + +def build_configuration(config: ConfigType) -> int: + """Build the writable TMP102 configuration-register bits.""" + value = CONVERSION_RATES[config.get(CONF_CONVERSION_RATE, "4Hz")] << 6 + value |= FAULT_QUEUE_BITS[config.get(CONF_FAULT_QUEUE, 1)] << 11 + value |= ALERT_POLARITIES[config.get(CONF_ALERT_POLARITY, "active_low")] << 10 + value |= THERMOSTAT_MODES[config.get(CONF_THERMOSTAT_MODE, "comparator")] << 9 + if config.get(CONF_ONE_SHOT_MODE, False): + value |= 1 << 8 + if config.get(CONF_EXTENDED_MODE, False): + value |= 1 << 4 + return value + + +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 + high = config.get(CONF_TEMPERATURE_HIGH) + low = config.get(CONF_TEMPERATURE_LOW) + for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): + if value is not None and not t_min <= value <= t_max: + raise cv.Invalid( + f"{key} ({value}°C) out of range for " + f"{'extended' if extended else 'normal'} mode " + f"({t_min}°C to {t_max}°C)", + [key], + ) + + if high is not None and low is not None and low > high: + raise cv.Invalid( + f"low limit ({low}°C) must be <= high limit ({high}°C)", + [CONF_TEMPERATURE_LOW], + ) + if high is not None and low is None and high < TMP102_DEFAULT_TLOW: + raise cv.Invalid( + f"high limit ({high}°C) is below the chip power-up TLOW default " + f"({TMP102_DEFAULT_TLOW}°C). Set temperature_low explicitly.", + [CONF_TEMPERATURE_HIGH], + ) + if low is not None and high is None and low > TMP102_DEFAULT_THIGH: + raise cv.Invalid( + f"low limit ({low}°C) is above the chip power-up THIGH default " + f"({TMP102_DEFAULT_THIGH}°C). Set temperature_high explicitly.", + [CONF_TEMPERATURE_LOW], + ) + return config diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index eba0d3c0269..fbfed7444d6 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -1,15 +1,8 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. - -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +"""TMP102 temperature sensor and optional hardware configuration.""" 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 ( DEVICE_CLASS_TEMPERATURE, @@ -18,15 +11,42 @@ from esphome.const import ( ) from esphome.types import ConfigType +from . import ( + ALERT_POLARITIES, + CONF_ALERT_POLARITY, + CONF_EXTENDED_MODE, + CONF_FAULT_QUEUE, + CONF_ONE_SHOT_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_THERMOSTAT_MODE, + CONVERSION_RATES, + FAULT_QUEUE_BITS, + THERMOSTAT_MODES, + TMP102_DEFAULT_THIGH, + TMP102_DEFAULT_TLOW, + TMP102Component, + build_configuration, + encode_temperature, + validate_tmp102_thresholds, +) + CODEOWNERS = ["@timsavage"] DEPENDENCIES = ["i2c"] -tmp102_ns = cg.esphome_ns.namespace("tmp102") -TMP102Component = tmp102_ns.class_( - "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor -) +# No defaults here: absence of all advanced options preserves upstream's read-only behavior and firmware size. +_ADVANCED_SCHEMA = { + cv.Optional(CONF_EXTENDED_MODE): cv.boolean, + cv.Optional(CONF_CONVERSION_RATE): cv.enum(CONVERSION_RATES), + cv.Optional(CONF_ONE_SHOT_MODE): cv.boolean, + cv.Optional(CONF_ALERT_POLARITY): cv.enum(ALERT_POLARITIES), + cv.Optional(CONF_THERMOSTAT_MODE): cv.enum(THERMOSTAT_MODES), + cv.Optional(CONF_FAULT_QUEUE): cv.enum(FAULT_QUEUE_BITS, int=True), + cv.Optional(CONF_TEMPERATURE_HIGH): cv.temperature, + cv.Optional(CONF_TEMPERATURE_LOW): cv.temperature, +} -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( TMP102Component, unit_of_measurement=UNIT_CELSIUS, @@ -36,6 +56,8 @@ CONFIG_SCHEMA = ( ) .extend(cv.polling_component_schema("60s")) .extend(i2c.i2c_device_schema(0x48)) + .extend(_ADVANCED_SCHEMA), + validate_tmp102_thresholds, ) @@ -43,3 +65,18 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + + if 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), + ) + ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index d851bd925fa..a1066b97619 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -8,13 +8,33 @@ ESPHOME_LOG_TAG(TAG, "tmp102"); static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; -static const uint8_t TMP102_ADDRESS = 0x48; -static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; -static const uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; -static const uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; -static const uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; +static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; +#ifdef USE_TMP102_CONFIGURE +static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; +static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; -static const float TMP102_CONVERSION_FACTOR = 0.0625; +static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; +static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; +static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; +static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; +static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; +static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; + +static float decode_temperature(uint16_t raw, bool extended) { + return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; +} + +void TMP102Component::setup() { + if (!this->configure_) + return; + if (!this->write_configuration_()) + this->mark_failed(); +} +#endif void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); @@ -24,9 +44,44 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_CONFIGURE + if (!this->configure_) + return; + const uint8_t rate = (this->config_ >> 6) & 0x03; + const LogString *rate_str = rate == 0 ? LOG_STR("0.25Hz") + : rate == 1 ? LOG_STR("1Hz") + : rate == 2 ? LOG_STR("4Hz") + : LOG_STR("8Hz"); + const uint8_t fault_bits = (this->config_ >> 11) & 0x03; + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + ESP_LOGCONFIG( + TAG, + " Extended Mode: %s\n" + " One-Shot Mode: %s\n" + " Conversion Rate: %s\n" + " Thermostat Mode: %s\n" + " Alert Polarity: %s\n" + " Fault Queue: %u\n" + " Temperature High: %.4f°C\n" + " Temperature Low: %.4f°C", + YESNO(extended), YESNO(this->config_ & TMP102_SHUTDOWN_BIT), LOG_STR_ARG(rate_str), + this->config_ & TMP102_THERMOSTAT_MODE_BIT ? LOG_STR_LITERAL("Interrupt") : LOG_STR_LITERAL("Comparator"), + 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)); + if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { + ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); + } +#endif } void TMP102Component::update() { +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->update_configured_(); + return; + } +#endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { this->status_set_warning(); return; @@ -47,4 +102,75 @@ void TMP102Component::update() { }); } +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::update_configured_() { + if (!this->check_configuration_()) + return; + if (this->config_ & TMP102_SHUTDOWN_BIT) { + if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { + this->status_set_warning(); + return; + } + this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); + } else { + this->read_temperature_(); + } +} + +bool TMP102Component::write_register_(uint8_t reg, uint16_t value) { + uint8_t frame[3] = {reg, static_cast(value >> 8), static_cast(value)}; + if (this->write(frame, 3) == i2c::ERROR_OK) + return true; + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return false; +} + +bool TMP102Component::write_configuration_() { + return this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_) && + this->write_register_(TMP102_REGISTER_HIGH_LIMIT, this->high_limit_) && + this->write_register_(TMP102_REGISTER_LOW_LIMIT, this->low_limit_); +} + +bool TMP102Component::check_configuration_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->status_set_warning(); + return false; + } + bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); + // A chip reset is invisible in the configuration register when it matches the reset default; check the limits. + if (matches && + (this->config_ & TMP102_CONFIGURATION_MASK) == (TMP102_RESET_CONFIGURATION & TMP102_CONFIGURATION_MASK)) { + uint16_t high, low; + if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || + !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { + this->status_set_warning(); + return false; + } + matches = high == this->high_limit_ && low == this->low_limit_; + } + if (matches) + return true; + + ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); + this->status_set_warning(); + if (!this->write_configuration_()) { + ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + } + return false; +} + +void TMP102Component::read_temperature_() { + uint16_t raw; + if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { + this->status_set_warning(); + return; + } + const float temperature = decode_temperature(raw, raw & 0x0001); + ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); + this->publish_state(temperature); + this->status_clear_warning(); +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index f9eda32e989..b14b6c4cc1f 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -1,15 +1,41 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" +#include "esphome/core/defines.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +#include namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_CONFIGURE + void setup() override; + void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { + this->config_ = config; + this->high_limit_ = high_limit; + this->low_limit_ = low_limit; + this->configure_ = true; + } +#endif void dump_config() override; void update() override; + + protected: +#ifdef USE_TMP102_CONFIGURE + bool write_register_(uint8_t reg, uint16_t value); + bool write_configuration_(); + bool check_configuration_(); + void read_temperature_(); + void update_configured_(); + + uint16_t config_{0}; + uint16_t high_limit_{0}; + uint16_t low_limit_{0}; + bool configure_{false}; +#endif }; } // namespace esphome::tmp102 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de621f891a1..31549ceb8eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -243,6 +243,7 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/tmp102/__init__.py b/tests/component_tests/tmp102/__init__.py new file mode 100644 index 00000000000..61db1769a2f --- /dev/null +++ b/tests/component_tests/tmp102/__init__.py @@ -0,0 +1 @@ +"""TMP102 regression tests.""" diff --git a/tests/component_tests/tmp102/config/advanced.yaml b/tests/component_tests/tmp102/config/advanced.yaml new file mode 100644 index 00000000000..5d32255cb83 --- /dev/null +++ b/tests/component_tests/tmp102/config/advanced.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: false + conversion_rate: 1Hz + one_shot_mode: false + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 2 + temperature_high: 30 + temperature_low: 25 diff --git a/tests/component_tests/tmp102/config/basic.yaml b/tests/component_tests/tmp102/config/basic.yaml new file mode 100644 index 00000000000..6326b0eaa26 --- /dev/null +++ b/tests/component_tests/tmp102/config/basic.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py new file mode 100644 index 00000000000..020303d455d --- /dev/null +++ b/tests/component_tests/tmp102/test_config.py @@ -0,0 +1,95 @@ +"""Exercise TMP102 sensor validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import tmp102 as component +from esphome.components.tmp102.sensor import CONFIG_SCHEMA +import esphome.config_validation as cv +from esphome.core import CORE + + +def test_basic_unchanged( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("basic.yaml")) + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_" not in defines + + +def test_advanced_options_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("advanced.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(3648, 7680, 6400)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_CONFIGURE" in defines + + +@pytest.mark.parametrize( + ("extended", "high", "low"), + [ + (False, 127.9375, -55), + (True, 150, -55), + (False, 30, 30), + ], +) +def test_valid_limits(extended: bool, high: float, low: float) -> None: + validated = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": extended, + "temperature_high": high, + "temperature_low": low, + } + ) + assert validated["temperature_high"] == high + assert validated["temperature_low"] == low + + +@pytest.mark.parametrize( + "config", + [ + {"temperature_high": float("nan")}, + {"temperature_low": float("inf")}, + {"temperature_low": -55.0625}, + {"temperature_high": 128}, + {"extended_mode": True, "temperature_high": 150.0625}, + {"temperature_high": 30, "temperature_low": 31}, + {"temperature_high": 74}, + {"temperature_low": 81}, + {"fault_queue": 3}, + ], +) +def test_invalid_limits(config: dict) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Temperature", **config}) + + +def test_precomputed_register_values() -> None: + config = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": True, + "conversion_rate": "0.25Hz", + "one_shot_mode": True, + "alert_polarity": "active_high", + "thermostat_mode": "interrupt", + "fault_queue": 6, + "temperature_high": 12.22, + "temperature_low": -12.22, + } + ) + assert component.build_configuration(config) == 0x1F10 + assert component.encode_temperature(config["temperature_high"], True) == 0x0620 + assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 + + +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 new file mode 100644 index 00000000000..9702134e8c6 --- /dev/null +++ b/tests/components/tmp102/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +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: + CORE.testing_ensure_platform_registered("sensor") + cg.add_define("USE_TMP102_CONFIGURE") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 049a8067da7..c764eae42b2 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -2,3 +2,16 @@ sensor: - platform: tmp102 i2c_id: i2c_bus name: TMP102 Temperature + - platform: tmp102 + id: tmp102_extended_temperature + name: TMP102 Extended Temperature + i2c_id: i2c_bus + address: 0x49 + extended_mode: true + one_shot_mode: true + conversion_rate: 8Hz + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 6 + temperature_high: 140°C + temperature_low: 120°C diff --git a/tests/components/tmp102/test.esp32-idf.yaml b/tests/components/tmp102/test.esp32-idf.yaml index b47e39c3898..d879c5b4aef 100644 --- a/tests/components/tmp102/test.esp32-idf.yaml +++ b/tests/components/tmp102/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.esp8266-ard.yaml b/tests/components/tmp102/test.esp8266-ard.yaml index 4a98b9388ab..10be01238cf 100644 --- a/tests/components/tmp102/test.esp8266-ard.yaml +++ b/tests/components/tmp102/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.rp2040-ard.yaml b/tests/components/tmp102/test.rp2040-ard.yaml index 319a7c71a65..b364443f573 100644 --- a/tests/components/tmp102/test.rp2040-ard.yaml +++ b/tests/components/tmp102/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp new file mode 100644 index 00000000000..b148b07716c --- /dev/null +++ b/tests/components/tmp102/test_tmp102.cpp @@ -0,0 +1,228 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/tmp102/tmp102.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tmp102::testing { + +class RegisterBus final : public i2c::I2CBus { + public: + std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; + int fail_read{-1}; + int fail_write{-1}; + int fail_select{-1}; + unsigned transactions{0}; + unsigned writes{0}; + uint8_t pointer{0}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + ++this->transactions; + if (size != 0) { + if (data[0] >= this->registers.size() || data[0] == this->fail_select) + return i2c::ERROR_NOT_ACKNOWLEDGED; + this->pointer = data[0]; + if (size == 3) { + if (this->pointer == this->fail_write) + return i2c::ERROR_NOT_ACKNOWLEDGED; + ++this->writes; + this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + } + } + if (out_size != 0) { + if (out_size != 2 || this->pointer == this->fail_read) + return i2c::ERROR_NOT_ACKNOWLEDGED; + out[0] = this->registers[this->pointer] >> 8; + out[1] = this->registers[this->pointer]; + } + return i2c::ERROR_OK; + } +}; + +class TMP102Test : public ::testing::Test { + protected: + RegisterBus bus_; + TMP102Component chip_; + 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); + this->chip_.set_i2c_bus(&this->bus_); + this->chip_.set_i2c_address(0x48); + this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); + App.scheduler.call(millis()); + App.~Application(); + } + + static void run_timers(uint32_t duration) { + App.scheduler.call(millis()); + delay(duration); + App.scheduler.call(millis()); + } + + void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { + this->chip_.set_configuration(config, high, low); + } +}; + +TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + this->chip_.update(); + run_timers(55); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.registers, original); +} + +TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { + for (bool configured_extended : {false, true}) { + this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, + configured_extended ? 0x2580 : 0x4B00); + this->chip_.setup(); + const auto writes = this->bus_.writes; + for (auto raw : {0x1900, 0x0C81, 0xFF00, 0xFF81}) { + this->bus_.registers[0] = raw; + this->chip_.update(); + EXPECT_FLOAT_EQ(this->chip_.state, raw & 0x8000 ? -1 : 25); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.writes, writes); + } + } +} + +TEST_F(TMP102Test, WritableConfigurationFields) { + this->configure(0x1FD0, 0x2800, 0x2580); + this->chip_.setup(); + EXPECT_EQ(this->bus_.registers[1], 0x1FD0); + EXPECT_EQ(this->bus_.registers[3], 0x2800); + EXPECT_EQ(this->bus_.registers[2], 0x2580); +} + +TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + const auto configured = this->bus_.registers; + this->chip_.update(); + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_TRUE(this->chip_.status_has_warning()); + for (size_t reg = 1; reg < 4; ++reg) + EXPECT_EQ(this->bus_.registers[reg], configured[reg]); + this->chip_.update(); + EXPECT_EQ(this->publications_, 2); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, RecoveryRetriesFailedWritesAndDetectsThresholdOnlyChange) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[3] = 0; + this->bus_.fail_write = 3; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.fail_write = -1; + this->chip_.update(); + EXPECT_EQ(this->bus_.registers[3], 0x5000); + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, ConfigurationReadFailuresDoNotPublishOrOverwriteRegisters) { + this->configure(); + this->chip_.setup(); + for (int reg : {1, 2, 3}) { + const auto publications = this->publications_; + const auto writes = this->bus_.writes; + this->bus_.fail_read = reg; + this->chip_.update(); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->publications_, publications); + EXPECT_EQ(this->bus_.writes, writes); + this->bus_.fail_read = -1; + } +} + +TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { + this->configure(); + this->chip_.setup(); + const auto writes = this->bus_.writes; + this->bus_.registers[1] ^= 0xE020; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->configure(0x0180); + this->chip_.setup(); + this->bus_.fail_write = 1; + this->chip_.update(); + run_timers(45); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) { + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.pointer = 3; + run_timers(45); + EXPECT_EQ(this->publications_, 1); + EXPECT_FLOAT_EQ(this->chip_.state, 25); +} + +TEST_F(TMP102Test, LimitsAreOnlyCheckedWhenConfigurationMatchesResetDefault) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[2] = 0; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.registers[2], 0x4B00); + + this->configure(0x00C0); + this->chip_.setup(); + this->bus_.registers[2] = 0; + const auto transactions = this->bus_.transactions; + const auto writes = this->bus_.writes; + this->chip_.update(); + EXPECT_EQ(this->bus_.transactions - transactions, 2); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { + for (int reg : {1, 2, 3}) { + TMP102Component failed; + failed.set_i2c_bus(&this->bus_); + failed.set_i2c_address(0x48); + failed.set_configuration(0x0080, 0x5000, 0x4B00); + this->bus_.fail_write = reg; + failed.setup(); + EXPECT_TRUE(failed.is_failed()); + } +} + +} // namespace esphome::tmp102::testing + +#endif From eb1aa2bfd6e44ffbc5082b53be410bad68776896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:07:06 -1000 Subject: [PATCH 236/435] [web_server] Keep the cover, valve and alarm panel action tables in flash (#20397) --- esphome/components/web_server/web_server.cpp | 72 ++++++++------------ 1 file changed, 27 insertions(+), 45 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 062007b02e0..323857c37e8 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -60,6 +60,27 @@ ESPHOME_LOG_TAG(TAG, "web_server"); // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +// One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the +// table needs no RAM on ESP8266. +template struct ActionEntry { + char name[16]; + Call &(Call::*action)(); +}; + +// Runs the entry whose name matches the request's action; false when none does. +template +static bool run_action(const UrlMatch &match, Call &call, const ActionEntry (&table)[N]) { + for (const auto &entry : table) { + if (match.method_equals(reinterpret_cast(entry.name))) { + Call &(Call::*action)(); + progmem_memcpy(&action, &entry.action, sizeof(action)); + (call.*action)(); + return true; + } + } + return false; +} + // Out of line: every GET handler ends with this [[maybe_unused]] static void send_json(AsyncWebServerRequest *request, json::JsonBuilder &builder) { auto data = builder.serialize(); @@ -1051,27 +1072,14 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for cover methods - static const struct { - const char *name; - cover::CoverCall &(cover::CoverCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &cover::CoverCall::set_command_open}, {"close", &cover::CoverCall::set_command_close}, {"stop", &cover::CoverCall::set_command_stop}, {"toggle", &cover::CoverCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1745,27 +1753,14 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for valve methods - static const struct { - const char *name; - valve::ValveCall &(valve::ValveCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &valve::ValveCall::set_command_open}, {"close", &valve::ValveCall::set_command_close}, {"stop", &valve::ValveCall::set_command_stop}, {"toggle", &valve::ValveCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1831,11 +1826,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques static_cast(&decltype(call)::set_code)); - // Lookup table for alarm control panel methods - static const struct { - const char *name; - alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, {"arm_away", &alarm_control_panel::AlarmControlPanelCall::arm_away}, {"arm_home", &alarm_control_panel::AlarmControlPanelCall::arm_home}, @@ -1843,16 +1834,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques {"arm_vacation", &alarm_control_panel::AlarmControlPanelCall::arm_vacation}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found) { + if (!run_action(match, call, ACTIONS)) { request->send(404); return; } From 9355bdc3c5472560566763ec7ed5949b43c8fdf0 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 11 Oct 2026 21:12:28 +0200 Subject: [PATCH 237/435] [canbus] Bus error logging, Auto-recover bus-off on ESP32 (#10953) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/canbus/__init__.py | 17 ++++- esphome/components/canbus/canbus.cpp | 58 +++++++++++++++ esphome/components/canbus/canbus.h | 44 ++++++++++++ esphome/components/esp32_can/canbus.py | 2 +- esphome/components/esp32_can/esp32_can.cpp | 82 ++++++++++++++++++++++ esphome/components/esp32_can/esp32_can.h | 5 ++ esphome/components/mcp2515/canbus.py | 2 +- esphome/components/mcp2515/mcp2515.cpp | 53 +++++++++++--- esphome/components/mcp2515/mcp2515.h | 5 ++ tests/components/esp32_can/common.yaml | 9 +++ tests/components/mcp2515/common.yaml | 11 +++ 11 files changed, 273 insertions(+), 15 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 23dc41d5092..56733870dc4 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,14 +1,17 @@ +import logging import re from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID +from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@mvturnho", "@danielschramm"] DOMAIN = "canbus" IS_PLATFORM_COMPONENT = True @@ -150,9 +153,19 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: ) -async def register_canbus(var: MockObj, config: ConfigType) -> None: +async def register_canbus( + var: MockObj, config: ConfigType, *, reports_status: bool | None = None +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) + if reports_status is None: + _LOGGER.warning( + "CAN bus platform '%s' does not report bus state, so bus-off recovery and " + "error-state logging are disabled. Platform authors should either override " + "get_status() and get_events() and call register_canbus(..., reports_status=True) " + "or pass reports_status=False if the hardware cannot report it", + config[CONF_PLATFORM], + ) await setup_canbus_core_(var, config) diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 88eed2246fd..dfe3ab9fd31 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -1,5 +1,6 @@ #include "canbus.h" #include +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::canbus { @@ -65,6 +66,12 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { }; void Canbus::loop() { + uint32_t now = App.get_loop_component_start_time(); + if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) { + this->log_events_(this->get_events(), now); + this->last_event_check_time_ = now; + } + struct CanFrame can_message; uint32_t message_counter = 0; for (; message_counter < this->max_frames_per_loop_; message_counter++) { @@ -109,4 +116,55 @@ void Canbus::loop() { } } +void Canbus::log_events_(CanEventFlags events, uint32_t now) { + // Bus-off gets its own handling because automatic recovery can make it flap + if (events & CanEventFlags::CAN_EVENT_BUS_OFF) { + this->last_bus_off_time_ = now; + if (!this->bus_off_) { + this->bus_off_ = true; + this->status_set_warning(LOG_STR("Bus off")); + } + } else if (this->bus_off_) { + if ((now - this->last_bus_off_time_) >= EVENT_LOG_BUS_OFF_HOLDOFF_MS) { + // No bus-off event for the hold-off time, so ask whether it is really over + if (this->get_status().bus_off) { + this->last_bus_off_time_ = now; + } else { + this->bus_off_ = false; + this->status_clear_warning(); + // Events gathered while bus-off say nothing about the recovered bus + this->events_to_log_ = 0; + } + } + } else { + this->events_to_log_ |= events; + if (this->events_to_log_ != 0 && (now - this->last_event_log_time_) >= EVENT_LOG_THROTTLE_MS) { + if (this->events_to_log_ & (CanEventFlags::CAN_EVENT_PASSIVE | CanEventFlags::CAN_EVENT_ACTIVE)) { + bool error_passive = this->events_to_log_ & CanEventFlags::CAN_EVENT_PASSIVE; + if (error_passive && (this->events_to_log_ & CanEventFlags::CAN_EVENT_ACTIVE)) { + // Both edges within one throttle window, so the counters decide the current state + auto status = this->get_status(); + error_passive = status.rx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD || + status.tx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD; + } + ESP_LOGW(TAG, "entered %s", error_passive ? LOG_STR_LITERAL("error-passive") : LOG_STR_LITERAL("error-active")); + } + if (this->events_to_log_ & CanEventFlags::CAN_EVENT_RX_QUEUE_FULL) { + ESP_LOGD(TAG, "receive buffer overrun"); + } + this->events_to_log_ = 0; + this->last_event_log_time_ = now; + } + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + if ((now - this->last_state_log_time_) >= STATE_LOG_INTERVAL_MS) { + auto status = this->get_status(); + ESP_LOGV(TAG, "Status: bus_off %d, tx_err %d, rx_err %d", status.bus_off, status.tx_error_counter, + status.rx_error_counter); + this->last_state_log_time_ = now; + } +#endif +} + } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 432eb77e13d..077ac9d4cdf 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -48,6 +48,8 @@ template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ static const uint8_t CAN_MAX_DATA_LENGTH = 8; +static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; + /* Can Frame describes a normative CAN Frame The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used @@ -61,6 +63,26 @@ struct CanFrame { uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); }; +enum CanEventFlags : uint8_t { + CAN_EVENT_PASSIVE = 1 << 0, + CAN_EVENT_ACTIVE = 1 << 1, + CAN_EVENT_BUS_OFF = 1 << 2, + CAN_EVENT_RX_QUEUE_FULL = 1 << 3, +}; + +static constexpr uint32_t CAN_STATUS_UNSET = UINT32_MAX; + +struct CanStatus { + bool bus_off{false}; + uint8_t rx_error_counter{0}; + uint8_t tx_error_counter{0}; + uint32_t tx_failed_count{CAN_STATUS_UNSET}; + uint32_t rx_missed_count{CAN_STATUS_UNSET}; + uint32_t rx_overrun_count{CAN_STATUS_UNSET}; + uint32_t arb_lost_count{CAN_STATUS_UNSET}; + uint32_t bus_error_count{CAN_STATUS_UNSET}; +}; + class Canbus : public Component { public: Canbus(){}; @@ -83,6 +105,7 @@ class Canbus : public Component { void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } + virtual CanStatus get_status() { return {}; } void add_trigger(CanbusTrigger *trigger); /** @@ -104,12 +127,33 @@ class Canbus : public Component { uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py bool use_extended_id_{false}; + uint8_t events_to_log_{0}; + bool bus_off_{false}; CallbackManager &data)> callback_manager_{}; + // interval in which to check for new can bus events (bus-off, passive, active) + static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100; + uint32_t last_event_check_time_{0}; + + // time to wait after logging an event before logging the next event(s) + static constexpr uint32_t EVENT_LOG_THROTTLE_MS = 1000; + uint32_t last_event_log_time_{0}; + + // time span without bus-off events before the bus is declared recovered + static constexpr uint32_t EVENT_LOG_BUS_OFF_HOLDOFF_MS = 1000; + uint32_t last_bus_off_time_{0}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE + static constexpr uint32_t STATE_LOG_INTERVAL_MS = 1000; + uint32_t last_state_log_time_{0}; +#endif + + void log_events_(CanEventFlags events, uint32_t now); virtual bool setup_internal() = 0; virtual Error send_message(struct CanFrame *frame) = 0; virtual Error read_message(struct CanFrame *frame) = 0; + virtual CanEventFlags get_events() { return CanEventFlags{}; } }; template class CanbusSendAction final : public Action, public Parented { diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2272459fb8f..2713bae241a 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -129,7 +129,7 @@ async def to_code(config: ConfigType) -> None: # Also enable esp_driver_twai for future migration to new API include_builtin_idf_component("esp_driver_twai") var = cg.new_Pvariable(config[CONF_ID]) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) cg.add(var.set_rx(config[CONF_RX_PIN])) cg.add(var.set_tx(config[CONF_TX_PIN])) diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index 083c97f8cfe..b98ba853e78 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -84,6 +84,8 @@ bool ESP32Can::setup_internal() { twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t) this->tx_, (gpio_num_t) this->rx_, twai_mode); g_config.controller_id = next_twai_ctrl_num++; + g_config.alerts_enabled = TWAI_ALERT_ERR_PASS | TWAI_ALERT_ERR_ACTIVE | TWAI_ALERT_BUS_OFF | + TWAI_ALERT_BUS_RECOVERED | TWAI_ALERT_RX_QUEUE_FULL; if (this->tx_queue_len_.has_value()) { g_config.tx_queue_len = this->tx_queue_len_.value(); } @@ -116,6 +118,86 @@ bool ESP32Can::setup_internal() { return true; } +canbus::CanEventFlags ESP32Can::get_events() { + if (this->twai_handle_ == nullptr) { + return {}; // not set up yet, or setup failed + } + + uint32_t alerts; + esp_err_t err = twai_read_alerts_v2(this->twai_handle_, &alerts, 0); + if (err != ESP_OK) { + if (err != ESP_ERR_TIMEOUT) { + ESP_LOGD(TAG, "failed to get CAN events"); + } + alerts = 0; + } + + uint8_t events = 0; + if (alerts & TWAI_ALERT_ERR_PASS) { + events |= canbus::CAN_EVENT_PASSIVE; + } + if (alerts & TWAI_ALERT_ERR_ACTIVE) { + events |= canbus::CAN_EVENT_ACTIVE; + } + if (alerts & TWAI_ALERT_BUS_OFF) { + events |= canbus::CAN_EVENT_BUS_OFF; + // Start recovery at once, as the MCP2515 does in hardware + this->recovery_pending_ = true; + } + if (alerts & TWAI_ALERT_BUS_RECOVERED) { + // Recovery leaves the controller stopped + this->restart_pending_ = true; + } + if (this->recovery_pending_ || this->restart_pending_) { + this->handle_bus_off_recovery_(); + } + if (alerts & TWAI_ALERT_RX_QUEUE_FULL) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + } + return static_cast(events); +} + +void ESP32Can::handle_bus_off_recovery_() { + // These only fail with an invalid handle or outside the expected state; retry on the next pass + if (this->recovery_pending_) { + if (twai_initiate_recovery_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Recovery after bus off failed")); + return; + } + this->recovery_pending_ = false; + } + if (this->restart_pending_) { + if (twai_start_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Restart after bus off failed")); + return; + } + this->restart_pending_ = false; + } + this->status_clear_error(); +} + +canbus::CanStatus ESP32Can::get_status() { + canbus::CanStatus status = {}; + + twai_status_info_t twai_status; + + if (this->twai_handle_ != nullptr && twai_get_status_info_v2(this->twai_handle_, &twai_status) == ESP_OK) { + status.bus_off = (twai_status.state == TWAI_STATE_BUS_OFF) || (twai_status.state == TWAI_STATE_RECOVERING); + status.rx_error_counter = twai_status.rx_error_counter; + status.tx_error_counter = twai_status.tx_error_counter; + + status.tx_failed_count = twai_status.tx_failed_count; + status.rx_missed_count = twai_status.rx_missed_count; + status.rx_overrun_count = twai_status.rx_overrun_count; + status.arb_lost_count = twai_status.arb_lost_count; + status.bus_error_count = twai_status.bus_error_count; + } else { + ESP_LOGD(TAG, "failed to get CAN status"); + } + + return status; +} + canbus::Error ESP32Can::send_message(struct canbus::CanFrame *frame) { // In listen-only mode, we cannot transmit if (this->mode_ == CAN_MODE_LISTEN_ONLY) { diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f224e10be33..f9549bea346 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -24,16 +24,21 @@ class ESP32Can final : public canbus::Canbus { void set_tx_enqueue_timeout_ms(uint32_t tx_enqueue_timeout_ms) { this->tx_enqueue_timeout_ticks_ = pdMS_TO_TICKS(tx_enqueue_timeout_ms); } + canbus::CanStatus get_status() override; ESP32Can(){}; protected: bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error send_message(struct canbus::CanFrame *frame) override; canbus::Error read_message(struct canbus::CanFrame *frame) override; + void handle_bus_off_recovery_(); int rx_{-1}; int tx_{-1}; CanMode mode_{CAN_MODE_NORMAL}; + bool recovery_pending_{false}; + bool restart_pending_{false}; TickType_t tx_enqueue_timeout_ticks_{}; optional tx_queue_len_{}; optional rx_queue_len_{}; diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index 8bb8918f965..eab9be0f700 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -40,7 +40,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( async def to_code(config: ConfigType) -> None: rhs = mcp2515.new() var = cg.Pvariable(config[CONF_ID], rhs) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) if CONF_CLOCK in config: canclock = CAN_CLOCK[config[CONF_CLOCK]] cg.add(var.set_mcp_clock(canclock)) diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index 8a1973d022f..599a864e54e 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -43,6 +43,48 @@ bool MCP2515::setup_internal() { return true; } +canbus::CanEventFlags MCP2515::get_events() { + uint8_t events = 0; + uint8_t error_flags = this->get_error_flags_(); + + // Error-passive is one state for RX and TX together, so only a change of the combined flag is an event + bool was_passive = this->last_error_flags_ & (EFLG_RXEP | EFLG_TXEP); + bool is_passive = error_flags & (EFLG_RXEP | EFLG_TXEP); + if (was_passive != is_passive) { + events |= is_passive ? canbus::CAN_EVENT_PASSIVE : canbus::CAN_EVENT_ACTIVE; + } + if ((this->last_error_flags_ ^ error_flags) & EFLG_TXBO) { + events |= canbus::CAN_EVENT_BUS_OFF; + } + + // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set + // once both buffers are full. However, the RX0OVR flag is actually set instead. + // We can just check for both though because it doesn't break anything. + if (error_flags & (EFLG_RX0OVR | EFLG_RX1OVR)) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + this->clear_rx_n_ovr_flags_(); + } + + this->last_error_flags_ = error_flags; + return static_cast(events); +} + +canbus::CanStatus MCP2515::get_status() { + canbus::CanStatus status = {}; + + uint8_t error_flags = this->get_error_flags_(); + status.bus_off = error_flags & EFLG_TXBO; + + // Transmit Error Counter is followed by Receive Error Counter so we're reading both at once + uint8_t err_counters[2]; + this->read_registers_(MCP_TEC, err_counters, 2); + + status.rx_error_counter = err_counters[1]; + status.tx_error_counter = err_counters[0]; + + return status; +} + canbus::Error MCP2515::reset_() { this->enable(); this->transfer_byte(INSTRUCTION_RESET); @@ -331,17 +373,6 @@ canbus::Error MCP2515::read_message(struct canbus::CanFrame *frame) { rc = canbus::ERROR_NOMSG; } -#ifdef ESPHOME_LOG_HAS_DEBUG - uint8_t err = get_error_flags_(); - // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set - // once both buffers are full. However, the RX0OVR flag is actually set instead. - // We can just check for both though because it doesn't break anything. - if (err & (EFLG_RX0OVR | EFLG_RX1OVR)) { - ESP_LOGD(TAG, "receive buffer overrun"); - clear_rx_n_ovr_flags_(); - } -#endif - return rc; } diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index 960e1508000..8d2fe4d51b6 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -58,6 +58,7 @@ class MCP2515 final : public canbus::Canbus, MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; void set_mcp_mode(const CanctrlReqopMode mode) { this->mcp_mode_ = mode; } + canbus::CanStatus get_status() override; static const struct TxBnRegs { REGISTER CTRL; REGISTER SIDH; @@ -74,7 +75,11 @@ class MCP2515 final : public canbus::Canbus, protected: CanClock mcp_clock_{MCP_8MHZ}; CanctrlReqopMode mcp_mode_ = CANCTRL_REQOP_NORMAL; + + uint8_t last_error_flags_{0}; + bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error set_mode_(CanctrlReqopMode mode); uint8_t read_register_(REGISTER reg); diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index f15b609d843..a26c5d35a34 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -44,3 +44,12 @@ canbus: break; // to be continued... } + +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(esp32_can_esp32_internal_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d, bus_err: %" PRIu32 ", arb_lost: %" PRIu32, + status.bus_off, status.tx_error_counter, status.rx_error_counter, + status.bus_error_count, status.arb_lost_count); diff --git a/tests/components/mcp2515/common.yaml b/tests/components/mcp2515/common.yaml index 2e88f3e5c64..a35839d9f29 100644 --- a/tests/components/mcp2515/common.yaml +++ b/tests/components/mcp2515/common.yaml @@ -42,6 +42,17 @@ canbus: // to be continued... } +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(mcp2515_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d", + status.bus_off, + status.tx_error_counter, + status.rx_error_counter + ); + button: - platform: template name: MCP2515 Send From 8449820480e14cc8769b9fab16352c50d6c0660d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:18 +0200 Subject: [PATCH 238/435] [emontx] Use the core validate_synchronous_automation() helper (#20525) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 971f9769246..79b0e83405b 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -44,20 +44,11 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] -def _synchronous_automation(value: ConfigType) -> ConfigType: - """Reject deferring actions: the received line and parsed JSON are only valid while the - automation runs, and DelayAction and friends keep the trigger arguments for later.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and " - "on_data: the received data is only valid while the automation runs. Copy what you need " - "into globals first, then defer in a separate script or automation." - ) - return value - - def _synchronous_automation_schema() -> cv.All: - return cv.All(automation.validate_automation({}), _synchronous_automation) + # The received line and the parsed JSON document only live while the callbacks run. + return automation.validate_synchronous_automation( + "on_json and on_data", "the received data" + ) # Main configuration schema From eff3a6f9a269d80329ad490b3d1bd4498a570a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:39 +0200 Subject: [PATCH 239/435] [modbus_client] Use the core deferring-actions validators (#20526) Co-authored-by: Claude Opus 5.5 --- esphome/components/modbus_client/__init__.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 5294f20f2de..67c3cd75bdc 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -113,20 +113,18 @@ def _packed_bit_bytes(bits: int) -> int: return (bits + 7) // 8 -def _synchronous_handler(value: ConfigType) -> ConfigType: - """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused - once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " - "handlers: the request/response data is only valid while the handler runs. Copy what you " - "need into globals first, then defer in a separate script or automation." - ) - return value +# Handler PDU spans point into hub buffers that are reused once the handler returns. +_HANDLER_CONTEXT = "modbus_client handlers" +_HANDLER_DATA = "the request/response data" +_synchronous_handler = automation.validate_no_deferring_actions( + _HANDLER_CONTEXT, _HANDLER_DATA +) def _handler_schema() -> cv.All: - return cv.All(automation.validate_automation(single=True), _synchronous_handler) + return automation.validate_synchronous_automation( + _HANDLER_CONTEXT, _HANDLER_DATA, single=True + ) # Each action is its own hub device: the modbus hub routes the reply straight back to the action that From 78c7cd725de3b80dc6d2cc496141617a85d0a5d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:22:39 -1000 Subject: [PATCH 240/435] [remote_base] Transmit constant Haier, Mirage and AEHA codes without a heap vector (#20287) --- .../components/remote_base/aeha_protocol.cpp | 9 +- .../components/remote_base/aeha_protocol.h | 12 +- .../components/remote_base/haier_protocol.cpp | 7 +- .../components/remote_base/haier_protocol.h | 7 +- .../remote_base/mirage_protocol.cpp | 9 +- .../components/remote_base/mirage_protocol.h | 9 +- tests/components/remote_base/__init__.py | 3 +- .../remote_base/ir_code_encoders_test.cpp | 139 ++++++++++++++++++ 8 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 tests/components/remote_base/ir_code_encoders_test.cpp diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index eb05f7d21e8..3263f6b2208 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -14,20 +14,21 @@ static constexpr uint16_t BIT_ONE_LOW_US = BITWISE * 3; static constexpr uint16_t BIT_ZERO_LOW_US = BITWISE; static constexpr uint16_t TRAILER = BITWISE; -void AEHAProtocol::encode(RemoteTransmitData *dst, const AEHAData &data) { - dst->reserve(2 + 32 + (data.data.size() * 2) + 1); +void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len) { + dst->reserve(2 + 32 + (len * 2) + 1); dst->item(HEADER_HIGH_US, HEADER_LOW_US); for (uint16_t mask = 1 << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { + if (address & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); } else { dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); } } - for (uint8_t bit : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t bit = data[i]; for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { if (bit & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 57706a7e4f1..f6064dd5df7 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,7 +15,10 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data); + void encode(RemoteTransmitData *dst, const AEHAData &data) { + this->encode(dst, data.address, data.data.data(), data.data.size()); + } + void encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const AEHAData &data); @@ -32,11 +35,10 @@ template class AEHAAction : public RemoteTransmitterActionBaseaddress_.value(x...); - data.data = this->data_.value(x...); + const uint16_t address = this->address_.value(x...); dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - AEHAProtocol().encode(dst, data); + this->data_.visit( + [dst, address](const uint8_t *data, size_t len) { AEHAProtocol().encode(dst, address, data, len); }, x...); } }; diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index 3c9ba7b61f0..e7fa563fb54 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -29,16 +29,17 @@ void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { } } -void HaierProtocol::encode(RemoteTransmitData *dst, const HaierData &data) { +void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 16)); + dst->reserve(5 + ((len + 1) * 16)); dst->mark(HEADER_LOW_US); dst->space(HEADER_LOW_US); dst->mark(HEADER_LOW_US); dst->space(HEADER_HIGH_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = data[i]; this->encode_byte_(dst, item); checksum += item; } diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 90af88d2c78..770784b0fc5 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,7 +13,8 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data); + void encode(RemoteTransmitData *dst, const HaierData &data) { this->encode(dst, data.data.data(), data.data.size()); } + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const HaierData &data); @@ -28,9 +29,7 @@ template class HaierAction : public RemoteTransmitterActionBase< TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - HaierData data{}; - data.data = this->code_.value(x...); - HaierProtocol().encode(dst, data); + this->code_.visit([dst](const uint8_t *data, size_t len) { HaierProtocol().encode(dst, data, len); }, x...); } }; diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index c2a487080bc..23c6bd21e8f 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -16,16 +16,17 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; // Max data bytes in packet (excluding checksum) constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); -void MirageProtocol::encode(RemoteTransmitData *dst, const MirageData &data) { +void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { char hex_buf[format_hex_pretty_size(MIRAGE_MAX_DATA_BYTES)]; - ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data, len)); dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 2)); + dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); dst->space(HEADER_SPACE_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = data[i]; this->encode_byte_(dst, item); checksum += (item >> 4) + (item & 0xF); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index 958127ba664..923d412e841 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,7 +13,10 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data); + void encode(RemoteTransmitData *dst, const MirageData &data) { + this->encode(dst, data.data.data(), data.data.size()); + } + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const MirageData &data); @@ -28,9 +31,7 @@ template class MirageAction : public RemoteTransmitterActionBase TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - MirageData data{}; - data.data = this->code_.value(x...); - MirageProtocol().encode(dst, data); + this->code_.visit([dst](const uint8_t *data, size_t len) { MirageProtocol().encode(dst, data, len); }, x...); } }; diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 714f8ab571a..c52eb41ca44 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - request_protocol("hob2hood") + for protocol in ("hob2hood", "haier", "mirage", "aeha"): + request_protocol(protocol) manifest.to_code = to_code_testing # AUTO_LOAD is not resolved by the unit test build. diff --git a/tests/components/remote_base/ir_code_encoders_test.cpp b/tests/components/remote_base/ir_code_encoders_test.cpp new file mode 100644 index 00000000000..bffea25864e --- /dev/null +++ b/tests/components/remote_base/ir_code_encoders_test.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/haier_protocol.h" +#include "esphome/components/remote_base/mirage_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +const uint8_t HAIER_CODE[] = {0xA6, 0x12, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05}; +const uint8_t MIRAGE_CODE[] = {0x56, 0x75, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; +const uint8_t AEHA_CODE[] = {0x80, 0x00, 0x00, 0x00, 0x00}; +constexpr uint16_t AEHA_ADDRESS = 0x2002; + +// Captured from the struct encoders before they gained the pointer and length overloads +const RawTimings HAIER_GOLDEN = { + 3100, -3100, 3100, -4400, 540, -1650, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -1650, 540, -1650, 540, -580, + 540, -1650, 540, -1650, 540, -1650, 540, -1650, 540, -580, 540, -1650, 540}; +const RawTimings MIRAGE_GOLDEN = { + 8360, -4248, 554, -545, 554, -1592, 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -545, 554, -545, 554}; +const RawTimings AEHA_GOLDEN = { + 3400, -1700, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -1275, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425}; + +std::vector haier_code() { return {std::begin(HAIER_CODE), std::end(HAIER_CODE)}; } +std::vector mirage_code() { return {std::begin(MIRAGE_CODE), std::end(MIRAGE_CODE)}; } +std::vector aeha_code() { return {std::begin(AEHA_CODE), std::end(AEHA_CODE)}; } + +// The Haier and Mirage decoders expect an item after the last mark (pre-existing) +RawTimings with_trailing_space(RawTimings timings) { + timings.push_back(-10000); + return timings; +} + +template RawTimings encode(A &action) { + RemoteTransmitData dst; + action.encode(&dst); + return dst.get_data(); +} + +// Exposes the protected encode() of an action for testing +template class Open : public A { + public: + using A::encode; +}; + +// A constant and a lambda code must both encode to the golden timings +template +void expect_static_and_lambda_match(const uint8_t *code, size_t len, std::vector (*lambda)(), + const RawTimings &golden) { + Open fixed, from_lambda; + fixed.set_code_static(code, len); + from_lambda.set_code_template(lambda); + EXPECT_EQ(encode(fixed), golden); + EXPECT_EQ(encode(from_lambda), golden); +} + +template void expect_decodes_to(const RawTimings &timings, const std::vector &code) { + auto decoded = P().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + EXPECT_EQ(decoded.value_or(typename P::ProtocolData{}).data, code); +} + +} // namespace + +TEST(IrCodeEncodersTest, HaierStaticAndLambdaMatchGolden) { + expect_static_and_lambda_match>(HAIER_CODE, sizeof(HAIER_CODE), haier_code, HAIER_GOLDEN); +} + +TEST(IrCodeEncodersTest, MirageStaticAndLambdaMatchGolden) { + expect_static_and_lambda_match>(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code, MIRAGE_GOLDEN); +} + +TEST(IrCodeEncodersTest, AehaStaticAndLambdaMatchGolden) { + Open> fixed, lambda; + for (auto *action : {&fixed, &lambda}) { + // TemplatableFn fields take functions, not raw constants + action->set_address([]() -> uint16_t { return AEHA_ADDRESS; }); + action->set_carrier_frequency([]() -> uint32_t { return 38000; }); + } + fixed.set_data_static(AEHA_CODE, sizeof(AEHA_CODE)); + lambda.set_data_template(aeha_code); + EXPECT_EQ(encode(fixed), AEHA_GOLDEN); + EXPECT_EQ(encode(lambda), AEHA_GOLDEN); +} + +TEST(IrCodeEncodersTest, StructEncodersMatchGolden) { + RemoteTransmitData haier, mirage, aeha; + HaierProtocol().encode(&haier, HaierData{haier_code()}); + MirageProtocol().encode(&mirage, MirageData{mirage_code()}); + AEHAData aeha_data; + aeha_data.address = AEHA_ADDRESS; + aeha_data.data = aeha_code(); + AEHAProtocol().encode(&aeha, aeha_data); + EXPECT_EQ(haier.get_data(), HAIER_GOLDEN); + EXPECT_EQ(mirage.get_data(), MIRAGE_GOLDEN); + EXPECT_EQ(aeha.get_data(), AEHA_GOLDEN); +} + +TEST(IrCodeEncodersTest, RoundTripDecodes) { + expect_decodes_to(with_trailing_space(HAIER_GOLDEN), haier_code()); + expect_decodes_to(with_trailing_space(MIRAGE_GOLDEN), mirage_code()); + expect_decodes_to(AEHA_GOLDEN, aeha_code()); + auto aeha = AEHAProtocol().decode(RemoteReceiveData(AEHA_GOLDEN, 25, TOLERANCE_MODE_PERCENTAGE)); + EXPECT_EQ(aeha.value_or(AEHAData{}).address, AEHA_ADDRESS); +} + +} // namespace esphome::remote_base::testing From c130e2808b24fe430df9d88d628ffd3f6da3ee05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:23:05 -1000 Subject: [PATCH 241/435] [display] Build on_page_change with build_callback_automation and compile it out when unused (#20456) --- esphome/components/display/__init__.py | 30 ++++++++++++-------------- esphome/components/display/display.cpp | 17 ++++++--------- esphome/components/display/display.h | 26 +++++++++------------- esphome/core/defines.h | 1 + 4 files changed, 32 insertions(+), 42 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 1cd75ddaf07..0b871af30cd 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -21,7 +21,6 @@ from esphome.const import ( CONF_PAGES, CONF_ROTATION, CONF_TO, - CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, CONF_WIDTH, SCHEDULER_DONT_RUN, @@ -39,14 +38,13 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayOnPageChangeTrigger = display_ns.class_( - "DisplayOnPageChangeTrigger", automation.Trigger -) CONF_ON_PAGE_CHANGE = "on_page_change" CONF_SHOW_TEST_CARD = "show_test_card" CONF_UNSPECIFIED = "unspecified" +_request_page_change_slot = cg.slot_counter("DISPLAY_PAGE_CHANGE_CALLBACK_COUNT") + DISPLAY_ROTATIONS = { 0: display_ns.DISPLAY_ROTATION_0_DEGREES, 90: display_ns.DISPLAY_ROTATION_90_DEGREES, @@ -99,9 +97,6 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend( ), cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayOnPageChangeTrigger - ), cv.Optional(CONF_FROM): cv.use_id(DisplayPage), cv.Optional(CONF_TO): cv.use_id(DisplayPage), } @@ -137,15 +132,18 @@ async def setup_display_core_(var, config): pages.append(page) cg.add(var.set_pages(pages)) for conf in config.get(CONF_ON_PAGE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if CONF_FROM in conf: - page = await cg.get_variable(conf[CONF_FROM]) - cg.add(trigger.set_from(page)) - if CONF_TO in conf: - page = await cg.get_variable(conf[CONF_TO]) - cg.add(trigger.set_to(page)) - await automation.build_automation( - trigger, [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], conf + _request_page_change_slot(str(var)) + checks = [] + for key, name in ((CONF_FROM, "from"), (CONF_TO, "to")): + if (page_id := conf.get(key)) is not None: + page = await cg.get_variable(page_id) + checks.append(f"{name} == ::{page}") + await automation.build_callback_automation( + var, + "add_on_page_change_callback", + [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], + conf, + when=" && ".join(checks) or None, ) if config.get(CONF_SHOW_TEST_CARD): cg.add(var.show_test_card()) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 49cd2fc7c66..0bd8346953c 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -678,12 +678,14 @@ void Display::set_pages(std::vector pages) { } void Display::show_page(DisplayPage *page) { - this->previous_page_ = this->page_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + DisplayPage *previous = this->page_; this->page_ = page; - if (this->previous_page_ != this->page_) { - for (auto *t : on_page_change_triggers_) - t->process(this->previous_page_, this->page_); - } + if (previous != page) + this->page_change_callback_.call(previous, page); +#else + this->page_ = page; +#endif } void Display::do_update_() { @@ -700,11 +702,6 @@ void Display::do_update_() { this->clear_clipping_(); } -void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) { - if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to)) - this->trigger(from, to); -} - void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ESPTime time) { char buffer[64]; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 74b5bcedd94..7d1053f4fa7 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -7,6 +7,8 @@ #include "esphome/core/color.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/time.h" #include "esphome/core/log.h" #include "display_color_utils.h" @@ -173,7 +175,6 @@ enum RegularPolygonDrawing { class Display; class DisplayPage; -class DisplayOnPageChangeTrigger; /** Optimized display writer that uses function pointers for stateless lambdas. * @@ -702,7 +703,11 @@ class Display : public PollingComponent { const DisplayPage *get_active_page() const { return this->page_; } - void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); } +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + template void add_on_page_change_callback(F &&callback) { + this->page_change_callback_.add(std::forward(callback)); + } +#endif /// Internal method to set the display rotation with. virtual void set_rotation(DisplayRotation rotation); @@ -809,8 +814,9 @@ class Display : public PollingComponent { DisplayRotation rotation_{DISPLAY_ROTATION_0_DEGREES}; display_writer_t writer_{}; DisplayPage *page_{nullptr}; - DisplayPage *previous_page_{nullptr}; - std::vector on_page_change_triggers_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + StaticCallbackManager page_change_callback_; +#endif bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; uint8_t wdt_pixel_counter_{0}; @@ -850,18 +856,6 @@ template class DisplayPageShowAction final : public Action { - public: - explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } - void process(DisplayPage *from, DisplayPage *to); - void set_from(DisplayPage *p) { this->from_ = p; } - void set_to(DisplayPage *p) { this->to_ = p; } - - protected: - DisplayPage *from_{nullptr}; - DisplayPage *to_{nullptr}; -}; - const LogString *text_align_to_string(TextAlign textalign); } // namespace esphome::display diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 31549ceb8eb..552221dd5eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -63,6 +63,7 @@ #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY +#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT From 86192d89f4afc681e18b0652ea1a7fb1f6b4bb35 Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Sun, 11 Oct 2026 21:29:56 +0200 Subject: [PATCH 242/435] [ufm01] Improve stale recovery, add diagnostics and clear flow action (#18899) Co-authored-by: Cursor Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/ufm01/__init__.py | 37 ++ esphome/components/ufm01/text_sensor.py | 34 ++ esphome/components/ufm01/ufm01.cpp | 563 ++++++++++++++---- esphome/components/ufm01/ufm01.h | 135 +++-- esphome/core/defines.h | 3 + tests/components/ufm01/__init__.py | 12 + tests/components/ufm01/benchmark.yaml | 29 + tests/components/ufm01/common.h | 214 ++++++- tests/components/ufm01/common.yaml | 14 + tests/components/ufm01/test.esp32-idf.yaml | 3 +- tests/components/ufm01/test.esp8266-ard.yaml | 3 +- tests/components/ufm01/test.rp2040-ard.yaml | 3 +- .../ufm01/ufm01_clear_action_test.cpp | 113 ++++ .../ufm01/ufm01_diagnostics_test.cpp | 173 ++++++ tests/components/ufm01/ufm01_frame_test.cpp | 26 +- tests/components/ufm01/ufm01_startup_test.cpp | 127 +++- tests/components/ufm01/validate.host.yaml | 16 + .../integration/fixtures/uart_mock_ufm01.yaml | 153 +++++ tests/integration/test_uart_mock_ufm01.py | 399 +++++++++++++ 19 files changed, 1858 insertions(+), 199 deletions(-) create mode 100644 esphome/components/ufm01/text_sensor.py create mode 100644 tests/components/ufm01/__init__.py create mode 100644 tests/components/ufm01/benchmark.yaml create mode 100644 tests/components/ufm01/ufm01_clear_action_test.cpp create mode 100644 tests/components/ufm01/ufm01_diagnostics_test.cpp create mode 100644 tests/components/ufm01/validate.host.yaml create mode 100644 tests/integration/fixtures/uart_mock_ufm01.yaml create mode 100644 tests/integration/test_uart_mock_ufm01.py diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index e1fcef107d5..1846958e591 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass + +from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] @@ -16,6 +20,23 @@ UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component CONF_UFM01_ID = "ufm01_id" + +@dataclass +class UFM01Data: + clear_action_used: bool = False + + +def _get_data() -> UFM01Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UFM01Data() + return CORE.data[DOMAIN] + + +def _note_clear_action(config: ConfigType) -> ConfigType: + _get_data().clear_action_used = True + return config + + CONFIG_SCHEMA = ( cv.Schema( { @@ -26,6 +47,13 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(UFM01Component), + }, + _note_clear_action, +) + FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "ufm01", require_tx=True, @@ -41,3 +69,12 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) + if _get_data().clear_action_used: + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + +automation.register_apply_action( + "ufm01.clear_accumulated_flow", + CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA, + automation.ApplyCall("request_clear_accumulated_flow()"), +) diff --git a/esphome/components/ufm01/text_sensor.py b/esphome/components/ufm01/text_sensor.py new file mode 100644 index 00000000000..dcb5d3294c2 --- /dev/null +++ b/esphome/components/ufm01/text_sensor.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP, ICON_FINGERPRINT +from esphome.types import ConfigType + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_METER_ID = "meter_id" +CONF_SOFTWARE_VERSION = "software_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_METER_ID): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_FINGERPRINT + ), + cv.Optional(CONF_SOFTWARE_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_UFM01_ID]) + sensors = text_sensor.sub_text_sensors(config) + if await sensors(CONF_METER_ID, hub.set_meter_id_text_sensor) is not None: + cg.add_define("USE_UFM01_METER_ID") + if ( + await sensors(CONF_SOFTWARE_VERSION, hub.set_software_version_text_sensor) + is not None + ): + cg.add_define("USE_UFM01_SOFTWARE_VERSION") diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index c68842ab6c1..47ff2165f8c 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -1,8 +1,9 @@ #include "ufm01.h" -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include #include #include #include @@ -13,6 +14,7 @@ ESPHOME_LOG_TAG(TAG, "ufm01"); static constexpr uint8_t COMMAND_ACK = 0xE5; static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t CLEAR_QUEUE_TIMEOUT_MS = 15000; static constexpr uint32_t STARTUP_DELAY_MS = 2000; static constexpr uint32_t POST_RESET_DELAY_MS = 2000; static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; @@ -20,23 +22,40 @@ static constexpr uint32_t STARTUP_RETRY_MS = 3000; static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; static constexpr uint32_t ACTIVE_STALE_MS = 5000; static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t SOFTWARE_VERSION_READ_TIMEOUT_MS = 2000; +static constexpr uint32_t SOFTWARE_VERSION_RETRY_MS = 30000; +static constexpr uint8_t SOFTWARE_VERSION_MAX_ATTEMPTS = 3; static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; +// After this many consecutive passive poll failures, re-run the reset/startup sequence +static constexpr uint8_t PASSIVE_FAIL_ESCALATE_COUNT = 8; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr std::array READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr std::array GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_DEVICE_ID_INDEX = 2; static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr size_t PASSIVE_WITH_ID_DEVICE_ID_INDEX = 2; +static constexpr uint8_t PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX = 22; +static constexpr uint8_t PASSIVE_WITH_ID_TEMP_FLAG_INDEX = 31; +static constexpr uint8_t PASSIVE_WITH_ID_ST1_INDEX = 35; +static constexpr size_t PASSIVE_WITH_ID_CHECKSUM_INDEX = 37; +static constexpr size_t PASSIVE_WITH_ID_STOP_INDEX = 38; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; @@ -58,8 +77,40 @@ static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; +static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "software version read reuses passive_frame_"); +static constexpr size_t SOFTWARE_VERSION_CHECKSUM_INDEX = 5; +static constexpr size_t SOFTWARE_VERSION_STOP_INDEX = 6; + static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } +static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { + bool all_zero = true; + for (size_t i = 0; i < len; ++i) { + const uint8_t lo = data[i] & 0x0F; + const uint8_t hi = data[i] >> 4; + if (lo > 9 || hi > 9) + return false; + const size_t pos = (len - 1 - i) * 2; + if (hi != 0) + all_zero = false; + out[pos] = static_cast('0' + hi); + if (lo != 0) + all_zero = false; + out[pos + 1] = static_cast('0' + lo); + } + out[len * 2] = '\0'; + return !all_zero; +} + +static bool validate_software_version_response(const uint8_t data[SOFTWARE_VERSION_RESPONSE_SIZE]) { + if (data[0] != COMMAND_ACK || data[SOFTWARE_VERSION_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 1; i < SOFTWARE_VERSION_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[SOFTWARE_VERSION_CHECKSUM_INDEX] == sum; +} + static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { if (data[index] == expected) return true; @@ -84,25 +135,42 @@ static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { return data[21] == sum_bytes(data, 21); } -static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { +#ifdef USE_UFM01_METER_ID +static bool validate_passive_with_id_frame(const uint8_t data[PASSIVE_FRAME_WITH_ID_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2_WITH_ID || + data[PASSIVE_WITH_ID_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < PASSIVE_WITH_ID_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[PASSIVE_WITH_ID_CHECKSUM_INDEX] == sum; +} +#endif + +// Start index of each run in a passive reply; each run maps to the matching run of the active frame +struct PassiveLayout { + uint8_t acc_flow; // flag + 6 bytes, active 8..14 + uint8_t instant_flow; // flag + 5 bytes, active 15..20 + uint8_t temperature; // flag + 3 bytes, active 24..27 + uint8_t status; // ST1, ST2, checksum, active 28..30 +}; +static constexpr PassiveLayout PASSIVE_NO_ID_LAYOUT{2, 9, 15, 19}; +#ifdef USE_UFM01_METER_ID +static constexpr PassiveLayout PASSIVE_WITH_ID_LAYOUT{PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_TEMP_FLAG_INDEX, PASSIVE_WITH_ID_ST1_INDEX}; +#endif + +static void passive_to_active_frame(const uint8_t *passive, const PassiveLayout &layout, uint8_t active[FRAME_SIZE]) { std::memset(active, 0, FRAME_SIZE); active[0] = FRAME_START_BYTE_1; active[1] = FRAME_START_BYTE_2; - active[7] = 0x01; - active[8] = passive[2]; - for (size_t i = 0; i < 6; ++i) - active[9 + i] = passive[3 + i]; - active[15] = passive[9]; - for (size_t i = 0; i < 5; ++i) - active[16 + i] = passive[10 + i]; - active[21] = FRAME_FLAG_RESERVED_SECTION; - active[24] = passive[15]; - for (size_t i = 0; i < 3; ++i) - active[25 + i] = passive[16 + i]; - active[28] = passive[19]; - active[29] = passive[20]; - active[30] = passive[21]; - active[31] = FRAME_STOP_BYTE; + std::copy_n(&passive[layout.acc_flow], 7, &active[FRAME_ACC_FLOW_FLAG_INDEX]); + std::copy_n(&passive[layout.instant_flow], 6, &active[FRAME_INDEX_INSTANT_FLOW_FLAG]); + active[FRAME_INDEX_RESERVED_SECTION] = FRAME_FLAG_RESERVED_SECTION; + std::copy_n(&passive[layout.temperature], 4, &active[FRAME_INDEX_TEMP_FLAG]); + std::copy_n(&passive[layout.status], 3, &active[FRAME_ST1_INDEX]); + active[FRAME_STOP_INDEX] = FRAME_STOP_BYTE; } static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { @@ -118,8 +186,10 @@ static float read_flow(const uint8_t data[FRAME_SIZE]) { M3_PER_L; } +static constexpr size_t LOG_HEX_MAX_SIZE = std::max(FRAME_SIZE, PASSIVE_FRAME_MAX_SIZE); + static void log_hex(const uint8_t *data, size_t len) { - char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + char hex_buf[format_hex_pretty_size(LOG_HEX_MAX_SIZE)]; ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } @@ -157,7 +227,6 @@ void UFM01Component::send_command_no_wait_(const std::array &command this->flush(); } -// Drains whatever is currently in the RX buffer, looking for a command ACK. bool UFM01Component::consume_ack_() { while (this->available()) { uint8_t byte; @@ -170,29 +239,65 @@ bool UFM01Component::consume_ack_() { return false; } -bool UFM01Component::send_command_(const std::array &command) { - this->send_command_no_wait_(command); - const uint32_t start = millis(); - while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->consume_ack_()) - return true; - delay(1); - } - return false; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +bool UFM01Component::can_start_clear_action_() const { + return +#ifdef USE_UFM01_SOFTWARE_VERSION + !this->software_version_read_pending_ && +#endif + (this->operating_mode_ == OperatingMode::ACTIVE_STREAM || + (this->operating_mode_ == OperatingMode::PASSIVE_POLL && !this->passive_read_pending_)); } -bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } +void UFM01Component::request_clear_accumulated_flow() { + if (this->clear_pending_) { + ESP_LOGD(TAG, "Clear accumulated flow already pending"); + return; + } + this->clear_pending_ = true; + this->pending_clear_sent_ = false; + this->pending_clear_start_ms_ = App.get_loop_component_start_time(); +} -bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } +void UFM01Component::finish_pending_clear_() { + this->clear_pending_ = false; + this->pending_clear_sent_ = false; +} -bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } +void UFM01Component::loop_pending_clear_action_(uint32_t now) { + if (!this->clear_pending_) + return; + if (!this->pending_clear_sent_) { + if (!this->can_start_clear_action_()) { + if (now - this->pending_clear_start_ms_ < CLEAR_QUEUE_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow timed out waiting for an idle UART"); + this->finish_pending_clear_(); + return; + } + this->pending_clear_start_ms_ = now; + this->pending_clear_sent_ = true; + this->send_command_no_wait_(CLEAR_ACCUMULATED_FLOW); + return; + } + if (this->consume_ack_()) { + ESP_LOGI(TAG, "Clear accumulated flow acknowledged"); + this->finish_pending_clear_(); + return; + } + if (now - this->pending_clear_start_ms_ < COMMAND_ACK_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow not acknowledged"); + this->finish_pending_clear_(); +} +#endif // USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); this->startup_wait_ms_ = STARTUP_DELAY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, App.get_loop_component_start_time()); } void UFM01Component::dump_config() { @@ -208,9 +313,123 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif +#ifdef USE_UFM01_METER_ID + LOG_TEXT_SENSOR(" ", "Meter ID", this->meter_id_text_sensor_); +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + LOG_TEXT_SENSOR(" ", "Software Version", this->software_version_text_sensor_); +#endif } -void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { +// The binary sensors keep their last state while readings are stale; only flow and temperature go unknown +void UFM01Component::publish_stale_flow_and_temperature_() { +#ifdef USE_SENSOR + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); +#endif +} + +#ifdef USE_UFM01_METER_ID +void UFM01Component::publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]) { + if (this->meter_id_text_sensor_ == nullptr || this->meter_id_published_) + return; + + char meter_id_str[DEVICE_ID_STRING_LENGTH + 1]; + if (!bcd_to_digits(&data[FRAME_DEVICE_ID_INDEX], DEVICE_ID_LENGTH, meter_id_str)) { + if (!this->meter_id_invalid_logged_) { + ESP_LOGW(TAG, "Meter ID in frame is zero or not BCD, leaving the sensor unknown"); + this->meter_id_invalid_logged_ = true; + } + return; + } + + this->meter_id_text_sensor_->publish_state(meter_id_str); + this->meter_id_published_ = true; + ESP_LOGI(TAG, "UFM-01 meter ID: %s", meter_id_str); +} +#endif + +#ifdef USE_UFM01_SOFTWARE_VERSION +void UFM01Component::start_software_version_read_(uint32_t now) { + this->send_command_no_wait_(GET_SOFTWARE_VERSION); + this->passive_index_ = 0; + this->passive_start_ms_ = now; + this->software_version_read_pending_ = true; +} + +ReadResult UFM01Component::continue_software_version_read_(uint32_t now) { + while (this->available() && this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + // The tail of an in-flight active-stream frame can arrive before the reply + if (this->passive_index_ == 0 && byte != COMMAND_ACK) + continue; + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + if (now - this->passive_start_ms_ < SOFTWARE_VERSION_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "software version read timeout (%u/%u bytes)", this->passive_index_, + static_cast(SOFTWARE_VERSION_RESPONSE_SIZE)); + return ReadResult::READ_RESULT_FAILURE; + } + + if (!validate_software_version_response(this->passive_frame_)) { + log_hex(this->passive_frame_, SOFTWARE_VERSION_RESPONSE_SIZE); + ESP_LOGW(TAG, "invalid software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_) { + char version_str[SOFTWARE_VERSION_STRING_LENGTH + 1]; + if (!bcd_to_digits(&this->passive_frame_[1], SOFTWARE_VERSION_LENGTH, version_str)) { + ESP_LOGW(TAG, "invalid BCD data in software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + this->software_version_text_sensor_->publish_state(version_str); + this->software_version_published_ = true; + ESP_LOGI(TAG, "UFM-01 software version: %s", version_str); + } + + return ReadResult::READ_RESULT_SUCCESS; +} + +void UFM01Component::finish_software_version_read_(ReadResult result, uint32_t now) { + this->software_version_read_pending_ = false; + this->last_software_version_attempt_ms_ = now; + if (result == ReadResult::READ_RESULT_FAILURE && + ++this->software_version_failures_ == SOFTWARE_VERSION_MAX_ATTEMPTS) { + ESP_LOGW(TAG, "Software version not read after %u attempts, giving up", SOFTWARE_VERSION_MAX_ATTEMPTS); + } +} + +// Returns true while a software version read owns the UART, so the caller skips its own work +bool UFM01Component::service_software_version_(uint32_t now) { + if (this->software_version_read_pending_) { + const ReadResult result = this->continue_software_version_read_(now); + if (result != ReadResult::READ_RESULT_PENDING) + this->finish_software_version_read_(result, now); + return true; + } + if (this->software_version_text_sensor_ == nullptr || this->software_version_published_ || + this->software_version_failures_ >= SOFTWARE_VERSION_MAX_ATTEMPTS || this->passive_read_pending_) + return false; + if (this->last_software_version_attempt_ms_ != 0 && + now - this->last_software_version_attempt_ms_ < SOFTWARE_VERSION_RETRY_MS) + return false; + this->start_software_version_read_(now); + return true; +} +#endif // USE_UFM01_SOFTWARE_VERSION + +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) { +#ifdef USE_UFM01_METER_ID + this->publish_meter_id_from_frame_(data); +#endif bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -229,10 +448,7 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); if (empty_tube) { - if (this->flow_sensor_ != nullptr) - this->flow_sensor_->publish_state(NAN); - if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(NAN); + this->publish_stale_flow_and_temperature_(); } else { if (this->flow_sensor_ != nullptr) this->flow_sensor_->publish_state(read_flow(data)); @@ -240,12 +456,12 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif - this->last_valid_frame_ms_ = millis(); + this->last_valid_frame_ms_ = now; this->status_clear_warning(); this->status_clear_error(); } -bool UFM01Component::process_active_stream_() { +bool UFM01Component::process_active_stream_(uint32_t now) { bool got_valid_frame = false; while (this->available()) { @@ -264,7 +480,7 @@ bool UFM01Component::process_active_stream_() { continue; if (validate_active_frame(this->data_)) { - this->on_active_frame_(this->data_); + this->on_active_frame_(this->data_, now); this->read_index_ = 0; got_valid_frame = true; continue; @@ -287,33 +503,89 @@ bool UFM01Component::process_active_stream_() { return got_valid_frame; } -void UFM01Component::set_startup_phase_(StartupPhase phase) { - this->startup_phase_ = phase; - this->phase_start_ms_ = millis(); -} - -void UFM01Component::enter_active_stream_(const char *reason) { - ESP_LOGI(TAG, "UFM-01 active stream %s", reason); - this->operating_mode_ = OperatingMode::ACTIVE_STREAM; +void UFM01Component::set_mode_(OperatingMode mode, uint32_t now) { + this->operating_mode_ = mode; + this->phase_start_ms_ = now; this->passive_read_pending_ = false; +#ifdef USE_UFM01_SOFTWARE_VERSION + this->software_version_read_pending_ = false; +#endif + this->consecutive_passive_failures_ = 0; } -void UFM01Component::start_passive_read_() { - this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); +void UFM01Component::set_startup_phase_(StartupPhase phase, uint32_t now) { + this->startup_phase_ = phase; + this->phase_start_ms_ = now; +} + +void UFM01Component::send_reset_(const LogString *status, uint32_t now) { + this->status_set_warning(status); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); +} + +void UFM01Component::enter_active_stream_(const LogString *reason, uint32_t now) { + ESP_LOGI(TAG, "UFM-01 active stream %s", LOG_STR_ARG(reason)); + this->set_mode_(OperatingMode::ACTIVE_STREAM, now); +} + +void UFM01Component::enter_passive_from_stale_(uint32_t now) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->publish_stale_flow_and_temperature_(); + // Flushes leftover stream bytes first; the ACK is not awaited, the next command's flush discards it + this->send_command_no_wait_(PASSIVE_MODE); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; + this->status_set_warning(LOG_STR("UFM-01 passive poll")); +} + +void UFM01Component::restart_startup_(const LogString *reason, uint32_t now) { + ESP_LOGW(TAG, "%s, re-initializing UFM-01", LOG_STR_ARG(reason)); + this->publish_stale_flow_and_temperature_(); + this->set_mode_(OperatingMode::STARTUP, now); + this->send_reset_(LOG_STR("re-initializing UFM-01"), now); +} + +void UFM01Component::note_passive_poll_result_(bool success, uint32_t now) { + if (success) { + this->consecutive_passive_failures_ = 0; + return; + } + this->status_set_warning(LOG_STR("UFM-01 passive poll failed")); + if (++this->consecutive_passive_failures_ < PASSIVE_FAIL_ESCALATE_COUNT) + return; + this->restart_startup_(LOG_STR("Passive poll failed repeatedly"), now); +} + +void UFM01Component::start_passive_read_(uint32_t now) { +#ifdef USE_UFM01_METER_ID + if (this->meter_id_text_sensor_ != nullptr && !this->meter_id_published_) { + this->passive_expected_size_ = PASSIVE_FRAME_WITH_ID_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2_WITH_ID; + this->send_command_no_wait_(READ_SENSOR_DATA_WITH_ID); + } else +#endif + { + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + } this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_start_ms_ = now; } -// Accumulates the reply to a passive read request across loop iterations. -PassiveReadResult UFM01Component::continue_passive_read_() { - while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { +ReadResult UFM01Component::continue_passive_read_(uint32_t now) { + const uint8_t expected_size = this->passive_expected_size_; + + while (this->available() && this->passive_index_ < expected_size) { uint8_t byte; if (!this->read_byte(&byte)) break; if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) continue; - if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + if (this->passive_index_ == 1 && byte != this->passive_start_byte_2_) { // The mismatched byte may itself be the start of the real frame this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; continue; @@ -321,57 +593,69 @@ PassiveReadResult UFM01Component::continue_passive_read_() { this->passive_frame_[this->passive_index_++] = byte; } - if (this->passive_index_ < PASSIVE_FRAME_SIZE) { - if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) - return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; - ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; - } - - if (!validate_passive_frame(this->passive_frame_)) { - log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); - ESP_LOGW(TAG, "invalid passive frame"); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; + if (this->passive_index_ < expected_size) { + if (now - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "passive read timeout (%u/%u bytes)", this->passive_index_, expected_size); + return ReadResult::READ_RESULT_FAILURE; } uint8_t active_frame[FRAME_SIZE]; - passive_no_id_to_active_frame(this->passive_frame_, active_frame); - this->on_active_frame_(active_frame); - return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; +#ifdef USE_UFM01_METER_ID + if (expected_size == PASSIVE_FRAME_WITH_ID_SIZE) { + if (!validate_passive_with_id_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_WITH_ID_SIZE); + ESP_LOGW(TAG, "invalid passive frame with ID"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_WITH_ID_LAYOUT, active_frame); + // Device ID and the byte after it + std::copy_n(&this->passive_frame_[PASSIVE_WITH_ID_DEVICE_ID_INDEX], DEVICE_ID_LENGTH + 1, + &active_frame[FRAME_DEVICE_ID_INDEX]); + } else +#endif + { + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_NO_ID_LAYOUT, active_frame); + active_frame[FRAME_DEVICE_ID_INDEX + DEVICE_ID_LENGTH] = 0x01; + } + this->on_active_frame_(active_frame, now); + return ReadResult::READ_RESULT_SUCCESS; } -void UFM01Component::loop_startup_() { - const uint32_t elapsed = millis() - this->phase_start_ms_; +void UFM01Component::loop_startup_(uint32_t now) { + const uint32_t elapsed = now - this->phase_start_ms_; switch (this->startup_phase_) { case StartupPhase::WAIT: // Pick up an already-streaming device without resetting it - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < this->startup_wait_ms_) return; ESP_LOGD(TAG, "Running startup sequence"); - this->status_set_warning("initializing UFM-01"); - this->reset_retried_ = false; - this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->send_reset_(LOG_STR("initializing UFM-01"), now); return; case StartupPhase::RESET_WAIT_ACK: if (this->consume_ack_()) { - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); return; } if (elapsed < COMMAND_ACK_TIMEOUT_MS) return; if (!this->reset_retried_) { ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); - this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT, now); } else { ESP_LOGW(TAG, "Reset failed during startup"); - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); } return; @@ -380,91 +664,138 @@ void UFM01Component::loop_startup_() { return; this->reset_retried_ = true; this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); return; case StartupPhase::POST_RESET_WAIT: if (elapsed < POST_RESET_DELAY_MS) return; +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_ && + this->software_version_failures_ < SOFTWARE_VERSION_MAX_ATTEMPTS) { + this->start_software_version_read_(now); + this->set_startup_phase_(StartupPhase::SOFTWARE_VERSION_WAIT_REPLY, now); + return; + } +#endif this->send_command_no_wait_(ACTIVE_MODE); - this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); return; +#ifdef USE_UFM01_SOFTWARE_VERSION + case StartupPhase::SOFTWARE_VERSION_WAIT_REPLY: { + const ReadResult result = this->continue_software_version_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) + return; + if (result == ReadResult::READ_RESULT_SUCCESS) { + ESP_LOGD(TAG, "Software version read during startup"); + } else { + ESP_LOGW(TAG, "Software version read failed during startup, continuing"); + } + this->finish_software_version_read_(result, now); + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); + return; + } +#endif + case StartupPhase::ACTIVE_WAIT_FRAME: // The command ACK (0xE5) is consumed by the frame parser as noise - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) return; - this->start_passive_read_(); - this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + this->send_command_no_wait_(PASSIVE_MODE); + this->set_startup_phase_(StartupPhase::SET_PASSIVE_WAIT_ACK, now); + return; + + case StartupPhase::SET_PASSIVE_WAIT_ACK: + if (this->consume_ack_()) { + ESP_LOGD(TAG, "Passive mode acknowledged during startup"); + } else if (elapsed < COMMAND_ACK_TIMEOUT_MS) { + return; + } else { + ESP_LOGW(TAG, "SET_PASSIVE_MODE not acknowledged during startup, continuing"); + } + this->start_passive_read_(now); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY, now); return; case StartupPhase::PASSIVE_WAIT_REPLY: - switch (this->continue_passive_read_()) { - case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: + switch (this->continue_passive_read_(now)) { + case ReadResult::READ_RESULT_PENDING: return; - case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: + case ReadResult::READ_RESULT_SUCCESS: ESP_LOGI(TAG, "UFM-01 using passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = millis(); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; return; - case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: + case ReadResult::READ_RESULT_FAILURE: ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); this->startup_wait_ms_ = STARTUP_RETRY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, now); return; } } } -void UFM01Component::loop_active_stream_() { - this->process_active_stream_(); - if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { - ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = 0; - this->status_set_warning("UFM-01 passive poll"); - } +void UFM01Component::loop_active_stream_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + + this->process_active_stream_(now); + + if (this->last_valid_frame_ms_ != 0 && now - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) + this->enter_passive_from_stale_(now); } -void UFM01Component::loop_passive_poll_() { +void UFM01Component::loop_passive_poll_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + if (this->passive_read_pending_) { - const PassiveReadResult result = this->continue_passive_read_(); - if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) + const ReadResult result = this->continue_passive_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) return; this->passive_read_pending_ = false; - if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) - this->status_set_warning("UFM-01 passive poll failed"); + this->note_passive_poll_result_(result == ReadResult::READ_RESULT_SUCCESS, now); return; } - if (this->process_active_stream_()) { - this->enter_active_stream_("resumed"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("resumed"), now); return; } - if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { - this->last_poll_ms_ = millis(); - this->start_passive_read_(); + if (now - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = now; + this->start_passive_read_(now); this->passive_read_pending_ = true; } } void UFM01Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + this->loop_pending_clear_action_(now); + if (this->clear_pending_ && this->pending_clear_sent_) + return; +#endif switch (this->operating_mode_) { case OperatingMode::STARTUP: - this->loop_startup_(); + this->loop_startup_(now); return; case OperatingMode::ACTIVE_STREAM: - this->loop_active_stream_(); + this->loop_active_stream_(now); return; case OperatingMode::PASSIVE_POLL: - this->loop_passive_poll_(); + this->loop_passive_poll_(now); return; } } diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index 0a39dcc9afd..cf73daa5fa7 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -8,6 +8,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include "esphome/components/uart/uart.h" #include @@ -23,6 +26,17 @@ class TestableUFM01; static constexpr size_t FRAME_SIZE = 32; static constexpr size_t PASSIVE_FRAME_SIZE = 23; +static constexpr size_t PASSIVE_FRAME_WITH_ID_SIZE = 39; +#ifdef USE_UFM01_METER_ID +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_WITH_ID_SIZE; +#else +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_SIZE; +#endif +static constexpr size_t SOFTWARE_VERSION_RESPONSE_SIZE = 7; +static constexpr size_t DEVICE_ID_LENGTH = 5; +static constexpr size_t DEVICE_ID_STRING_LENGTH = 10; +static constexpr size_t SOFTWARE_VERSION_LENGTH = 4; +static constexpr size_t SOFTWARE_VERSION_STRING_LENGTH = 8; enum class OperatingMode : uint8_t { STARTUP = 0, @@ -31,18 +45,22 @@ enum class OperatingMode : uint8_t { }; enum class StartupPhase : uint8_t { - WAIT = 0, - RESET_WAIT_ACK = 1, - RESET_RETRY_WAIT = 2, - POST_RESET_WAIT = 3, - ACTIVE_WAIT_FRAME = 4, - PASSIVE_WAIT_REPLY = 5, + WAIT, + RESET_WAIT_ACK, + RESET_RETRY_WAIT, + POST_RESET_WAIT, +#ifdef USE_UFM01_SOFTWARE_VERSION + SOFTWARE_VERSION_WAIT_REPLY, +#endif + ACTIVE_WAIT_FRAME, + SET_PASSIVE_WAIT_ACK, + PASSIVE_WAIT_REPLY, }; -enum class PassiveReadResult : uint8_t { - PASSIVE_READ_RESULT_PENDING = 0, - PASSIVE_READ_RESULT_SUCCESS = 1, - PASSIVE_READ_RESULT_FAILURE = 2, +enum class ReadResult : uint8_t { + READ_RESULT_PENDING = 0, + READ_RESULT_SUCCESS = 1, + READ_RESULT_FAILURE = 2, }; class UFM01Component : public uart::UARTDevice, public Component { @@ -59,6 +77,13 @@ class UFM01Component : public uart::UARTDevice, public Component { SUB_BINARY_SENSOR(flow_rate_out_of_range) #endif +#ifdef USE_UFM01_METER_ID + SUB_TEXT_SENSOR(meter_id) +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + SUB_TEXT_SENSOR(software_version) +#endif + public: void setup() override; @@ -66,43 +91,85 @@ class UFM01Component : public uart::UARTDevice, public Component { void loop() override; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + /// Queue a clear of the accumulated flow; sent once the UART is idle, a request while one is pending joins it + void request_clear_accumulated_flow(); +#endif + float get_setup_priority() const override; - protected: - bool clear_accumulated_flow_(); - bool set_active_mode_(); - bool reset_device_(); - private: - bool send_command_(const std::array &command); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool can_start_clear_action_() const; + void loop_pending_clear_action_(uint32_t now); + void finish_pending_clear_(); +#endif void send_command_no_wait_(const std::array &command); bool consume_ack_(); void flush_rx_(); - bool process_active_stream_(); - void on_active_frame_(uint8_t data[FRAME_SIZE]); + bool process_active_stream_(uint32_t now); + void on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now); +#ifdef USE_UFM01_METER_ID + void publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]); +#endif + void publish_stale_flow_and_temperature_(); +#ifdef USE_UFM01_SOFTWARE_VERSION + void start_software_version_read_(uint32_t now); + ReadResult continue_software_version_read_(uint32_t now); + void finish_software_version_read_(ReadResult result, uint32_t now); + bool service_software_version_(uint32_t now); +#endif - void loop_startup_(); - void loop_active_stream_(); - void loop_passive_poll_(); - void set_startup_phase_(StartupPhase phase); - void enter_active_stream_(const char *reason); - void start_passive_read_(); - PassiveReadResult continue_passive_read_(); + void loop_startup_(uint32_t now); + void loop_active_stream_(uint32_t now); + void loop_passive_poll_(uint32_t now); + void set_mode_(OperatingMode mode, uint32_t now); + void set_startup_phase_(StartupPhase phase, uint32_t now); + void send_reset_(const LogString *status, uint32_t now); + void enter_active_stream_(const LogString *reason, uint32_t now); + void enter_passive_from_stale_(uint32_t now); + void restart_startup_(const LogString *reason, uint32_t now); + void start_passive_read_(uint32_t now); + ReadResult continue_passive_read_(uint32_t now); + void note_passive_poll_result_(bool success, uint32_t now); + + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + // Shared by the passive read and the software version read, which never overlap + uint32_t passive_start_ms_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + uint32_t last_software_version_attempt_ms_{0}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + uint32_t pending_clear_start_ms_{0}; +#endif + int32_t read_index_{0}; OperatingMode operating_mode_{OperatingMode::STARTUP}; StartupPhase startup_phase_{StartupPhase::WAIT}; - uint32_t phase_start_ms_{0}; - uint32_t startup_wait_ms_{0}; bool reset_retried_{false}; - uint32_t last_valid_frame_ms_{0}; - uint32_t last_poll_ms_{0}; - bool passive_read_pending_{false}; - uint32_t passive_start_ms_{0}; - size_t passive_index_{0}; - uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; + uint8_t consecutive_passive_failures_{0}; + uint8_t passive_index_{0}; + uint8_t passive_expected_size_{PASSIVE_FRAME_SIZE}; + uint8_t passive_start_byte_2_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + bool software_version_published_{false}; + bool software_version_read_pending_{false}; + uint8_t software_version_failures_{0}; +#endif +#ifdef USE_UFM01_METER_ID + bool meter_id_published_{false}; + bool meter_id_invalid_logged_{false}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool clear_pending_{false}; + bool pending_clear_sent_{false}; +#endif - int32_t read_index_ = 0; + uint8_t passive_frame_[PASSIVE_FRAME_MAX_SIZE]; uint8_t data_[FRAME_SIZE]; friend class testing::TestableUFM01; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 552221dd5eb..730e17fb53a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -248,6 +248,9 @@ #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX +#define USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +#define USE_UFM01_METER_ID +#define USE_UFM01_SOFTWARE_VERSION #define USE_UPDATE #define USE_UPTIME_TIMESTAMP #define USE_VALVE diff --git a/tests/components/ufm01/__init__.py b/tests/components/ufm01/__init__.py new file mode 100644 index 00000000000..19febacc61a --- /dev/null +++ b/tests/components/ufm01/__init__.py @@ -0,0 +1,12 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The optional features are define-gated; emit them so the tests cover those paths + async def to_code_testing(config): + cg.add_define("USE_UFM01_METER_ID") + cg.add_define("USE_UFM01_SOFTWARE_VERSION") + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + manifest.to_code = to_code_testing diff --git a/tests/components/ufm01/benchmark.yaml b/tests/components/ufm01/benchmark.yaml new file mode 100644 index 00000000000..f78cdfe0334 --- /dev/null +++ b/tests/components/ufm01/benchmark.yaml @@ -0,0 +1,29 @@ +# Defines USE_SENSOR; the tests attach their own instances +sensor: + - platform: template + id: bench_flow + name: "Flow" + - platform: template + id: bench_temperature + name: "Temperature" + +# Defines USE_TEXT_SENSOR +text_sensor: + - platform: template + id: bench_meter_id + name: "Meter ID" + - platform: template + id: bench_software_version + name: "Software version" + +# One instance so __init__.py's to_code runs and emits the feature defines +uart: + - id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null + +ufm01: + - id: ufm01_bench + uart_id: uart_bus diff --git a/tests/components/ufm01/common.h b/tests/components/ufm01/common.h index 1582358700c..e04e09b765c 100644 --- a/tests/components/ufm01/common.h +++ b/tests/components/ufm01/common.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -9,17 +10,36 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/ufm01/ufm01.h" +#include "esphome/core/application.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::ufm01::testing { static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; static constexpr uint8_t COMMAND_ACK = 0xE5; +// Matches PASSIVE_FAIL_ESCALATE_COUNT in ufm01.cpp +static constexpr uint8_t ESCALATE_COUNT = 8; + +using Command = std::array; +static constexpr Command ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr Command PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; +static constexpr Command CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr Command RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; +static constexpr Command READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr Command READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr Command GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // UART mock with a byte queue for read-side simulation. class QueuedMockUART : public uart::UARTComponent { @@ -31,6 +51,10 @@ class QueuedMockUART : public uart::UARTComponent { this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); } + template void enqueue(const std::array &data) { + this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); + } + void enqueue(std::initializer_list data) { for (uint8_t byte : data) this->rx_queue.push_back(byte); @@ -71,15 +95,37 @@ class TestableUFM01 : public UFM01Component { public: void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); } - bool process_active_stream() { return this->process_active_stream_(); } + static uint32_t now() { return App.get_loop_component_start_time(); } - PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); } + bool process_active_stream() { return this->process_active_stream_(now()); } + + ReadResult continue_passive_read() { return this->continue_passive_read_(now()); } bool consume_ack() { return this->consume_ack_(); } - void start_passive_read() { this->start_passive_read_(); } + void start_passive_read() { this->start_passive_read_(now()); } - void loop_startup() { this->loop_startup_(); } + void loop_startup() { this->loop_startup_(now()); } + + void loop_active_stream() { this->loop_active_stream_(now()); } + + void loop_passive_poll() { this->loop_passive_poll_(now()); } + + void loop_pending_clear_action() { this->loop_pending_clear_action_(now()); } + + bool clear_pending() const { return this->clear_pending_; } + + bool pending_clear_sent() const { return this->pending_clear_sent_; } + + bool meter_id_published() const { return this->meter_id_published_; } + + void start_software_version_read() { this->start_software_version_read_(now()); } + + ReadResult continue_software_version_read() { return this->continue_software_version_read_(now()); } + + bool software_version_published() const { return this->software_version_published_; } + + void set_last_software_version_attempt_ms(uint32_t ms) { this->last_software_version_attempt_ms_ = ms; } OperatingMode operating_mode() const { return this->operating_mode_; } @@ -87,20 +133,52 @@ class TestableUFM01 : public UFM01Component { int32_t read_index() const { return this->read_index_; } - size_t passive_index() const { return this->passive_index_; } + uint8_t passive_index() const { return this->passive_index_; } uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; } + uint8_t consecutive_passive_failures() const { return this->consecutive_passive_failures_; } + + void set_operating_mode(OperatingMode mode) { this->operating_mode_ = mode; } + + // Expects a reply without the meter ID, as start_passive_read_() picks when no meter ID sensor is set void prepare_passive_read() { this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->passive_start_ms_ = now(); + } + + void begin_pending_passive_read() { + this->prepare_passive_read(); + this->passive_read_pending_ = true; } void init_wait_phase() { this->operating_mode_ = OperatingMode::STARTUP; this->startup_phase_ = StartupPhase::WAIT; this->startup_wait_ms_ = 60000; - this->phase_start_ms_ = millis(); + this->phase_start_ms_ = now(); + } + + void begin_active_stream() { + this->operating_mode_ = OperatingMode::ACTIVE_STREAM; + this->last_valid_frame_ms_ = now(); + this->passive_read_pending_ = false; + this->read_index_ = 0; + } + + void begin_active_wait_frame() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::ACTIVE_WAIT_FRAME; + this->phase_start_ms_ = now(); + } + + void begin_post_reset_wait() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::POST_RESET_WAIT; + this->phase_start_ms_ = now(); + this->software_version_published_ = false; } void reset_state() { @@ -108,24 +186,59 @@ class TestableUFM01 : public UFM01Component { this->last_valid_frame_ms_ = 0; this->passive_index_ = 0; this->passive_read_pending_ = false; + this->operating_mode_ = OperatingMode::STARTUP; + this->consecutive_passive_failures_ = 0; + this->meter_id_published_ = false; + this->software_version_published_ = false; + this->software_version_read_pending_ = false; + this->software_version_failures_ = 0; + this->meter_id_invalid_logged_ = false; + this->last_software_version_attempt_ms_ = 0; + this->startup_phase_ = StartupPhase::WAIT; + this->clear_pending_ = false; + this->pending_clear_sent_ = false; } }; +template inline uint8_t checksum(const std::array &data, size_t begin, size_t end) { + uint8_t sum = 0; + for (size_t i = begin; i < end; ++i) + sum += data[i]; + return sum; +} + inline std::array make_active_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; frame[1] = FRAME_START_BYTE_2; + // Datasheet example device ID 2307140001 + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; frame[15] = FRAME_FLAG_INSTANT_FLOW; frame[21] = FRAME_FLAG_RESERVED_SECTION; frame[24] = FRAME_FLAG_TEMP; frame[31] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 30; ++i) - sum += frame[i]; - frame[30] = sum; + frame[30] = checksum(frame, 0, 30); return frame; } +inline std::array make_software_version_response() { + // Datasheet example version 23380315 + std::array response{}; + response[0] = COMMAND_ACK; + response[1] = 0x15; + response[2] = 0x03; + response[3] = 0x38; + response[4] = 0x23; + response[5] = checksum(response, 1, 5); + response[6] = FRAME_STOP_BYTE; + return response; +} + inline std::array make_passive_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; @@ -133,22 +246,93 @@ inline std::array make_passive_frame() { frame[9] = FRAME_FLAG_INSTANT_FLOW; frame[15] = FRAME_FLAG_TEMP; frame[22] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += frame[i]; - frame[21] = sum; + frame[21] = checksum(frame, 0, 21); + return frame; +} + +inline std::array make_passive_with_id_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = PASSIVE_START_BYTE_2_WITH_ID; + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; + frame[8] = 0x0A; + // Non-measurement bytes ensure a shifted instant-flow offset is detected. + for (size_t i = 17; i < 22; ++i) + frame[i] = 0x99; + frame[22] = FRAME_FLAG_INSTANT_FLOW; + frame[23] = 0x34; + frame[24] = 0x12; + frame[27] = 0x00; + frame[31] = FRAME_FLAG_TEMP; + frame[32] = 0x50; + frame[33] = 0x23; + frame[38] = FRAME_STOP_BYTE; + frame[37] = checksum(frame, 0, 37); return frame; } class UFM01Test : public ::testing::Test { protected: + // Non-zero so a frame received at the start time still counts as a valid timestamp + static constexpr uint32_t START_MS = 1000; + void SetUp() override { this->mock_uart_.clear_rx(); this->mock_uart_.written_data.clear(); this->ufm01_.set_mock_uart(&this->mock_uart_); this->ufm01_.reset_state(); + this->set_clock(START_MS); } + // Publishes the time the component reads as the loop clock + void set_clock(uint32_t ms) { + this->now_ = ms; + LoopBlockingGuard clock{nullptr, nullptr, ms}; + } + + void advance(uint32_t ms) { this->set_clock(this->now_ + ms); } + + void expect_written(const Command &command) { + EXPECT_EQ(this->mock_uart_.written_data, std::vector(command.begin(), command.end())); + } + + void fail_passive_poll() { + this->ufm01_.begin_pending_passive_read(); + this->advance(2000); + this->ufm01_.loop_passive_poll(); + } + + void make_active_stream_stale() { + this->ufm01_.begin_active_stream(); + this->advance(6000); + } + +#ifdef USE_SENSOR + void attach_flow_and_temperature_sensors() { + this->ufm01_.set_flow_sensor(&this->flow_sensor_); + this->ufm01_.set_temperature_sensor(&this->temperature_sensor_); + this->flow_sensor_.publish_state(1.23f); + this->temperature_sensor_.publish_state(20.5f); + } + + sensor::Sensor flow_sensor_; + sensor::Sensor temperature_sensor_; +#endif + + void attach_diagnostic_text_sensors() { + this->ufm01_.set_meter_id_text_sensor(&this->meter_id_text_sensor_); + this->ufm01_.set_software_version_text_sensor(&this->software_version_text_sensor_); + } + + text_sensor::TextSensor meter_id_text_sensor_; + text_sensor::TextSensor software_version_text_sensor_; + + uint32_t now_{0}; QueuedMockUART mock_uart_; TestableUFM01 ufm01_; }; diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml index c818dc29651..29bc05dfabd 100644 --- a/tests/components/ufm01/common.yaml +++ b/tests/components/ufm01/common.yaml @@ -28,3 +28,17 @@ binary_sensor: flow_rate_out_of_range: id: flow_rate_out_of_range name: "Flow rate out of range" + +text_sensor: + - platform: ufm01 + meter_id: + id: ufm01_meter_id + name: "Meter ID" + software_version: + id: ufm01_software_version + name: "Software version" + +script: + - id: ufm01_clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_component diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml index 34041cc2237..a2d4469798c 100644 --- a/tests/components/ufm01/test.esp32-idf.yaml +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml index 195f4b41b59..7826cdc44a0 100644 --- a/tests/components/ufm01/test.esp8266-ard.yaml +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml index 13b3284fe30..1483674ddcf 100644 --- a/tests/components/ufm01/test.rp2040-ard.yaml +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/ufm01_clear_action_test.cpp b/tests/components/ufm01/ufm01_clear_action_test.cpp new file mode 100644 index 00000000000..a1d10b1926d --- /dev/null +++ b/tests/components/ufm01/ufm01_clear_action_test.cpp @@ -0,0 +1,113 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ClearAccumulatedFlowSendsCommand) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); + EXPECT_TRUE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnAck) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.enqueue({COMMAND_ACK}); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnTimeout) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->advance(400); + this->ufm01_.loop_pending_clear_action(); + ASSERT_TRUE(this->ufm01_.clear_pending()); + + this->advance(200); + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, DuplicateClearRequestJoinsThePendingOne) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.written_data.clear(); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->ufm01_.loop_pending_clear_action(); + EXPECT_FALSE(this->ufm01_.clear_pending()); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingPassiveRead) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.loop_passive_poll(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingSoftwareVersionRead) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.start_software_version_read(); + this->mock_uart_.written_data.clear(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_software_version_response()); + this->ufm01_.loop_active_stream(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsDuringStartup) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_TRUE(this->ufm01_.clear_pending()); + EXPECT_FALSE(this->ufm01_.pending_clear_sent()); +} + +TEST_F(UFM01Test, ClearTimesOutWhileWaitingForIdle) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + this->advance(20000); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_diagnostics_test.cpp b/tests/components/ufm01/ufm01_diagnostics_test.cpp new file mode 100644 index 00000000000..5898b565633 --- /dev/null +++ b/tests/components/ufm01/ufm01_diagnostics_test.cpp @@ -0,0 +1,173 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ActiveFramePublishesMeterIdOnce) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + + this->mock_uart_.enqueue(frame); + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, PassiveFrameWithIdUsesDocumentedMeasurementOffsets) { + this->attach_diagnostic_text_sensors(); + this->attach_flow_and_temperature_sensors(); + this->ufm01_.start_passive_read(); + this->expect_written(READ_SENSOR_DATA_WITH_ID); + + this->mock_uart_.enqueue(make_passive_with_id_frame()); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + EXPECT_NEAR(this->flow_sensor_.get_state(), 0.01234f, 0.000001f); + EXPECT_NEAR(this->temperature_sensor_.get_state(), 23.5f, 0.001f); +} +#endif + +TEST_F(UFM01Test, InvalidPassiveFrameWithIdIsRejected) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_passive_read(); + auto frame = make_passive_with_id_frame(); + frame[37] ^= 0xFF; + this->mock_uart_.enqueue(frame); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.meter_id_published()); +} + +TEST_F(UFM01Test, SoftwareVersionResponseDecoded) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, SoftwareVersionReadSkipsLeftoverStreamBytes) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + // Tail of an active frame that was still on the wire when the command was sent + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin() + 20, frame.end())); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, InvalidSoftwareVersionBcdFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + auto response = make_software_version_response(); + response[2] = 0xFA; + response[5] = checksum(response, 1, 5); + this->mock_uart_.enqueue(response); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, SoftwareVersionReadTimeoutFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->advance(1900); + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_PENDING); + + this->advance(100); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, StartupReadsSoftwareVersionBeforeActiveMode) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.begin_post_reset_wait(); + this->advance(3000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SOFTWARE_VERSION_WAIT_REPLY); + this->expect_written(GET_SOFTWARE_VERSION); + + this->mock_uart_.enqueue(make_software_version_response()); + this->mock_uart_.written_data.clear(); + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::ACTIVE_WAIT_FRAME); + this->expect_written(ACTIVE_MODE); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, AlreadyActiveStartupRequestsSoftwareVersion) { + this->attach_diagnostic_text_sensors(); + this->mock_uart_.enqueue(make_active_frame()); + this->ufm01_.init_wait_phase(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamRetriesSoftwareVersionRead) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.set_last_software_version_attempt_ms(this->now_); + this->advance(29000); + this->ufm01_.loop_active_stream(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->advance(2000); + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamGivesUpSoftwareVersionAfterRepeatedFailures) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + for (int attempt = 0; attempt < 3; ++attempt) { + this->ufm01_.loop_active_stream(); + this->expect_written(GET_SOFTWARE_VERSION); + this->mock_uart_.written_data.clear(); + this->advance(2000); + this->ufm01_.loop_active_stream(); + this->advance(30000); + } + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, InvalidMeterIdLeavesSensorUnknown) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + frame[2] = 0xAB; + frame[30] = checksum(frame, 0, 30); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + + EXPECT_FALSE(this->ufm01_.meter_id_published()); + EXPECT_FALSE(this->meter_id_text_sensor_.has_state()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp index 3b1b148d504..8e659ce40df 100644 --- a/tests/components/ufm01/ufm01_frame_test.cpp +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -4,7 +4,7 @@ namespace esphome::ufm01::testing { TEST_F(UFM01Test, ValidActiveFrameAccepted) { auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -14,7 +14,7 @@ TEST_F(UFM01Test, ValidActiveFrameAccepted) { TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { this->mock_uart_.enqueue({0x00, 0xFF, 0xAA}); auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -23,7 +23,7 @@ TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { auto frame = make_active_frame(); frame[30] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_FALSE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -32,10 +32,10 @@ TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { auto frame = make_passive_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -43,29 +43,29 @@ TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { TEST_F(UFM01Test, InvalidPassiveChecksumFails) { auto frame = make_passive_frame(); frame[21] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); } TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({0x00, 0x01, 0x02}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { @@ -73,11 +73,11 @@ TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_PENDING); EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_startup_test.cpp b/tests/components/ufm01/ufm01_startup_test.cpp index 6b7feb0f1b0..c6d5a7f4ebf 100644 --- a/tests/components/ufm01/ufm01_startup_test.cpp +++ b/tests/components/ufm01/ufm01_startup_test.cpp @@ -1,14 +1,7 @@ #include "common.h" -#include "esphome/core/component.h" - namespace esphome::ufm01::testing { -TEST(UFM01SetupPriority, IsLate) { - TestableUFM01 ufm01; - EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE); -} - TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02}); @@ -19,8 +12,7 @@ TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); } TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { - auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(make_active_frame()); this->ufm01_.init_wait_phase(); this->ufm01_.loop_startup(); @@ -32,12 +24,117 @@ TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { TEST_F(UFM01Test, StartPassiveReadSendsCommand) { this->ufm01_.start_passive_read(); - ASSERT_EQ(this->mock_uart_.written_data.size(), 7u); - EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[2], 0x11); - EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B); - EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StartupActiveWaitTimeoutSendsSetPassiveMode) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SET_PASSIVE_WAIT_ACK); + this->expect_written(PASSIVE_MODE); +} + +TEST_F(UFM01Test, StartupSetPassiveWaitAckStartsPassiveRead) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + this->ufm01_.loop_startup(); + this->mock_uart_.enqueue({COMMAND_ACK}); + this->mock_uart_.written_data.clear(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::PASSIVE_WAIT_REPLY); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StaleActiveStreamSendsSetPassiveMode) { + // Leftover stream noise should be flushed before SET_PASSIVE_MODE + this->mock_uart_.enqueue({0x3C, 0x32, 0x00}); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + EXPECT_EQ(this->ufm01_.read_index(), 0); + this->expect_written(PASSIVE_MODE); + EXPECT_EQ(this->mock_uart_.available(), 0u); // RX flushed +} + +TEST_F(UFM01Test, StalePassivePollWaitsOneIntervalAndDiscardsLateAck) { + this->make_active_stream_stale(); + this->ufm01_.loop_active_stream(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + this->mock_uart_.written_data.clear(); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->advance(500); + this->ufm01_.loop_passive_poll(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + + this->advance(500); + this->ufm01_.loop_passive_poll(); + this->expect_written(READ_SENSOR_DATA_NO_ID); + EXPECT_EQ(this->mock_uart_.available(), 0u); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, StaleActiveStreamPublishesNanToFlowAndTemperature) { + this->attach_flow_and_temperature_sensors(); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresPublishNanBeforeReset) { + this->attach_flow_and_temperature_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT; ++i) + this->fail_passive_poll(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} +#endif + +TEST_F(UFM01Test, PassivePollSuccessClearsFailureCount) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (int i = 0; i < 3; ++i) + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.consecutive_passive_failures(), 3u); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.loop_passive_poll(); + + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresEscalateToReset) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT - 1; ++i) { + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL) << "i=" << static_cast(i); + } + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), ESCALATE_COUNT - 1); + + this->mock_uart_.written_data.clear(); + this->fail_passive_poll(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::STARTUP); + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::RESET_WAIT_ACK); + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + this->expect_written(RESET_DEVICE); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/validate.host.yaml b/tests/components/ufm01/validate.host.yaml new file mode 100644 index 00000000000..19c0739f0bd --- /dev/null +++ b/tests/components/ufm01/validate.host.yaml @@ -0,0 +1,16 @@ +esphome: + name: ufm01-validate + +host: + +logger: + +packages: + ufm01: !include common.yaml + +uart: + id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null diff --git a/tests/integration/fixtures/uart_mock_ufm01.yaml b/tests/integration/fixtures/uart_mock_ufm01.yaml new file mode 100644 index 00000000000..0f72225c089 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ufm01.yaml @@ -0,0 +1,153 @@ +esphome: + name: uart-mock-ufm01 + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates + actions: + - action: clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_dev + - lambda: |- + id(clear_count) += 1; + id(clear_completions).publish_state(id(clear_count)); +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ufm01's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 2400 + port: /dev/null + +globals: + # The simulated meter streams active frames only after SET_ACTIVE_MODE + - id: streaming + type: bool + initial_value: "false" + # Set by CLEAR_ACCUMULATED_FLOW; later frames report zero accumulated flow + - id: cleared + type: bool + initial_value: "false" + - id: clear_count + type: int + initial_value: "0" + +# Simulated UFM-01: every command is one 7 byte write +uart_mock: + id: mock_uart + baud_rate: 2400 + parity: EVEN + on_tx: + - then: + - lambda: |- + if (id(silent).state || data.size() != 7) { + return; + } + static const std::vector ACK = {0xE5}; + switch (data[3]) { + case 0x5D: // RESET_DEVICE + id(streaming) = false; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5C: // SET_ACTIVE_MODE (00) or SET_PASSIVE_MODE (01) + id(streaming) = data[4] == 0x00; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5A: // CLEAR_ACCUMULATED_FLOW + id(cleared) = true; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5B: // READ_SENSOR_DATA without meter ID + if (data[4] != 0x0F) { + break; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0x3C, 0x64, // start + 0x0A, 0x78, 0x56, 0x02, 0x00, 0x00, 0x00, // accumulated flow 25.678 L + 0x0B, 0x00, 0x67, 0x05, 0x00, 0x00, // flow 0.567 m3/h + 0x0D, 0x25, 0x19, 0x00, // temperature 19.25 C + 0x00, 0x00, // ST1, ST2 + 0x3C, 0x16, // checksum, stop + }); + break; + case 0x5E: // GET_SOFTWARE_VERSION: ACK, version 23380315 (BCD, low byte first), checksum, stop + id(mock_uart).inject_to_rx_buffer(std::vector{0xE5, 0x15, 0x03, 0x38, 0x23, 0x73, 0x16}); + break; + } + +interval: + - interval: 1s + then: + - if: + condition: + lambda: "return id(streaming) && !id(silent).state;" + then: + - lambda: |- + // Meter ID 2307140001 (BCD, low byte first), flow 1.2345 m3/h, temperature 23.5 C + std::vector frame{ + 0x3C, 0x32, // start + 0x01, 0x00, 0x14, 0x07, 0x23, 0x01, // meter ID, reserved + 0x0A, 0x45, 0x23, 0x01, 0x00, 0x00, 0x00, // accumulated flow 12.345 L + 0x0B, 0x50, 0x34, 0x12, 0x00, 0x00, // flow + 0x0C, 0x00, 0x00, // reserved section + 0x0D, 0x50, 0x23, 0x00, // temperature + 0x00, 0x00, // ST1, ST2 + 0x4E, 0x16, // checksum, stop + }; + if (id(cleared)) { + // Accumulated flow 0 m3: unit flag 0x1A, zero digits, checksum follows + frame[8] = 0x1A; + std::fill(frame.begin() + 9, frame.begin() + 15, 0x00); + frame[30] = 0xF5; + } + id(mock_uart).inject_to_rx_buffer(frame); + +ufm01: + id: ufm01_dev + uart_id: mock_uart + +sensor: + - platform: ufm01 + accumulated_flow: + name: "Accumulated Flow" + flow: + name: "Flow" + temperature: + name: "Temperature" + - platform: template + id: clear_completions + name: "Clear Completions" + update_interval: never + +text_sensor: + - platform: ufm01 + meter_id: + name: "Meter ID" + software_version: + name: "Software Version" + +switch: + # The meter stops streaming and answers nothing + - platform: template + id: silent + name: "Silent" + optimistic: true + turn_on_action: + - lambda: "id(streaming) = false;" + +button: + # The stream stops while the meter still answers commands + - platform: template + name: "Pause Stream" + on_press: + - lambda: "id(streaming) = false;" + - platform: template + name: "Resume Stream" + on_press: + - lambda: "id(streaming) = true;" diff --git a/tests/integration/test_uart_mock_ufm01.py b/tests/integration/test_uart_mock_ufm01.py new file mode 100644 index 00000000000..c2c2f08f523 --- /dev/null +++ b/tests/integration/test_uart_mock_ufm01.py @@ -0,0 +1,399 @@ +"""Integration tests for the UFM-01 flow meter with a simulated meter on the mock UART. + +All tests share uart_mock_ufm01.yaml, whose on_tx lambda answers like a UFM-01: it ACKs commands, +answers sensor data and software version reads, and streams active frames each second after +SET_ACTIVE_MODE. The "Silent" switch stops it answering, "Pause Stream" stops the stream while it still +answers, and "Resume Stream" starts the stream again. + +test_uart_mock_ufm01_startup: + Reset, software version read and active mode go out in order, the active frames publish flow, + temperature and accumulated flow, and the meter ID and software version publish once. +test_uart_mock_ufm01_stale_recovery: + A stopped stream marks flow and temperature unknown after 5 s and switches to passive polling, + whose answers publish; a resumed stream switches back to the active stream and polling stops. +test_uart_mock_ufm01_escalation: + Eight unanswered passive polls send a reset and run the startup sequence again; values publish + once the meter answers, and the diagnostics are not published again. +test_uart_mock_ufm01_clear_action: + The clear command is sent once the UART is idle while the automation continues at once; the + meter's ACK, or its absence, is only logged, and a second request joins the pending one. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import math +import re + +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + SensorState, + SwitchInfo, + TextSensorState, + UserService, +) +import pytest + +from .state_utils import InitialStateHelper, StateWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Commands as the uart mock logs them: FE FE 11, command, argument, checksum, 16 +RESET_DEVICE = "FE:FE:11:5D:CB:28:16" +SET_ACTIVE_MODE = "FE:FE:11:5C:00:5C:16" +SET_PASSIVE_MODE = "FE:FE:11:5C:01:5D:16" +READ_SENSOR_DATA = "FE:FE:11:5B:0F:6A:16" +GET_SOFTWARE_VERSION = "FE:FE:11:5E:62:C0:16" +CLEAR_ACCUMULATED_FLOW = "FE:FE:11:5A:FD:57:16" + +# Values the fixture's frames encode +ACTIVE_FLOW = 1.2345 +ACTIVE_TEMPERATURE = 23.5 +ACTIVE_ACCUMULATED = 12.345 +PASSIVE_FLOW = 0.567 +PASSIVE_TEMPERATURE = 19.25 +PASSIVE_ACCUMULATED = 25.678 +METER_ID = "2307140001" +SOFTWARE_VERSION = "23380315" + +ACTIVE_STARTED = "UFM-01 active stream started" +ACTIVE_RESUMED = "UFM-01 active stream resumed" +STALE = "Active stream stale, switching to passive polling" +ESCALATED = "Passive poll failed repeatedly, re-initializing UFM-01" +CLEAR_ACKED = "Clear accumulated flow acknowledged" +CLEAR_NOT_ACKED = "Clear accumulated flow not acknowledged" + +# Startup wait, reset ACK, post-reset wait, then the first frame within the 1 s stream interval +STARTUP_TIMEOUT = 8.0 +ACTIVE_STALE_S = 5.0 + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +class _Log: + """Record TX frames and ufm01 log lines with their arrival time, and wait for lines.""" + + def __init__(self) -> None: + self.events: list[tuple[float, str]] = [] + self._awaited: list[tuple[str, asyncio.Future[float]]] = [] + + def on_line(self, line: str) -> None: + line = ANSI_ESCAPE.sub("", line) + if "uart_mock" in line and "TX " in line and "bytes: " in line: + text = line.split("bytes: ", 1)[1].strip() + elif "[ufm01" in line: + text = line + else: + return + now = asyncio.get_running_loop().time() + self.events.append((now, text)) + for wanted, future in self._awaited: + if wanted in text and not future.done(): + future.set_result(now) + + def next(self, text: str) -> asyncio.Future[float]: + """Return a future for the time of the next event containing ``text``.""" + future: asyncio.Future[float] = asyncio.get_running_loop().create_future() + self._awaited.append((text, future)) + return future + + @property + def tx(self) -> list[str]: + return [text for _, text in self.events if text.startswith("FE:")] + + def index(self, text: str, start: int = 0) -> int: + return next( + i + for i, (_, event) in enumerate(self.events) + if i >= start and text in event + ) + + def tx_between(self, start: int, end: int) -> list[str]: + return [text for _, text in self.events[start:end] if text.startswith("FE:")] + + +class _Meter: + """Entities and state history of the running fixture.""" + + def __init__( + self, client: APIClient, entities: list[EntityInfo], services: list[UserService] + ) -> None: + self.client = client + self.services = services + self.by_id = {entity.object_id: entity for entity in entities} + self.waiter = StateWaiter() + self.states: dict[str, list[float | str]] = {name: [] for name in self.by_id} + self._names = {entity.key: entity.object_id for entity in entities} + self.helper = InitialStateHelper(entities) + + def on_state(self, state: EntityState) -> None: + if ( + isinstance(state, (SensorState, TextSensorState)) + and not state.missing_state + ): + self.states[self._names[state.key]].append(state.state) + self.waiter.on_state(state) + + def expect( + self, name: str, check: Callable[[float | str], bool], timeout: float + ) -> Awaitable[EntityState]: + key = self.by_id[name].key + return self.waiter.expect( + lambda s: s.key == key and not s.missing_state and check(s.state), + timeout=timeout, + label=name, + ) + + def press(self, name: str) -> None: + entity = self.by_id[name] + assert isinstance(entity, ButtonInfo) + self.client.button_command(entity.key) + + def set_silent(self, silent: bool) -> None: + entity = self.by_id["silent"] + assert isinstance(entity, SwitchInfo) + self.client.switch_command(entity.key, silent) + + def all_values(self, name: str) -> list[float | str]: + """Return every value ``name`` held, including one already set at connect time.""" + initial = self.helper.initial_states[self.by_id[name].key] + return ([] if initial.missing_state else [initial.state]) + self.states[name] + + +async def _connect(client: APIClient) -> _Meter: + entities, services = await client.list_entities_services() + meter = _Meter(client, entities, services) + client.subscribe_states(meter.helper.on_state_wrapper(meter.on_state)) + try: + await meter.helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + return meter + + +def _is_nan(value: float | str) -> bool: + return isinstance(value, float) and math.isnan(value) + + +def _approx(expected: float) -> Callable[[float | str], bool]: + return lambda value: value == pytest.approx(expected, abs=1e-4) + + +def _active_values(meter: _Meter, timeout: float) -> list[Awaitable[EntityState]]: + """Arm waits for the next flow, temperature and accumulated flow from an active frame.""" + return [ + meter.expect("flow", _approx(ACTIVE_FLOW), timeout), + meter.expect("temperature", _approx(ACTIVE_TEMPERATURE), timeout), + meter.expect("accumulated_flow", _approx(ACTIVE_ACCUMULATED), timeout), + ] + + +async def _gather(meter: _Meter, waits: list[Awaitable[EntityState]]) -> None: + try: + await asyncio.gather(*waits) + except TimeoutError as err: + pytest.fail(f"{err}; states: {meter.states}") + + +async def _wait_for(log: _Log, future: asyncio.Future[float], timeout: float) -> float: + try: + return await asyncio.wait_for(future, timeout) + except TimeoutError: + pytest.fail(f"Timed out; log: {log.events}") + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_startup( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Startup reaches the active stream, publishes the decoded values and the diagnostics once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + values = _active_values(meter, STARTUP_TIMEOUT) + await _wait_for(log, started, STARTUP_TIMEOUT) + await _gather(meter, values) + # Two more stream frames + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + assert log.tx == [RESET_DEVICE, GET_SOFTWARE_VERSION, SET_ACTIVE_MODE] + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_stale_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stopped stream falls back to passive polling and a resumed stream ends it.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + stale = log.next(STALE) + flow_unknown = meter.expect("flow", _is_nan, ACTIVE_STALE_S + 2) + temperature_unknown = meter.expect("temperature", _is_nan, ACTIVE_STALE_S + 2) + polled = [ + meter.expect("flow", _approx(PASSIVE_FLOW), ACTIVE_STALE_S + 4), + meter.expect( + "temperature", _approx(PASSIVE_TEMPERATURE), ACTIVE_STALE_S + 4 + ), + meter.expect( + "accumulated_flow", _approx(PASSIVE_ACCUMULATED), ACTIVE_STALE_S + 4 + ), + ] + meter.press("pause_stream") + paused_at = asyncio.get_running_loop().time() + stale_at = await _wait_for(log, stale, ACTIVE_STALE_S + 2) + # The last frame came up to 1 s before the pause + assert stale_at - paused_at >= ACTIVE_STALE_S - 1.5 + await _gather(meter, [flow_unknown, temperature_unknown]) + await _gather(meter, polled) + + stale_index = log.index(STALE) + assert log.tx_between(stale_index, len(log.events))[:2] == [ + SET_PASSIVE_MODE, + READ_SENSOR_DATA, + ] + # Unknown first, then the passive answers + assert _is_nan(meter.states["flow"][-2]) + assert _is_nan(meter.states["temperature"][-2]) + + resumed = log.next(ACTIVE_RESUMED) + meter.press("resume_stream") + values = _active_values(meter, 3.0) + await _wait_for(log, resumed, 3.0) + await _gather(meter, values) + # Two more stream frames without any poll in between + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + assert READ_SENSOR_DATA not in log.tx_between( + log.index(ACTIVE_RESUMED), len(log.events) + ) + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_escalation( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Eight unanswered passive polls reset the meter and rerun the startup sequence.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + escalated = log.next(ESCALATED) + meter.set_silent(True) + # Stale after 5 s, then eight polls that each time out after 1 s + flow_count = len(meter.states["flow"]) + await _wait_for(log, escalated, ACTIVE_STALE_S + 8 + 4) + + restarted = log.next(ACTIVE_STARTED) + values = _active_values(meter, STARTUP_TIMEOUT) + meter.set_silent(False) + await _wait_for(log, restarted, STARTUP_TIMEOUT) + await _gather(meter, values) + + stale_index = log.index(STALE) + escalated_index = log.index(ESCALATED, stale_index) + assert ( + log.tx_between(stale_index, escalated_index) + == [SET_PASSIVE_MODE] + [READ_SENSOR_DATA] * 8 + ) + # A reset (resent once if the meter was still silent), then active mode; the software + # version is already known, so it is not read again + after = log.tx_between( + escalated_index, log.index(ACTIVE_STARTED, escalated_index) + ) + assert after in ( + [RESET_DEVICE, SET_ACTIVE_MODE], + [RESET_DEVICE, RESET_DEVICE, SET_ACTIVE_MODE], + ), after + # Unknown when the stream went stale and again on the re-init, nothing in between; + # a frame already in flight when the meter went silent may come first + flow = meter.states["flow"][flow_count:] + first_unknown = next(i for i, value in enumerate(flow) if _is_nan(value)) + assert _is_nan(flow[first_unknown + 1]), flow + assert flow[first_unknown + 2] == pytest.approx(ACTIVE_FLOW, abs=1e-4), flow + + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_clear_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The clear command is sent once the UART is idle; the automation continues at once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + acked = log.next(CLEAR_ACKED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + # A second entity listing would re-send the initial states and duplicate the first completion + (clear,) = meter.services + # Normally still inside the startup wait; a slow runner may already be streaming + queued_during_startup = not started.done() + + completed = meter.expect("clear_completions", _approx(1), STARTUP_TIMEOUT) + zeroed = meter.expect("accumulated_flow", _approx(0.0), STARTUP_TIMEOUT + 2) + await client.execute_service(clear, {}) + await _wait_for(log, acked, STARTUP_TIMEOUT) + await _gather(meter, [completed, zeroed]) + + # Queued during startup, sent only once the active stream started + if queued_during_startup: + assert CLEAR_ACCUMULATED_FLOW not in log.tx_between( + 0, log.index(ACTIVE_STARTED) + ) + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 1 + assert log.index(CLEAR_ACCUMULATED_FLOW) < log.index(CLEAR_ACKED) + + # Two requests in a row share one command; without an answer it is reported after the 500 ms ACK timeout + sent = log.next(CLEAR_ACCUMULATED_FLOW) + not_acked = log.next(CLEAR_NOT_ACKED) + completed = meter.expect("clear_completions", _approx(3), 3.0) + meter.set_silent(True) + await asyncio.sleep(0.2) + await client.execute_service(clear, {}) + await client.execute_service(clear, {}) + sent_at = await _wait_for(log, sent, 2.0) + not_acked_at = await _wait_for(log, not_acked, 2.0) + await _gather(meter, [completed]) + assert not_acked_at - sent_at >= 0.4 + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 2 + assert meter.states["clear_completions"] == [1.0, 2.0, 3.0] From 16a474cce5de7e3ed506fea4ffcc618cdf88cb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:40:27 +0200 Subject: [PATCH 243/435] [core] Only treat action names as actions in has_non_synchronous_actions() (#20524) Co-authored-by: Claude Opus 5.5 --- esphome/automation.py | 24 +++++++--- tests/unit_tests/test_automation.py | 70 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 0105d1e1072..fea5769050e 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1033,18 +1033,30 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: Non-synchronous actions (delay, wait_until, script.wait, etc.) store trigger args for later execution, making non-owning types like StringRef unsafe. + + Accepts an action list, a single action, or an automation. Only the keys of + action entries are looked up as action names; the options of an action are + only searched for nested action lists (``then:``, ``else:``, ...), so an + option that shares a name with an action, such as the ``delay:`` option of + ``speaker_source.set_playlist_delay``, is not mistaken for that action. """ if isinstance(actions, list): return any(has_non_synchronous_actions(item) for item in actions) if isinstance(actions, dict): - for key in actions: + for key, options in actions.items(): if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous: return True - return any( - has_non_synchronous_actions(v) - for v in actions.values() - if isinstance(v, (list, dict)) - ) + if _options_have_non_synchronous_actions(options): + return True + return False + + +def _options_have_non_synchronous_actions(options: Any) -> bool: + """Search an action's options for nested action lists, without treating option keys as actions.""" + if isinstance(options, list): + return has_non_synchronous_actions(options) + if isinstance(options, dict): + return any(_options_have_non_synchronous_actions(v) for v in options.values()) return False diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 351afe541d6..f2d46280448 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -216,6 +216,76 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False +def test_has_non_synchronous_actions_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """An option that shares a name with a deferring action is not that action.""" + actions = [{"logger.log": {"message": "hello", "delay": "1s"}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_nested_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Option keys in nested option dicts are not looked up as action names either.""" + actions = [{"logger.log": {"settings": {"wait_until": {"condition": {}}}}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_option_named_like_action_inside_then( + mock_registry: dict[str, RegistryEntry], +) -> None: + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": {"message": "hello", "delay": "1s"}}], + } + } + ] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_in_else( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Action lists in any option of an action are searched, not only then:.""" + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": "hello"}], + "else": [{"delay": "1s"}], + } + } + ] + assert has_non_synchronous_actions(actions) is True + + +def test_has_non_synchronous_actions_automation_dict( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A single automation, as validate_automation(single=True) returns it.""" + assert has_non_synchronous_actions({CONF_THEN: [{"delay": "1s"}]}) is True + assert has_non_synchronous_actions({CONF_THEN: [{"logger.log": "hi"}]}) is False + + +def test_has_non_synchronous_actions_set_playlist_delay() -> None: + """speaker_source.set_playlist_delay is synchronous despite its delay: option.""" + import esphome.components.speaker_source.media_player # noqa: F401 # registers the action + + actions = [ + { + "speaker_source.set_playlist_delay": { + CONF_ID: "player", + "pipeline": 0, + "delay": 1000, + } + } + ] + assert has_non_synchronous_actions(actions) is False + + def test_validate_no_deferring_actions_accepts_synchronous( mock_registry: dict[str, RegistryEntry], ) -> None: From 5c5737303f4f5344c9fbbb1bd7285bebc0a4611a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:02 +0200 Subject: [PATCH 244/435] [teleinfo] Store the frame as bytes and read with read_byte() (#20531) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 25 ++++++++++++------------ esphome/components/teleinfo/teleinfo.h | 4 ++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 91a0d0e16f5..b4e474dea4b 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -6,30 +6,30 @@ namespace esphome::teleinfo { ESPHOME_LOG_TAG(TAG, "teleinfo"); /* Helpers */ -static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int max_len) { - char *field_end; +static int get_field(char *dest, uint8_t *buf_start, uint8_t *buf_end, int sep, int max_len) { + uint8_t *field_end; int len; - field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); + field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); if (!field_end) return 0; len = field_end - buf_start; if (len >= max_len) return len; - strncpy(dest, buf_start, len); + memcpy(dest, buf_start, len); dest[len] = '\0'; return len; } /* TeleInfo methods */ -bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { +bool TeleInfo::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { int grp_len = grp_end - grp; if (grp_len < this->checksum_area_end_) { ESP_LOGE(TAG, "Empty or too short group"); return false; } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = sum_bytes(reinterpret_cast(grp), grp_len - this->checksum_area_end_); + uint8_t crc_tmp = sum_bytes(grp, grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; @@ -46,7 +46,8 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { while (available() > 0 && j < 128) { j++; - received = read(); + if (!read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -87,9 +88,9 @@ void TeleInfo::loop() { state_ = END_FRAME_RECEIVED; break; case END_FRAME_RECEIVED: - char *buf_finger; - char *grp_end; - char *buf_end; + uint8_t *buf_finger; + uint8_t *grp_end; + uint8_t *buf_end; int field_len; buf_finger = buf_; @@ -116,7 +117,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -126,7 +127,7 @@ void TeleInfo::loop() { buf_finger += 1; /* Group len */ - grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); + grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index b1bf586e9c2..39f02874113 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -33,7 +33,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { protected: int checksum_area_end_; int separator_; - char buf_[MAX_BUF_SIZE]; + uint8_t buf_[MAX_BUF_SIZE]; uint32_t buf_index_{0}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; @@ -45,7 +45,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { END_FRAME_RECEIVED, } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); - bool check_crc_(const char *grp, const char *grp_end); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const std::string &tag, const std::string &val); }; } // namespace esphome::teleinfo From b10eef537ea70f75f8b64136756cd0dd21739ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:28 +0200 Subject: [PATCH 245/435] [teleinfo] Log the tag name for an invalid timestamp (#20532) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index b4e474dea4b..1d3813d59e7 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -154,7 +154,7 @@ void TeleInfo::loop() { if (std::count(buf_finger, grp_end, separator_) == 2 && strcmp(tag_, "DATE") != 0 && separator_ != 0x20) { field_len = get_field(timestamp_, buf_finger, grp_end, separator_, MAX_TIMESTAMP_SIZE); if (!field_len || field_len >= MAX_TIMESTAMP_SIZE) { - ESP_LOGE(TAG, "Invalid timestamp for tag %s", timestamp_); + ESP_LOGE(TAG, "Invalid timestamp for tag %s", tag_); continue; } From da43b6292dbc7a51a1ac8b5056e190371e424e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:58 +0200 Subject: [PATCH 246/435] [mk2pvrouter] Store the frame as bytes and read with read_byte() (#20530) Co-authored-by: Claude Opus 5.5 --- .../components/mk2pvrouter/mk2pvrouter.cpp | 24 +++++++++---------- esphome/components/mk2pvrouter/mk2pvrouter.h | 8 +++---- .../mk2pvrouter/mk2pvrouter_test.cpp | 15 ++++++++---- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index b759758019a..98dd0b74ece 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -19,8 +19,8 @@ constexpr uint8_t CRC_OFFSET = 0x20; // Extracts a TAB-delimited field from [buf_start, buf_end) into dest. // Returns the field length, or 0 if no TAB was found, or the (uncopied) field // length if it's >= max_len. -static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { - const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); +static size_t get_field(char *dest, const uint8_t *buf_start, const uint8_t *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); if (!field_end) return 0; const size_t len = field_end - buf_start; @@ -34,13 +34,13 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, return len; } -// Calculates the CRC (checksum) for a given group of characters. -uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - return (sum_bytes(reinterpret_cast(grp), grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; +// Calculates the CRC (checksum) for a given group of bytes. +uint8_t Mk2PVRouter::calculate_crc_(const uint8_t *grp, size_t grp_len) { + return (sum_bytes(grp, grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. -bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { +bool Mk2PVRouter::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { const auto grp_len = grp_end - grp; if (grp_len < static_cast(CRC_SUFFIX_LEN)) { ESP_LOGE(TAG, "Empty or too short group"); @@ -58,7 +58,7 @@ bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { } // Validates, parses, and publishes a single tag/value group. -void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { +void Mk2PVRouter::process_group_(const uint8_t *grp, const uint8_t *grp_end) { if (!this->check_crc_(grp, grp_end)) return; @@ -83,9 +83,9 @@ bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { size_t j{0}; while (this->available() > 0 && j++ < MAX_ITERATIONS) { - const auto received = this->read(); - if (received < 0) - continue; + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -127,10 +127,10 @@ void Mk2PVRouter::loop() { // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) // CRC is computed over "Tag | TAB | Data | TAB". - while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { ++buf_finger; // Skip LF to the start of the group. - auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index 1a6af4b1b39..061a9a22e94 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -57,12 +57,12 @@ class Mk2PVRouter : public Component, public uart::UARTDevice { State state_{State::WAITING_FOR_START}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; - char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + uint8_t buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding bool read_chars_until_(bool drop, uint8_t c); - uint8_t calculate_crc_(const char *grp, size_t grp_len); - bool check_crc_(const char *grp, const char *grp_end); - void process_group_(const char *grp, const char *grp_end); + uint8_t calculate_crc_(const uint8_t *grp, size_t grp_len); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); + void process_group_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const char *tag, const char *val); }; } // namespace esphome::mk2pvrouter diff --git a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp index 84106eb0b77..a2fb33a2a33 100644 --- a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp +++ b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp @@ -8,6 +8,9 @@ namespace esphome::mk2pvrouter::testing { namespace { +// The parser works on raw frame bytes; the groups below are written as text for readability. +const uint8_t *as_bytes(const char *str) { return reinterpret_cast(str); } + class TestListener final : public Mk2PVRouterListener { public: explicit TestListener(const char *tag) : Mk2PVRouterListener(tag) {} @@ -25,7 +28,9 @@ class Mk2PVRouterTest : public ::testing::Test { void SetUp() override { this->sut_.register_mk2pvrouter_listener(&this->listener_); } // Feeds one "tagvaluecrc" literal, as the frame parser does after END_FRAME. - template void process_(const char (&group)[N]) { this->sut_.process_group_(group, group + N - 1); } + template void process_(const char (&group)[N]) { + this->sut_.process_group_(as_bytes(group), as_bytes(group) + N - 1); + } TestableMk2PVRouter sut_; TestListener listener_{"P1"}; @@ -40,22 +45,22 @@ TEST(Mk2PVRouterListenerTest, GetTagReturnsConstructorTag) { TEST_F(Mk2PVRouterTest, CalculateCrcMatchesKnownGroup) { // "P1\t1234\t" sums to 0x3D ('=') per the mk2pvrouter CRC algorithm. const char grp[] = "P1\t1234\t="; - EXPECT_EQ(this->sut_.calculate_crc_(grp, sizeof(grp) - 1), '='); + EXPECT_EQ(this->sut_.calculate_crc_(as_bytes(grp), sizeof(grp) - 1), '='); } TEST_F(Mk2PVRouterTest, CheckCrcAcceptsMatchingCrc) { const char grp[] = "P1\t1234\t="; - EXPECT_TRUE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_TRUE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsMismatchedCrc) { const char grp[] = "P1\t1234\t!"; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsEmptyGroup) { const char grp[] = ""; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp))); } TEST_F(Mk2PVRouterTest, ProcessGroupPublishesValidGroupToMatchingListener) { From b73f151619b2a62ce73130470987c42eccb071e4 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Sun, 11 Oct 2026 21:49:06 +0200 Subject: [PATCH 247/435] [pmsx003] add support for luftmy ld15 (#15296) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pmsx003/pmsx003.cpp | 78 ++++++++++--------- esphome/components/pmsx003/pmsx003.h | 1 + esphome/components/pmsx003/sensor.py | 11 +++ tests/components/pmsx003/common-luftmy.yaml | 22 ++++++ tests/components/pmsx003/test.esp32-idf.yaml | 3 +- .../components/pmsx003/test.esp8266-ard.yaml | 3 +- tests/components/pmsx003/test.rp2040-ard.yaml | 3 +- .../pmsx003/validate-luftmy.esp32-idf.yaml | 7 ++ 8 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 tests/components/pmsx003/common-luftmy.yaml create mode 100644 tests/components/pmsx003/validate-luftmy.esp32-idf.yaml diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index 14b1f60df54..f99e5adaa2c 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -189,6 +189,7 @@ bool PMSX003Component::check_payload_length_(uint16_t payload_length) { case Type::PMS5003ST: // Data 16 not set/reserved return payload_length == 36; // 2*17+2 case Type::PMS9003M: + case Type::LUFTMY: return payload_length == 28; // 2*13+2 } return false; @@ -219,40 +220,48 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { } void PMSX003Component::parse_data_() { - // Particle Matter + // PM mass concentrations const uint16_t pm_1_0_std_concentration = this->get_16_bit_uint_(4); const uint16_t pm_2_5_std_concentration = this->get_16_bit_uint_(6); const uint16_t pm_10_0_std_concentration = this->get_16_bit_uint_(8); - const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); - const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); - const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); - - const uint16_t pm_particles_03um = this->get_16_bit_uint_(16); - const uint16_t pm_particles_05um = this->get_16_bit_uint_(18); - const uint16_t pm_particles_10um = this->get_16_bit_uint_(20); - const uint16_t pm_particles_25um = this->get_16_bit_uint_(22); - - ESP_LOGD(TAG, - "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " - "Concentration: %u µg/m³, PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 " - "Concentration: %u µg/m³", - pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration, pm_1_0_concentration, - pm_2_5_concentration, pm_10_0_concentration); - if (this->pm_1_0_std_sensor_ != nullptr) this->pm_1_0_std_sensor_->publish_state(pm_1_0_std_concentration); if (this->pm_2_5_std_sensor_ != nullptr) this->pm_2_5_std_sensor_->publish_state(pm_2_5_std_concentration); if (this->pm_10_0_std_sensor_ != nullptr) this->pm_10_0_std_sensor_->publish_state(pm_10_0_std_concentration); + ESP_LOGD(TAG, + "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " + "Concentration: %u µg/m³", + pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration); - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + // The Luftmy LD10, LD11, LD13, LD15 and LD16 report no atmospheric PM, so their particle counts start at byte 10. + // All PMS types have atmospheric PM at bytes 10-15, so their particle counts start at byte 16. + const bool is_luftmy = this->type_ == Type::LUFTMY; + const uint8_t pc_offset = is_luftmy ? 10 : 16; + + if (!is_luftmy) { + const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); + const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); + const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); + + ESP_LOGD(TAG, "Got PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 Concentration: %u µg/m³", + pm_1_0_concentration, pm_2_5_concentration, pm_10_0_concentration); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + } + + // Particle counts at pc_offset: >0.3, >0.5, >1.0, >2.5µm + const uint16_t pm_particles_03um = this->get_16_bit_uint_(pc_offset); + const uint16_t pm_particles_05um = this->get_16_bit_uint_(pc_offset + 2); + const uint16_t pm_particles_10um = this->get_16_bit_uint_(pc_offset + 4); + const uint16_t pm_particles_25um = this->get_16_bit_uint_(pc_offset + 6); if (this->pm_particles_03um_sensor_ != nullptr) this->pm_particles_03um_sensor_->publish_state(pm_particles_03um); @@ -262,22 +271,17 @@ void PMSX003Component::parse_data_() { this->pm_particles_10um_sensor_->publish_state(pm_particles_10um); if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); + ESP_LOGD(TAG, + "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " + "PM2.5 Particles: %u Count/0.1L", + pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - if (this->type_ == Type::PMS5003T) { - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - } else { - // Note the pm particles 50um & 100um are not returned, - // as PMS5003T uses those data values for temperature and humidity. - const uint16_t pm_particles_50um = this->get_16_bit_uint_(24); - const uint16_t pm_particles_100um = this->get_16_bit_uint_(26); + // PMS5003T uses pc_offset+8 and pc_offset+10 for temperature/humidity, not particle counts + if (this->type_ != Type::PMS5003T) { + const uint16_t pm_particles_50um = this->get_16_bit_uint_(pc_offset + 8); + const uint16_t pm_particles_100um = this->get_16_bit_uint_(pc_offset + 10); - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L, PM5.0 Particles: %u Count/0.1L, PM10.0 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um, pm_particles_50um, + ESP_LOGD(TAG, "Got PM5.0 Particles: %u Count/0.1L, PM10.0 Particles: %u Count/0.1L", pm_particles_50um, pm_particles_100um); if (this->pm_particles_50um_sensor_ != nullptr) diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index c62960e7c31..c123a42b9e6 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -15,6 +15,7 @@ enum class Type : uint8_t { PMS5003T, PMS5003ST, PMS9003M, + LUFTMY, // Luftmy LD10, LD11, LD13, LD15, LD16: standard PM + particle counts, no atmospheric PM values }; enum class Command : uint8_t { diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 5eac5415ed3..723b99a5040 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -51,6 +51,7 @@ TYPE_PMS5003S = "PMS5003S" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" TYPE_PMS9003M = "PMS9003M" +TYPE_LUFTMY = "LUFTMY" Type = pmsx003_ns.enum("Type", is_class=True) @@ -62,6 +63,7 @@ PMSX003_TYPES = { TYPE_PMS5003T: Type.PMS5003T, TYPE_PMS5003ST: Type.PMS5003ST, TYPE_PMS9003M: Type.PMS9003M, + TYPE_LUFTMY: Type.LUFTMY, } SENSORS_TO_TYPE = { @@ -73,6 +75,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5_STD: [ TYPE_PMS1003, @@ -82,6 +85,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0_STD: [ TYPE_PMS1003, @@ -91,6 +95,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0: [ TYPE_PMS1003, @@ -126,6 +131,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_0_5UM: [ TYPE_PMS1003, @@ -134,6 +140,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0UM: [ TYPE_PMS1003, @@ -142,6 +149,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5UM: [ TYPE_PMS1003, @@ -150,6 +158,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_5_0UM: [ TYPE_PMS1003, @@ -157,6 +166,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0UM: [ TYPE_PMS1003, @@ -164,6 +174,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST], CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST], diff --git a/tests/components/pmsx003/common-luftmy.yaml b/tests/components/pmsx003/common-luftmy.yaml new file mode 100644 index 00000000000..538b2481a49 --- /dev/null +++ b/tests/components/pmsx003/common-luftmy.yaml @@ -0,0 +1,22 @@ +sensor: + - platform: pmsx003 + type: LUFTMY + pm_1_0_std: + name: PM 1.0 Standard Atmospheric Concentration + pm_2_5_std: + name: PM 2.5 Standard Atmospheric Concentration + pm_10_0_std: + name: PM 10.0 Standard Atmospheric Concentration + pm_0_3um: + name: Particulate Count >0.3um + pm_0_5um: + name: Particulate Count >0.5um + pm_1_0um: + name: Particulate Count >1.0um + pm_2_5um: + name: Particulate Count >2.5um + pm_5_0um: + name: Particulate Count >5.0um + pm_10_0um: + name: Particulate Count >10.0um + update_interval: 30s diff --git a/tests/components/pmsx003/test.esp32-idf.yaml b/tests/components/pmsx003/test.esp32-idf.yaml index b415125e84b..9e5b478a31e 100644 --- a/tests/components/pmsx003/test.esp32-idf.yaml +++ b/tests/components/pmsx003/test.esp32-idf.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.esp8266-ard.yaml b/tests/components/pmsx003/test.esp8266-ard.yaml index 96ab4ef6aca..8e7bd3ebe86 100644 --- a/tests/components/pmsx003/test.esp8266-ard.yaml +++ b/tests/components/pmsx003/test.esp8266-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.rp2040-ard.yaml b/tests/components/pmsx003/test.rp2040-ard.yaml index b28f2b5e05e..58779cc0956 100644 --- a/tests/components/pmsx003/test.rp2040-ard.yaml +++ b/tests/components/pmsx003/test.rp2040-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml new file mode 100644 index 00000000000..c0c648e0389 --- /dev/null +++ b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + pmsx003: !include common-luftmy.yaml From f562d7a6a41ed78b166eccac29c6f2b6aaad4605 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Sun, 11 Oct 2026 15:59:44 -0400 Subject: [PATCH 248/435] [socket] Add join_multicast_group() and set_ipv6_multicast_if() helpers (#17045) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/socket/socket.cpp | 207 ++++++++++++++++++ esphome/components/socket/socket.h | 22 ++ .../socket/test-ipv6.bk72xx-ard.yaml | 5 + .../components/socket/test_multicast_host.cpp | 96 ++++++++ 4 files changed, 330 insertions(+) create mode 100644 tests/components/socket/test-ipv6.bk72xx-ard.yaml create mode 100644 tests/components/socket/test_multicast_host.cpp diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f6d1daa1240..89002cdd19e 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -11,6 +11,16 @@ #ifdef USE_HOST #include "esphome/core/wake.h" #endif +#if defined(USE_HOST) +#include +#include +#elif defined(USE_ZEPHYR) +#ifndef CLANG_TIDY // NCS 2.9.2 net_if.h has a BUILD_ASSERT clang rejects; gone in NCS 3.4 +#include +#endif +#else +#include "lwip/netif.h" +#endif namespace esphome::socket { @@ -139,6 +149,203 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { } #endif +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +// Callback must be a temporary lambda +template static bool foreach_eligible_ipv6_if(F &&callback) { +#if defined(USE_HOST) + struct ifaddrs *ifaddr; + if (getifaddrs(&ifaddr) != 0) { + return false; + } + bool found_any = false; + for (struct ifaddrs *ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr || ifa->ifa_addr->sa_family != AF_INET6) { + continue; + } + if ((ifa->ifa_flags & IFF_LOOPBACK) || !(ifa->ifa_flags & IFF_UP) || !(ifa->ifa_flags & IFF_MULTICAST)) { + continue; + } + unsigned int idx = if_nametoindex(ifa->ifa_name); + if (idx == 0) { + continue; + } + found_any = true; + if (callback(idx)) { + break; + } + } + freeifaddrs(ifaddr); + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#elif defined(USE_ZEPHYR) && defined(CLANG_TIDY) + (void) callback; + return false; +#elif defined(USE_ZEPHYR) + // Zephyr has no getifaddrs()/ifaddrs.h; enumerate interfaces via the native net_if API. + // NET_IF_IPV6_NO_MLD is the direct analog of the LwIP branch's NETIF_FLAG_MLD6 check below. + struct EligibleIfContext { + F *callback; + bool found_any = false; + bool done = false; + }; + EligibleIfContext ctx{&callback}; + net_if_foreach( + [](struct net_if *iface, void *user_data) { + auto *ctx = static_cast(user_data); + if (ctx->done) { + return; + } + if (!net_if_flag_is_set(iface, NET_IF_UP) || !net_if_flag_is_set(iface, NET_IF_IPV6) || + net_if_flag_is_set(iface, NET_IF_IPV6_NO_MLD)) { + return; + } + int idx = net_if_get_by_iface(iface); + if (idx <= 0) { + return; + } + ctx->found_any = true; + if ((*ctx->callback)(static_cast(idx))) { + ctx->done = true; + } + }, + &ctx); + if (!ctx.found_any) { + errno = EADDRNOTAVAIL; + } + return ctx.found_any; +#else + bool found_any = false; + struct netif *netif; + NETIF_FOREACH(netif) { + if (netif->name[0] == 'l' && netif->name[1] == 'o') { + continue; + } + if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { + continue; + } + found_any = true; + if (callback(netif_get_index(netif))) { + break; + } + } + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#endif +} + +bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out) { + if (strchr(ip_address, ':') == nullptr) { + struct in_addr ipv4mc {}; +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET, ip_address, &ipv4mc) != 1) { +#else + if (inet_aton(ip_address, &ipv4mc) == 0) { +#endif + errno = EINVAL; + return false; + } +#ifdef USE_ZEPHYR + // Zephyr's setsockopt(IP_ADD_MEMBERSHIP) requires exactly sizeof(struct ip_mreqn); it has + // no plain "struct ip_mreq" at all (unlike POSIX/lwIP). + struct ip_mreqn imreq {}; + imreq.imr_address.s_addr = ESPHOME_INADDR_ANY; + imreq.imr_ifindex = 0; +#else + struct ip_mreq imreq {}; + imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; +#endif + imreq.imr_multiaddr = ipv4mc; + if (sock->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)) < 0) { + return false; + } + if (if_index_out != nullptr) { + *if_index_out = 0; + } + return true; + } +#if USE_NETWORK_IPV6 + struct ipv6_mreq imreq6 {}; +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#else + if (inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#endif + errno = EINVAL; + return false; + } +#else + ip6_addr_t ip6; + if (!inet6_aton(ip_address, &ip6)) { + errno = EINVAL; + return false; + } + memcpy(imreq6.ipv6mr_multiaddr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); +#endif + // POSIX: interface=0 fails for link-local multicast (ff02::) and may select loopback for + // other scopes. LwIP: NETIF_NO_INDEX=0 is always rejected. + bool joined = false; + foreach_eligible_ipv6_if([&](unsigned int idx) { +#ifdef USE_ZEPHYR + // This Zephyr fork's struct ipv6_mreq has no "ipv6mr_interface" (POSIX name) and no + // IPV6_JOIN_GROUP alias -- only the RFC 3678 legacy ipv6mr_ifindex/IPV6_ADD_MEMBERSHIP names. + imreq6.ipv6mr_ifindex = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_ADD_MEMBERSHIP, &imreq6, sizeof(imreq6)) == 0) { +#else + imreq6.ipv6mr_interface = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, &imreq6, sizeof(imreq6)) == 0) { +#endif + joined = true; + return true; + } + return false; + }); + if (!joined) { + return false; + } + if (if_index_out != nullptr) { +#ifdef USE_ZEPHYR + *if_index_out = imreq6.ipv6mr_ifindex; +#else + *if_index_out = imreq6.ipv6mr_interface; +#endif + } + return true; +#else + errno = EINVAL; + return false; +#endif // USE_NETWORK_IPV6 +} + +#if USE_NETWORK_IPV6 +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { +#if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + // Upstream lwIP (Zephyr's fork, LibreTiny) has no IPV6_MULTICAST_IF; outgoing + // multicast follows the default netif. Zephyr gains ZSOCK_IPV6_MULTICAST_IF in NCS 3.4.0. + (void) sock; + (void) if_index_in; + return true; +#else + uint32_t ifindex = if_index_in; + if (ifindex == 0) { + foreach_eligible_ipv6_if([&](unsigned int idx) { + ifindex = idx; + return true; + }); + if (ifindex == 0) { + return false; + } + } + return sock->setsockopt(IPPROTO_IPV6, IPV6_MULTICAST_IF, &ifindex, sizeof(ifindex)) == 0; +#endif +} +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 if (strchr(ip_address, ':') != nullptr) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 9d7dedbbbf9..d1a70c5aa84 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -94,6 +94,7 @@ inline bool Socket::ready() const { /// Create a socket of the given domain, type and protocol. std::unique_ptr socket(int domain, int type, int protocol); /// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol. +/// Not registered with the main loop's select() - use socket_ip_loop_monitored() if you need that. std::unique_ptr socket_ip(int type, int protocol); /// Create a socket and monitor it for data in the main loop. @@ -149,6 +150,27 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +/// Join a multicast group on the given socket. +/// @param sock The socket to join on +/// @param ip_address Null-terminated multicast address string (IPv4 or IPv6) +/// @param if_index_out If non-null, receives the interface index used (IPv4 always writes 0). For +/// link-local IPv6 groups, the caller must also copy this into sin6_scope_id +/// before bind() -- POSIX requires it, LwIP ignores it. +/// @return true on success, false on failure (errno set) +bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); + +#if USE_NETWORK_IPV6 +/// Configure the outgoing IPv6 multicast interface on the given socket. +/// @param sock The socket to configure +/// @param if_index_in Non-zero: use this interface index directly. Zero (default): probe for the first eligible +/// interface via foreach_eligible_ipv6_if(). On single-interface devices (typical for ESP32/ESP8266) this is always +/// correct. On multi-homed hosts the "first eligible" interface is arbitrary; pass the desired index explicitly. +/// @return true on success, false on failure (errno set). A no-op returning true on LWIP_SOCKETS and Zephyr. +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in = 0); +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + /// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or /// errno) on fd implementations and the failure the callbacks recorded on raw lwip. #ifdef USE_SOCKET_IMPL_LWIP_TCP diff --git a/tests/components/socket/test-ipv6.bk72xx-ard.yaml b/tests/components/socket/test-ipv6.bk72xx-ard.yaml new file mode 100644 index 00000000000..6075623b54a --- /dev/null +++ b/tests/components/socket/test-ipv6.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + network_enable_ipv6: "true" + +packages: + socket: !include common.yaml diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp new file mode 100644 index 00000000000..31fe286a865 --- /dev/null +++ b/tests/components/socket/test_multicast_host.cpp @@ -0,0 +1,96 @@ +#include + +#include + +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// ========================================================================= +// join_multicast_group — EINVAL on malformed input +// ========================================================================= + +TEST(JoinMulticastGroup, MalformedIPv4YieldsEINVAL) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "not-an-ip")); + EXPECT_EQ(errno, EINVAL); +} + +TEST(JoinMulticastGroup, MalformedIPv6YieldsEINVAL) { + // ':' present but parse fails — EINVAL regardless of USE_NETWORK_IPV6 + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "gg::not-ipv6")); + EXPECT_EQ(errno, EINVAL); +} + +// ========================================================================= +// join_multicast_group — IPv4 IP_ADD_MEMBERSHIP +// ========================================================================= + +TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 99; + // A host with no multicast route (e.g. an isolated container) cannot join + if (join_multicast_group(sock.get(), "239.0.60.53", &if_index)) { + EXPECT_EQ(if_index, 0u); + } else { + EXPECT_EQ(if_index, 99u); + } +} + +TEST(JoinMulticastGroup, IPv4JoinNullIfIndexOut) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_TRUE(join_multicast_group(sock.get(), "239.0.60.53", nullptr) || errno != 0); +} + +// ========================================================================= +// join_multicast_group — IPv6 and set_ipv6_multicast_if +// ========================================================================= + +#if USE_NETWORK_IPV6 + +TEST(JoinMulticastGroup, IPv6JoinSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 0; + bool ok = join_multicast_group(sock.get(), "ff12::cafe", &if_index); + if (ok) { + EXPECT_GT(if_index, 0u); + } else { + EXPECT_EQ(errno, EADDRNOTAVAIL); + } +} + +TEST(SetIPv6MulticastIf, ProbeWithZeroIndexSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_TRUE(set_ipv6_multicast_if(sock.get(), 0) || errno == EADDRNOTAVAIL); +} + +TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { + auto sock_join = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + auto sock_send = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock_join, nullptr); + ASSERT_NE(sock_send, nullptr); + uint32_t if_index = 0; + bool joined = join_multicast_group(sock_join.get(), "ff12::cafe", &if_index); + if (joined && if_index != 0) { + EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); + } +} + +#endif // USE_NETWORK_IPV6 + +} // namespace esphome::socket::testing + +#endif // USE_HOST From 153c51e39f118be0183c3703448d9c8a95fe987c Mon Sep 17 00:00:00 2001 From: sh1der Date: Sun, 11 Oct 2026 22:24:50 +0200 Subject: [PATCH 249/435] [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516) 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/gree/gree.cpp | 2 -- esphome/components/gree/gree.h | 8 ++++++- esphome/components/gree/switch/__init__.py | 25 ++++++++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index f47be4aea03..0e02f20080c 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -37,7 +37,6 @@ void GreeClimate::transmit_state() { remote_state[1] = this->temperature_(); if (model == GREE_YAN) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } @@ -61,7 +60,6 @@ void GreeClimate::transmit_state() { } if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index c2616295735..a85878e764d 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00; static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9. +static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10; +static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20; +static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40; +static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80; + // Model codes enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; - uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] + uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise }; } // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 9bec3751d6b..b48d32b9001 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -17,12 +17,22 @@ CONF_HEALTH = "health" CONF_XFAN = "xfan" CONF_GREE_ID = "gree_id" -# Switch configurations: (config_key, display_name, bit_mask, icon) +# Switch configurations: (config_key, display_name, mode_bit, icon) SWITCH_CONFIGS = ( - (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), - (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), - (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), - (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), + ( + CONF_TURBO, + "Gree Turbo Switch", + gree_ns.GREE_MODE_BIT_TURBO, + "mdi:car-turbocharger", + ), + (CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"), + ( + CONF_XFAN, + "Gree X-FAN Switch", + gree_ns.GREE_MODE_BIT_XFAN, + "mdi:wall-sconce-flat", + ), ) SUPPORTED_MODELS = { @@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(key): switch.switch_schema( GreeModeBitSwitch, icon=icon, - default_restore_mode="RESTORE_DEFAULT_OFF", + # The display light was on before the switches existed, so it stays on by default + default_restore_mode="RESTORE_DEFAULT_ON" + if key == CONF_LIGHT + else "RESTORE_DEFAULT_OFF", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, ) From bea0ef264aad87fa2be22b3789920d1c6b6eaf46 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:36:32 +1100 Subject: [PATCH 250/435] [lvgl] Migrate to 9.6.0 (#20257) Co-authored-by: Claude --- esphome/components/lvgl/__init__.py | 40 +++++-- esphome/components/lvgl/automation.py | 5 +- .../components/lvgl/binary_sensor/__init__.py | 4 +- esphome/components/lvgl/defines.py | 27 ++++- esphome/components/lvgl/lvgl_esphome.cpp | 35 +++++- esphome/components/lvgl/lvgl_esphome.h | 3 + esphome/components/lvgl/schemas.py | 1 - esphome/components/lvgl/trigger.py | 6 +- esphome/components/lvgl/widgets/__init__.py | 106 +++++++++--------- esphome/components/lvgl/widgets/arc.py | 4 +- esphome/components/lvgl/widgets/canvas.py | 2 +- esphome/components/lvgl/widgets/lv_list.py | 3 +- esphome/components/lvgl/widgets/meter.py | 3 +- esphome/components/lvgl/widgets/msgbox.py | 7 +- esphome/idf_component.yml | 2 +- platformio.ini | 4 +- .../lvgl/config/lv_conf_check_second.yaml | 41 +++++++ .../lvgl/config/lv_conf_check_warn.yaml | 27 +++++ .../lvgl/config/lv_conf_defaults.yaml | 26 +++++ .../lvgl/config/lv_conf_options.yaml | 29 +++++ tests/component_tests/lvgl/test_list.py | 5 +- tests/component_tests/lvgl/test_lv_conf.py | 80 +++++++++++++ .../lvgl/test_multi_conf_validate.py | 11 +- tests/components/lvgl/lvgl-package.yaml | 11 ++ .../lvgl/test-idf6.esp32-p4-idf.yaml | 22 ++++ tests/components/lvgl/test.esp32-p4-idf.yaml | 1 + tests/components/lvgl/test.host.yaml | 7 +- .../integration/test_lvgl_headless_render.py | 2 +- 28 files changed, 413 insertions(+), 101 deletions(-) create mode 100644 tests/component_tests/lvgl/config/lv_conf_check_second.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_check_warn.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_defaults.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_options.yaml create mode 100644 tests/component_tests/lvgl/test_lv_conf.py create mode 100644 tests/components/lvgl/test-idf6.esp32-p4-idf.yaml diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 66aaac5301f..9b2f41e0635 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, ) from esphome.components.image import ( CONF_OPAQUE, @@ -158,7 +159,7 @@ def as_macro(macro, value): return f"#define {macro} {value}" -LVGL_VERSION = "9.5.0" +LVGL_VERSION = "9.6.0" LV_CONF_FILENAME = "lv_conf.h" LV_CONF_H_FORMAT = """\ #pragma once @@ -324,10 +325,16 @@ async def to_code(configs): # Skip compiling lvgl examples add_idf_sdkconfig_option("CONFIG_LV_BUILD_EXAMPLES", False) add_idf_sdkconfig_option("CONFIG_LV_BUILD_DEMOS", False) + # Match LV_USE_STDLIB_MALLOC in lv_conf.h. This also drops the builtin malloc + # options, which LVGL 9.5 builds leave set to deprecated values. + add_idf_sdkconfig_option("CONFIG_LV_USE_CUSTOM_MALLOC", True) if get_esp32_variant() == VARIANT_ESP32P4: add_idf_sdkconfig_option("CONFIG_LV_DRAW_BUF_ALIGN", 64) - # disable use of PPA for fills until upstream bugs fixed - df.add_define("LV_USE_PPA", "0") + # The LVGL PPA driver uses cache functions added in ESP-IDF 6.0 + if idf_version() >= cv.Version(6, 0, 0): + df.add_define("LV_USE_PPA", "1") + else: + df.add_define("LV_USE_PPA", "0") df.add_define("LV_DRAW_BUF_ALIGN", "64") else: df.add_define("LV_DRAW_BUF_ALIGN", "32") @@ -337,6 +344,8 @@ async def to_code(configs): cg.add_library("lvgl/lvgl", LVGL_VERSION) df.add_define("LV_DRAW_BUF_STRIDE_ALIGN", "1") df.add_define("LV_USE_DRAW_SW", "1") + # LVGL 9.6 rejects 0 here unless tiled rendering is disabled + df.add_define("LV_DRAW_SW_DRAW_UNIT_CNT", "1") df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") @@ -356,15 +365,22 @@ async def to_code(configs): "LVGL_LOG_LEVEL", cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) - df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) + # Big-endian displays get LVGL to render directly in swapped byte order + display_format = ( + "RGB565_SWAPPED" if config_0[CONF_BYTE_ORDER] == BYTE_ORDER_BIG else "RGB565" + ) + df.add_define("LV_COLOR_FORMAT_DEFAULT", f"LV_COLOR_FORMAT_{display_format}") for font in df.get_lv_fonts_used(): df.add_define(f"LV_FONT_{font.upper()}") - - if config_0[CONF_COLOR_DEPTH] == 16: + df.add_define("LV_OBJ_STYLE_CACHE", "1") + if any(config[df.CONF_CHECK_ARGS] for config in configs): + df.add_define("LV_USE_CHECK_ARG", "1") df.add_define( - "LV_COLOR_16_SWAP", - "1" if config_0[CONF_BYTE_ORDER] == "big_endian" else "0", + "LV_CHECK_ARG_LOG_MODE", + f"LV_CHECK_ARG_LOG_MODE_{df.LV_CHECK_ARG_LOG_MODES[config_0[CONF_LOG_LEVEL]]}", ) + else: + df.add_define("LV_USE_CHECK_ARG", "0") df.add_define( "LV_COLOR_CHROMA_KEY", await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]), @@ -504,8 +520,9 @@ async def to_code(configs): if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): df.add_define("LV_THEME_DEFAULT_DARK", "1") - # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending - lv_image_formats = {"RGB565", "ARGB8888"} + # The display buffer format is always needed, RGB565 is used by canvas buffers and + # ARGB8888 for layer blending + lv_image_formats = {"RGB565", display_format, "ARGB8888"} for image_id in get_lv_images_used(): await cg.get_variable(image_id) @@ -519,7 +536,7 @@ async def to_code(configs): if image_type == ImageRGB565: lv_image_formats.add("RGB565A8" if transparent else "RGB565") if image_type == ImageRGB: - lv_image_formats.add("ARGB8888" if transparent else "RGB8888") + lv_image_formats.add("ARGB8888" if transparent else "RGB888") if df.is_defined("LV_GRADIENT_MAX_STOPS"): lv_image_formats.add("RGB888") for fmt in lv_image_formats: @@ -595,6 +612,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), + cv.Optional(df.CONF_CHECK_ARGS, default=False): cv.boolean, cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index d797688a7e8..4e8698f33c0 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTION, CONF_GROUP, + CONF_HIDDEN, CONF_ID, CONF_POSITION, CONF_ROTATION, @@ -354,7 +355,7 @@ async def obj_enable_to_code(config, action_id, template_arg, args): ) async def obj_hide_to_code(config, action_id, template_arg, args): async def do_hide(widget: Widget): - widget.add_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, True) widgets = [widget.outer or widget for widget in await get_widgets(config)] return await action_to_code(widgets, do_hide, action_id, template_arg, args) @@ -365,7 +366,7 @@ async def obj_hide_to_code(config, action_id, template_arg, args): ) async def obj_show_to_code(config, action_id, template_arg, args): async def do_show(widget: Widget): - widget.clear_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, False) if widget.move_to_foreground: lv_obj.move_foreground(widget.obj) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index fc7317f8b97..e658735c750 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -6,7 +6,7 @@ from esphome.components.binary_sensor import ( import esphome.config_validation as cv from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..defines import CONF_CLICKABLE, CONF_WIDGET, LvConstant from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t from ..widgets import Widget, get_widgets @@ -41,7 +41,7 @@ async def to_code(config): ctx.add(sensor.publish_initial_state(test_expr)) if is_pressed: events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] - widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + widget.set_flag(CONF_CLICKABLE, True) else: events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index c019dff96e0..854ef0fb494 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -477,6 +477,16 @@ LV_LOG_LEVELS = { "NONE": "NONE", } +# Log output for failed LVGL argument checks, chosen from the LVGL log level +LV_CHECK_ARG_LOG_MODES = { + "VERBOSE": "VERBOSE", + "DEBUG": "VERBOSE", + "INFO": "MINIMAL", + "WARN": "MINIMAL", + "ERROR": "NONE", + "NONE": "NONE", +} + LV_LONG_MODES = LvConstant( "LV_LABEL_LONG_", "WRAP", @@ -606,13 +616,20 @@ OBJ_FLAGS = ( "ignore_layout", "floating", "overflow_visible", + "send_draw_task_events", + "event_trickle", + "state_trickle", + "radio_button", + "flex_in_new_track", "layout_1", "layout_2", - "send_draw_task_events", "widget_1", "widget_2", ) -LV_OBJ_FLAG = LvConstant("LV_OBJ_FLAG_", *OBJ_FLAGS) +# Flags whose lv_obj_set_() setter has a different name +FLAG_SETTER_ALIASES = {"layout_1": "flex_in_new_track"} +# Custom flags that have no lv_obj_set_() setter +CUSTOM_FLAGS = ("layout_2", "widget_1", "widget_2") ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL") BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") @@ -703,6 +720,7 @@ CONF_DISP_BG_OPA = "disp_bg_opa" CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" +CONF_CLICKABLE = "clickable" CONF_CLOSE_BUTTON = "close_button" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" @@ -727,6 +745,7 @@ CONF_FLEX_ALIGN_CROSS = "flex_align_cross" CONF_FLEX_ALIGN_TRACK = "flex_align_track" CONF_FLEX_GROW = "flex_grow" CONF_FREEZE = "freeze" +CONF_CHECK_ARGS = "check_args" CONF_DARK_MODE = "dark_mode" CONF_FULL_REFRESH = "full_refresh" CONF_GRADIENTS = "gradients" @@ -795,6 +814,7 @@ CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" @@ -891,7 +911,8 @@ def get_part_state_selector(part: str, state: str) -> MockObj: # fmt: off LV_COLOR_FORMATS = ( - "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", + "RGB565", "RGB565_SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "ARGB8888_PREMULTIPLIED", "L8", "AL88", + "A8", "I1", ) LV_DEFINES = ( diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index e35cc0c48bf..0b375404d74 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -6,6 +6,7 @@ #include "core/lv_global.h" #include "core/lv_obj_class_private.h" +#include "core/lv_obj_private.h" #include @@ -225,6 +226,36 @@ void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_ev add_event_cb(obj, callback, event3, user_data); } +void LvglComponent::lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value) { + // LVGL 9.6 keeps these flags in spec_attr->user_flags, in the same bits as its deprecated lv_obj_add_flag() + uint8_t mask; + switch (flag) { + case LV_OBJ_FLAG_LAYOUT_2: + mask = 1 << 1; + break; + case LV_OBJ_FLAG_WIDGET_1: + mask = 1 << 2; + break; + case LV_OBJ_FLAG_WIDGET_2: + mask = 1 << 3; + break; + default: + return; + } + if (value) { + lv_obj_spec_attr_t *spec_attr = lv_obj_allocate_spec_attr(obj); + if (spec_attr == nullptr) + return; + spec_attr->user_flags |= mask; + } else if (obj->spec_attr != nullptr) { + obj->spec_attr->user_flags &= ~mask; + } + if (flag == LV_OBJ_FLAG_LAYOUT_2) { + lv_obj_mark_layout_as_dirty(lv_obj_get_parent(obj)); + lv_obj_mark_layout_as_dirty(obj); + } +} + void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); @@ -822,7 +853,7 @@ void LvglComponent::setup() { } this->draw_buf_ = static_cast(buffer); this->set_resolution_(); - lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_RGB565); + lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_DEFAULT); lv_display_set_flush_cb(this->disp_, static_flush_cb); lv_display_set_user_data(this->disp_, this); lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); @@ -994,7 +1025,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_clickable(obj, false); LV_UNUSED(class_p); } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8b7397c4cd2..4da8a6a1a30 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -269,6 +269,9 @@ class LvglComponent final : public PollingComponent { static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3, void *user_data = nullptr); + // Set or clear LV_OBJ_FLAG_LAYOUT_2, LV_OBJ_FLAG_WIDGET_1 or LV_OBJ_FLAG_WIDGET_2, which have no setter in LVGL 9.6 + static void lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value); + // change the state of a widget and fire an event if changed (only needed for CHECKED) static void lv_obj_set_state_value(lv_obj_t *obj, lv_state_t state, bool value) { diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 924c17fb547..2d879e8d43a 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -391,7 +391,6 @@ SET_STATE_SCHEMA = cv.Schema( ) # Setting object flags FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS}) -FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of) VALUE_TRIGGER_SCHEMA = { cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of( diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 56dcf81a797..cd118595f1a 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -17,6 +17,8 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, + CONF_CLICKABLE, + CONF_SCROLLABLE, DIRECTIONS, LV_DISPLAY_EVENT_MAP, LV_DISPLAY_EVENT_TRIGGERS, @@ -72,7 +74,7 @@ async def generate_triggers(): event: conf for event, conf in config.items() if event in all_triggers }.items(): conf = conf[0] - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger(conf, w, event) for event, conf in { @@ -82,7 +84,7 @@ async def generate_triggers(): dir = event[9:].upper() dir = {"UP": "TOP", "DOWN": "BOTTOM"}.get(dir, dir) dir = DIRECTIONS.mapper(dir) - w.clear_flag("LV_OBJ_FLAG_SCROLLABLE") + w.set_flag(CONF_SCROLLABLE, False) selected = literal( f"lv_indev_get_gesture_dir(lv_indev_active()) == {dir}" ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d69e90056fd..b48123b9022 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable import sys from typing import Any @@ -37,6 +37,8 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + CUSTOM_FLAGS, + FLAG_SETTER_ALIASES, LOGGER, OBJ_FLAGS, PARTS, @@ -47,13 +49,11 @@ from ..defines import ( get_part_state_selector, get_styles_used, get_widget_map, - join_enums, literal, next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( - LvConditional, add_line_marks, lv, lv_add, @@ -295,7 +295,9 @@ class Widget: This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ - def __init__(self, var, wtype: WidgetType, config: dict = None): + def __init__( + self, var: MockObj | ID, wtype: WidgetType, config: dict | None = None + ) -> None: self.var = var self.type = wtype self.config = config @@ -314,39 +316,50 @@ class Widget: self.parent = None @staticmethod - def create(name, var, wtype: WidgetType, config: dict = None): + def create( + name: ID | str, var: MockObj, wtype: WidgetType, config: dict | None = None + ) -> "Widget": w = Widget(var, wtype, config) get_widget_map()[name] = w return w - def set_state(self, state: MockObj, value: bool | Expression): + def set_state(self, state: MockObj, value: bool | Expression) -> None: lv_add(lvgl_static.lv_obj_set_state_value(self.obj, state, value)) - def has_state(self, state: MockObj): + def has_state(self, state: MockObj) -> MockObj: return lv_expr.obj_has_state(self.obj, state) - def is_pressed(self): + def is_pressed(self) -> MockObj: return self.has_state(LV_STATE.PRESSED) - def is_checked(self): + def is_checked(self) -> MockObj: return self.has_state(LV_STATE.CHECKED) - def add_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.add_flag(self.obj, literal(flag)) + def set_flag(self, flag: str, value: bool | Expression) -> None: + """ + Set or clear an object flag, given as the lower case name without the LV_OBJ_FLAG_ prefix + """ + flag = FLAG_SETTER_ALIASES.get(flag, flag) + if flag in CUSTOM_FLAGS: + lv_add( + lvgl_static.lv_obj_set_custom_flag( + self.obj, literal(f"LV_OBJ_FLAG_{flag.upper()}"), value + ) + ) + else: + lv_obj.call(f"set_{flag}", self.obj, value) - def clear_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.remove_flag(self.obj, literal(flag)) - - def add_style(self, style_id, state=LV_STATE.DEFAULT): + def add_style(self, style_id: ID, state: MockObj | str = LV_STATE.DEFAULT) -> None: lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( - self, prop, value, animated: bool = None, lv_name=None, processor=None - ): + self, + prop: str, + value: Any, + animated: bool | None = None, + lv_name: str | None = None, + processor: LValidator | Callable[[Any], Awaitable[Any]] | None = None, + ) -> None: """ Set a property of the widget. :param prop: The property name @@ -387,11 +400,13 @@ class Widget: literal("LV_ANIM_ON" if animated else "LV_ANIM_OFF"), ) - def get_property(self, prop, ltype=None): + def get_property(self, prop: str, ltype: str | None = None) -> cg.RawExpression: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop: str, value, state=LV_STATE.DEFAULT): + def set_style( + self, prop: str, value: Any, state: MockObj | str = LV_STATE.DEFAULT + ) -> None: if value is None: return get_styles_used().add(prop) @@ -399,22 +414,22 @@ class Widget: value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) - def __type_base(self): + def __type_base(self) -> str: wtype = self.type.w_type base = str(wtype) if base.startswith("Lv"): return f"{wtype}".removeprefix("Lv").removesuffix("Type").lower() return f"{wtype}".removeprefix("lv_").removesuffix("_t") - def __str__(self): + def __str__(self) -> str: return f"({self.var}, {self.type})" - def get_args(self): + def get_args(self) -> list[tuple[MockObj, str]]: if isinstance(self.type.w_type, LvType): return self.type.w_type.args.copy() return [(lv_obj_t_ptr, "obj")] - def get_value(self): + def get_value(self) -> Expression: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -422,7 +437,7 @@ class Widget: return result return self.obj - def get_values(self): + def get_values(self) -> list[Expression]: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -430,13 +445,13 @@ class Widget: return [result] return [self.obj] - def get_number_value(self): + def get_number_value(self) -> Expression: value = self.type.mock_obj.get_value(self.obj) if self.scale == 1.0: return value return value / float(self.scale) - def is_selected(self): + def is_selected(self) -> MockObj | None: """ Overridable property to determine if the widget is selected. Will be None except for matrix buttons @@ -444,7 +459,7 @@ class Widget: """ return - def get_scale(self): + def get_scale(self) -> float: return self.type.get_scale(self.config) @@ -630,29 +645,14 @@ async def set_obj_properties(w: Widget, config): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) props = parts[CONF_MAIN][CONF_DEFAULT] - lambs = {} - flag_set = set() - flag_clr = set() - for prop, value in {k: v for k, v in props.items() if k in OBJ_FLAGS}.items(): + for flag, value in props.items(): + if flag not in OBJ_FLAGS: + continue if isinstance(value, cv.Lambda): - lambs[prop] = value - elif value: - flag_set.add(prop) - else: - flag_clr.add(prop) - if flag_set: - adds = join_enums(flag_set, "LV_OBJ_FLAG_") - w.add_flag(adds) - if flag_clr: - clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") - w.clear_flag(clrs) - for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) - flag = f"LV_OBJ_FLAG_{key.upper()}" - with LvConditional(call_lambda(lamb)) as cond: - w.add_flag(flag) - cond.else_() - w.clear_flag(flag) + value = call_lambda( + await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) + ) + w.set_flag(flag, value) for key, value in config.get(CONF_STATE, {}).items(): if isinstance(value, cv.Lambda): diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index ac993cc3829..4739412e09e 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -13,12 +13,12 @@ from ..defines import ( ARC_MODES, CONF_ADJUSTABLE, CONF_CHANGE_RATE, + CONF_CLICKABLE, CONF_END_ANGLE, CONF_INDICATOR, CONF_KNOB, CONF_MAIN, CONF_START_ANGLE, - LV_OBJ_FLAG, LV_PART, ) from ..lv_validation import ( @@ -85,7 +85,7 @@ class ArcType(NumberType): if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) - w.clear_flag(LV_OBJ_FLAG.CLICKABLE) + w.set_flag(CONF_CLICKABLE, False) elif CONF_GROUP not in config: # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 2137acb3f6a..9dd5e84419e 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -99,7 +99,7 @@ class CanvasType(WidgetType): if config[CONF_TRANSPARENT]: color_format = "LV_COLOR_FORMAT_ARGB8888" else: - color_format = "LV_COLOR_FORMAT_NATIVE" + color_format = "LV_COLOR_FORMAT_RGB565" # LVGL 9.4: LV_CANVAS_BUF_SIZE(width, height, bits_per_pixel, stride) # stride is 0 for default (width * bytes_per_pixel) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index f41cb8e4e96..98e1838f0f3 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -20,6 +20,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from ..automation import action_to_code from ..defines import ( CONF_ALIGN_TO, + CONF_CLICKABLE, CONF_MAIN, CONF_PAD_ROW, CONF_SCROLLBAR, @@ -441,7 +442,7 @@ async def _wire_dynamic_triggers(w: Widget, config: dict) -> None: for event, conf in { event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS }.items(): - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger( conf[0], event_target, event, attach_obj=w.obj, user_data=user_data ) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index fedc79383dc..a49d58dc4db 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -38,7 +38,6 @@ from ..defines import ( CONF_SRC, CONF_START_VALUE, CONF_TICKS, - LV_OBJ_FLAG, LV_PART, LV_SCALE_MODE, add_lv_use, @@ -457,7 +456,7 @@ class MeterType(WidgetType): LV_EVENT.DRAW_TASK_ADDED, nullptr, ) - lv.obj_add_flag(scale_var, LV_OBJ_FLAG.SEND_DRAW_TASK_EVENTS) + lv_obj.set_send_draw_task_events(scale_var, True) if t == CONF_LINE: # Needle represented by a line diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index 29087009cb9..44d16bf6141 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -1,5 +1,5 @@ from esphome import codegen as cg, config_validation as cv -from esphome.const import CONF_BUTTON, CONF_ID, CONF_TEXT +from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT from esphome.core import ID from esphome.cpp_generator import MockObjClass from esphome.cpp_types import nullptr @@ -14,7 +14,6 @@ from ..defines import ( CONF_MSGBOXES, CONF_SRC, CONF_TITLE, - LV_OBJ_FLAG, TYPE_FLEX, add_lv_use, add_warning, @@ -141,7 +140,7 @@ async def msgbox_to_code(top_layer, conf): percent100 = await pixels_or_percent.process(1.0) lv_obj.set_size(outer, percent100, percent100) outer_widget.add_style(await OUTER_STYLE.get_var()) - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) lv.msgbox_add_title(msgbox, title) lv.msgbox_add_text(msgbox, text) lv_obj.set_style_align(msgbox, literal("LV_ALIGN_CENTER"), 0) @@ -153,7 +152,7 @@ async def msgbox_to_code(top_layer, conf): await widget_to_code(button, header_button_spec, msgbox) async with LambdaContext(EVENT_ARG, where=messagebox_id) as close_action: - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) if close_button: with LocalVariable( "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 8ac76cd41fa..670145cfe01 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -107,7 +107,7 @@ dependencies: rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0" lvgl/lvgl: - version: 9.5.0 + version: 9.6.0 fastled/FastLED: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b diff --git a/platformio.ini b/platformio.ini index 7534b748d1b..7b3414a32a7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -108,7 +108,7 @@ build_unflags = lib_deps = esphome/dlms_parser@2.2.0 ; dlms_meter bblanchon/ArduinoJson@7.4.3 ; json - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. [common:esp8266-arduino] @@ -646,7 +646,7 @@ extends = common platform = platformio/native lib_deps = esphome/noise-c@0.1.31 ; used by noise (api, ota) - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl build_flags = ${common.build_flags} -DUSE_HOST diff --git a/tests/component_tests/lvgl/config/lv_conf_check_second.yaml b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml new file mode 100644 index 00000000000..a5ccd0e68d4 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml @@ -0,0 +1,41 @@ +esphome: + name: test-lv-conf-check-second + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + id: display_a + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + - platform: mipi_spi + id: display_b + data_rate: 20MHz + model: st7735 + cs_pin: GPIO4 + dc_pin: GPIO5 + +lvgl: + - id: lvgl_a + displays: display_a + widgets: + - label: + text: Hello + - id: lvgl_b + displays: display_b + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml new file mode 100644 index 00000000000..769032f8393 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-lv-conf-check-warn + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_defaults.yaml b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml new file mode 100644 index 00000000000..94950a3b438 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-lv-conf-defaults + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_options.yaml b/tests/component_tests/lvgl/config/lv_conf_options.yaml new file mode 100644 index 00000000000..969091c086a --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_options.yaml @@ -0,0 +1,29 @@ +esphome: + name: test-lv-conf-options + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + byte_order: little_endian + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + check_args: true + log_level: DEBUG + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/test_list.py b/tests/component_tests/lvgl/test_list.py index 368a3a49905..e93d858ad5c 100644 --- a/tests/component_tests/lvgl/test_list.py +++ b/tests/component_tests/lvgl/test_list.py @@ -287,10 +287,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None: def test_add_button_with_checkable_flag(main_cpp: str) -> None: assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp - assert ( - "lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));" - in main_cpp - ) + assert "lv_obj_set_checkable(dyn_button_VAR_, true);" in main_cpp assert ( 'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp ) diff --git a/tests/component_tests/lvgl/test_lv_conf.py b/tests/component_tests/lvgl/test_lv_conf.py new file mode 100644 index 00000000000..8b93c011661 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf.py @@ -0,0 +1,80 @@ +"""Tests for the LVGL configuration defines written to lv_conf.h.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df +from esphome.components.lvgl.schemas import WIDGET_TYPES + +# LVGL 9.6 stops the build with #error when an enabled option "selects" another +# option that is disabled. ESPHome sets every unused LV_USE_* to 0, so each widget +# must list these in get_uses(). +LVGL_SELECTS = ( + ("SLIDER", "BAR"), + ("BARCODE", "CANVAS"), + ("QRCODE", "CANVAS"), + ("CANVAS", "IMAGE"), + ("DROPDOWN", "LABEL"), + ("MSGBOX", "LABEL"), + ("ROLLER", "LABEL"), + ("TEXTAREA", "LABEL"), +) + + +def test_default_lv_conf_defines( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("lv_conf_defaults.yaml")) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == "LV_COLOR_FORMAT_RGB565_SWAPPED" + assert defines["LV_DRAW_SW_SUPPORT_RGB565_SWAPPED"] == "1" + assert defines["LV_OBJ_STYLE_CACHE"] == "1" + assert defines["LV_USE_CHECK_ARG"] == "0" + assert "LV_CHECK_ARG_LOG_MODE" not in defines + # Both are deprecated in LVGL 9.6 and produce build warnings + assert "LV_COLOR_DEPTH" not in defines + assert "LV_COLOR_16_SWAP" not in defines + + +@pytest.mark.parametrize( + ("yaml_file", "color_format", "log_mode"), + [ + ("lv_conf_options.yaml", "RGB565", "VERBOSE"), + ("lv_conf_check_warn.yaml", "RGB565_SWAPPED", "MINIMAL"), + # Only the second LVGL instance sets check_args + ("lv_conf_check_second.yaml", "RGB565_SWAPPED", "MINIMAL"), + ], +) +def test_lv_conf_options( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, + color_format: str, + log_mode: str, +) -> None: + generate_main(component_config_path(yaml_file)) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == f"LV_COLOR_FORMAT_{color_format}" + assert defines["LV_USE_CHECK_ARG"] == "1" + assert defines["LV_CHECK_ARG_LOG_MODE"] == f"LV_CHECK_ARG_LOG_MODE_{log_mode}" + + +def test_check_arg_log_modes_cover_all_log_levels() -> None: + assert df.LV_CHECK_ARG_LOG_MODES.keys() == df.LV_LOG_LEVELS.keys() + + +@pytest.mark.parametrize("widget_name", sorted(WIDGET_TYPES)) +def test_widget_uses_satisfy_lvgl_selects(widget_name: str) -> None: + widget_type = WIDGET_TYPES[widget_name] + df.add_lv_use(widget_type.name, *widget_type.get_uses()) + enabled = {use.upper() for use in df.get_lv_uses()} + for option, required in LVGL_SELECTS: + if option in enabled: + assert required in enabled, ( + f"'{widget_name}' enables LV_USE_{option}, which needs LV_USE_{required}" + ) diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py index c44b40317e8..9ec73ca6182 100644 --- a/tests/component_tests/lvgl/test_multi_conf_validate.py +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -10,11 +10,15 @@ from esphome.config_validation import Invalid def _config( - displays: list[str], theme: dict | None = None, debug_outline: bool = False + displays: list[str], + theme: dict | None = None, + debug_outline: bool = False, + check_args: bool = False, ) -> dict: config = { df.CONF_DISPLAYS: displays, "log_level": "WARN", + df.CONF_CHECK_ARGS: check_args, "color_depth": 16, "byte_order": "big_endian", df.CONF_TRANSPARENCY_KEY: 0x000400, @@ -73,3 +77,8 @@ class TestDebugOutlineOnMultipleInstances: _config(["disp_b"], debug_outline=True), ] multi_conf_validate(configs) + + +def test_check_args_may_differ_between_instances() -> None: + configs = [_config(["disp_a"], check_args=True), _config(["disp_b"])] + multi_conf_validate(configs) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 18a7fd0913a..681eb41b5b5 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,7 @@ lvgl: id: lvgl_id rotation: 90 log_level: debug + check_args: true resume_on_input: true paused: true update_when_display_idle: true @@ -976,6 +977,16 @@ lvgl: - spinner: align: right_mid send_draw_task_events: true + event_trickle: true + state_trickle: true + radio_button: true + flex_in_new_track: !lambda return true; + - spinner: + align: top_mid + layout_1: true + layout_2: true + widget_1: !lambda return false; + widget_2: false - image: id: lv_image src: cat_image diff --git a/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e0332caaed0 --- /dev/null +++ b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml @@ -0,0 +1,22 @@ +# LVGL only uses the PPA to draw on the ESP32-P4 with ESP-IDF 6.0 or later +esp32: + framework: + version: 6.0.1 + +display: + - platform: mipi_dsi + model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C + +lvgl: + widgets: + - obj: + bg_color: 0x2040C0 + widgets: + - label: + text: PPA + +psram: + +esp_ldo: + - channel: 3 + voltage: 2.5V diff --git a/tests/components/lvgl/test.esp32-p4-idf.yaml b/tests/components/lvgl/test.esp32-p4-idf.yaml index 5fd93702554..a7a8d13ffc5 100644 --- a/tests/components/lvgl/test.esp32-p4-idf.yaml +++ b/tests/components/lvgl/test.esp32-p4-idf.yaml @@ -3,6 +3,7 @@ display: model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C lvgl: byte_order: little_endian + check_args: true rotation: 90 psram: diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 22d051da8eb..1df27d82f73 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -1,5 +1,5 @@ display: - - platform: sdl + - platform: snapshot id: sdl0 auto_clear_enabled: false dimensions: @@ -11,11 +11,6 @@ display: width: 480 height: 480 -touchscreen: - - platform: sdl - display: sdl0 - sdl_id: sdl0 - lvgl: - id: lvgl_0 default_font: space16 diff --git a/tests/integration/test_lvgl_headless_render.py b/tests/integration/test_lvgl_headless_render.py index 1c60e49604d..5a668a23027 100644 --- a/tests/integration/test_lvgl_headless_render.py +++ b/tests/integration/test_lvgl_headless_render.py @@ -30,7 +30,7 @@ EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd4865509 # Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was # generated against. A version bump can shift anti-aliasing enough to change the hash even though # nothing is actually wrong -- if this test fails, check that first before regenerating the hash. -EXPECTED_LVGL_VERSION = "9.5.0" +EXPECTED_LVGL_VERSION = "9.6.0" @pytest.mark.asyncio From 2ab06fa937d93af213307efab50f2e8360d4f519 Mon Sep 17 00:00:00 2001 From: Max Khrul'ov Date: Sun, 11 Oct 2026 23:39:22 +0300 Subject: [PATCH 251/435] [pipsolar] Add QET/QLT total energy sensors (#17347) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston --- esphome/components/pipsolar/pipsolar.cpp | 17 +++++++++++++++++ esphome/components/pipsolar/pipsolar.h | 11 +++++++++++ .../components/pipsolar/sensor/__init__.py | 19 +++++++++++++++++++ .../pipsolar/text_sensor/__init__.py | 4 ++++ tests/components/pipsolar/common.yaml | 12 ++++++++++++ 5 files changed, 63 insertions(+) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index ade93b87359..ea7ebaed0ae 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -286,6 +286,12 @@ void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const c case POLLING_QMN: handle_qmn_(message); break; + case POLLING_QET: + handle_single_int_(message, this->last_qet_, this->total_pv_generated_energy_); + break; + case POLLING_QLT: + handle_single_int_(message, this->last_qlt_, this->total_output_load_energy_); + break; default: break; } @@ -688,6 +694,17 @@ void Pipsolar::handle_qmn_(const char *message) { } } +// QET and QLT each answer with one integer +void Pipsolar::handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value) { + if (raw) { + raw->publish_state(message); + } + + size_t pos = 0; + this->skip_start_(message, &pos); + this->read_int_sensor_(message, &pos, value); +} + void Pipsolar::skip_start_(const char *message, size_t *pos) { if (message[*pos] == '(') { (*pos)++; diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 06c920a6e49..4695e39829a 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -19,6 +19,8 @@ enum ENUMPollingCommand { POLLING_QPIWS = 4, POLLING_QT = 5, POLLING_QMN = 6, + POLLING_QET = 7, + POLLING_QLT = 8, }; struct PollingCommand { uint8_t *command; @@ -116,6 +118,12 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_SENSOR(pv_ok_condition_for_parallel, QPIRI) PIPSOLAR_SENSOR(pv_power_balance, QPIRI) + // QET value + PIPSOLAR_SENSOR(total_pv_generated_energy, QET) + + // QLT value + PIPSOLAR_SENSOR(total_output_load_energy, QLT) + // QMOD values PIPSOLAR_TEXT_SENSOR(device_mode, QMOD) @@ -175,6 +183,8 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_TEXT_SENSOR(last_qpiws, QPIWS) PIPSOLAR_TEXT_SENSOR(last_qt, QT) PIPSOLAR_TEXT_SENSOR(last_qmn, QMN) + PIPSOLAR_TEXT_SENSOR(last_qet, QET) + PIPSOLAR_TEXT_SENSOR(last_qlt, QLT) PIPSOLAR_SWITCH(output_source_priority_utility_switch, QPIRI) PIPSOLAR_SWITCH(output_source_priority_solar_switch, QPIRI) @@ -213,6 +223,7 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { void handle_qpiws_(const char *message); void handle_qt_(const char *message); void handle_qmn_(const char *message); + void handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value); void skip_start_(const char *message, size_t *pos); void skip_field_(const char *message, size_t *pos); diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 5a697157b7e..02f9e239c7c 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -17,6 +18,7 @@ from esphome.const import ( ICON_FLASH, ICON_GAUGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -80,6 +83,10 @@ CONF_BATTERY_VOLTAGE_OFFSET_FOR_FANS_ON = "battery_voltage_offset_for_fans_on" CONF_EEPROM_VERSION = "eeprom_version" CONF_PV_CHARGING_POWER = "pv_charging_power" +# QET / QLT sensors +CONF_TOTAL_PV_GENERATED_ENERGY = "total_pv_generated_energy" +CONF_TOTAL_OUTPUT_LOAD_ENERGY = "total_output_load_energy" + TYPES = { CONF_GRID_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, @@ -319,6 +326,18 @@ TYPES = { device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + CONF_TOTAL_PV_GENERATED_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_TOTAL_OUTPUT_LOAD_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index cc7477395ba..0986120dcfc 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -15,6 +15,8 @@ CONF_LAST_QFLAG = "last_qflag" CONF_LAST_QPIWS = "last_qpiws" CONF_LAST_QT = "last_qt" CONF_LAST_QMN = "last_qmn" +CONF_LAST_QET = "last_qet" +CONF_LAST_QLT = "last_qlt" TYPES = [ CONF_DEVICE_MODE, @@ -25,6 +27,8 @@ TYPES = [ CONF_LAST_QPIWS, CONF_LAST_QT, CONF_LAST_QMN, + CONF_LAST_QET, + CONF_LAST_QLT, ] CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/tests/components/pipsolar/common.yaml b/tests/components/pipsolar/common.yaml index 819764a7eab..883b9d289d3 100644 --- a/tests/components/pipsolar/common.yaml +++ b/tests/components/pipsolar/common.yaml @@ -204,6 +204,12 @@ sensor: pv_charging_power: id: inverter0_pv_charging_power name: inverter0_pv_charging_power + total_pv_generated_energy: + id: inverter0_total_pv_generated_energy + name: inverter0_total_pv_generated_energy + total_output_load_energy: + id: inverter0_total_output_load_energy + name: inverter0_total_output_load_energy switch: - platform: pipsolar @@ -241,3 +247,9 @@ text_sensor: last_qflag: id: inverter0_last_qflag name: inverter0_last_qflag + last_qet: + id: inverter0_last_qet + name: inverter0_last_qet + last_qlt: + id: inverter0_last_qlt + name: inverter0_last_qlt From eb56ce2b7620f3ce229b045e96c8287f79b224c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:05:09 +0200 Subject: [PATCH 252/435] [haier] Reject deferring actions in on_status_message (#20539) Co-authored-by: Claude Opus 5.5 --- esphome/components/haier/climate.py | 7 ++- tests/component_tests/haier/__init__.py | 0 .../haier/test_status_message_automation.py | 61 +++++++++++++++++++ tests/components/haier/common.yaml | 6 ++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/haier/__init__.py create mode 100644 tests/component_tests/haier/test_status_message_automation.py diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index ba760536865..db915e9c7f4 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -186,7 +186,12 @@ def _base_config_schema(class_: MockObjClass) -> cv.Schema: cv.Optional( CONF_ANSWER_TIMEOUT, ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_STATUS_MESSAGE): automation.validate_automation({}), + # data points into the protocol's receive buffer, reused for the next frame + cv.Optional( + CONF_ON_STATUS_MESSAGE + ): automation.validate_synchronous_automation( + "on_status_message", "the received frame" + ), } ) .extend(uart.UART_DEVICE_SCHEMA) diff --git a/tests/component_tests/haier/__init__.py b/tests/component_tests/haier/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/haier/test_status_message_automation.py b/tests/component_tests/haier/test_status_message_automation.py new file mode 100644 index 00000000000..5f4927fe10c --- /dev/null +++ b/tests/component_tests/haier/test_status_message_automation.py @@ -0,0 +1,61 @@ +"""Tests that haier on_status_message automations reject deferring actions. + +The trigger passes a pointer into the protocol's receive buffer, which is reused for the next +frame, so the deferring-actions guard is a safety property. +""" + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.haier.climate import ( + CONF_ON_STATUS_MESSAGE, + HonClimate, + Smartair2Climate, + _base_config_schema, +) +from esphome.core import Lambda +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +CLIMATE_CLASSES = [HonClimate, Smartair2Climate] + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _trigger_schema( + class_: MockObjClass, +) -> Callable[[list[ConfigType]], list[ConfigType]]: + """The validator the protocol's schema applies to on_status_message.""" + return _base_config_schema(class_).schema[cv.Optional(CONF_ON_STATUS_MESSAGE)] + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_synchronous_automation_accepted(class_: MockObjClass) -> None: + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_nested_deferring_action_rejected(class_: MockObjClass) -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": actions}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_in_second_automation_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)( + [{"then": [SYNCHRONOUS_ACTION]}, {"then": [DEFERRING_ACTION]}] + ) diff --git a/tests/components/haier/common.yaml b/tests/components/haier/common.yaml index 926a1d1b7a9..ab39dfa61d5 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -43,6 +43,12 @@ climate: level: DEBUG format: "Alarm deactivated. Code: %d. Message: \"%s\"" args: [code, message] + on_status_message: + then: + - logger.log: + level: DEBUG + format: "Status message: %u bytes" + args: [(unsigned) data_size] sensor: - platform: haier From 5cdefa910f54bf7e49b9839b446d9b3173bfd6db Mon Sep 17 00:00:00 2001 From: Fabio Pugliese Ornellas Date: Sun, 11 Oct 2026 22:06:05 +0100 Subject: [PATCH 253/435] [cse7761] Add new sensors (#17531) Co-authored-by: J. Nick Koston --- esphome/components/cse7761/cse7761.cpp | 90 +++++++++++++++++++++----- esphome/components/cse7761/cse7761.h | 19 +++++- esphome/components/cse7761/sensor.py | 55 ++++++++++++---- tests/components/cse7761/common.yaml | 8 +++ 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index c4073d27821..a6101812223 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -18,21 +18,32 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc static constexpr int CSE7761_IREF = 52241; // RmsIAC static constexpr int CSE7761_PREF = 44513; // PowerPAC +// System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz +static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f; + static constexpr uint8_t CSE7761_REG_SYSCON = 0x00; // (2) System Control Register (0x0A04) static constexpr uint8_t CSE7761_REG_EMUCON = 0x01; // (2) Metering control register (0x0000) +static constexpr uint8_t CSE7761_REG_HFCONST = 0x02; // (2) Pulse frequency register (0x1000) static constexpr uint8_t CSE7761_REG_EMUCON2 = 0x13; // (2) Metering control register 2 (0x0001) static constexpr uint8_t CSE7761_REG_PULSE1SEL = 0x1D; // (2) Pin function output select register (0x3210) -static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) -static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register +static constexpr uint8_t CSE7761_REG_UFREQ = 0x23; // (2) Voltage frequency (0x0000) +static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) +static constexpr uint8_t CSE7761_REG_POWERFACTOR = 0x27; // (3) Power factor of the selected channel (0x7FFFFF) +static constexpr uint8_t CSE7761_REG_ENERGY_PA = 0x28; // (3) Channel A active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_ENERGY_PB = 0x29; // (3) Channel B active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register static constexpr uint8_t CSE7761_REG_COEFFCHKSUM = 0x6F; // (2) Coefficient checksum static constexpr uint8_t CSE7761_REG_RMSIAC = 0x70; // (2) Channel A effective current conversion coefficient +// Datasheet: Wh = pulses * EnergyXC * HFConst * 1000 / 2^41 with K1 = K2 = 1 +static constexpr float CSE7761_ENERGY_WH_SCALE = 1000.0f / 2199023255552.0f; + static constexpr uint8_t CSE7761_SPECIAL_COMMAND = 0xEA; // Start special command static constexpr uint8_t CSE7761_CMD_RESET = 0x96; // Reset command, after receiving the command, the chip resets static constexpr uint8_t CSE7761_CMD_CLOSE_WRITE = 0xDC; // Close write operation @@ -58,6 +69,15 @@ void CSE7761Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current 1", this->current_sensor_1_); + LOG_SENSOR(" ", "Current 2", this->current_sensor_2_); + LOG_SENSOR(" ", "Power 1", this->power_sensor_1_); + LOG_SENSOR(" ", "Power 2", this->power_sensor_2_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Power Factor 1", this->power_factor_sensor_1_); + LOG_SENSOR(" ", "Energy 1", this->energy_sensors_[0]); + LOG_SENSOR(" ", "Energy 2", this->energy_sensors_[1]); } void CSE7761Component::update() { @@ -123,17 +143,18 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { return true; } -uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { - bool result = false; // Start loop - uint8_t retry = 3; // Retry up to three times - uint32_t value = 0; // Default no value - while (!result && retry > 0) { - retry--; - if (this->read_once_(reg, size, &value)) - return value; +bool CSE7761Component::read_(uint8_t reg, uint8_t size, uint32_t *value) { + for (uint8_t retry = 0; retry < 3; retry++) { + if (this->read_once_(reg, size, value)) + return true; } ESP_LOGE(TAG, "Reading register %hhu failed!", reg); - return value; + return false; +} + +uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { + uint32_t value = 0; + return this->read_(reg, size, &value) ? value : 0; } uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) { @@ -159,12 +180,25 @@ bool CSE7761Component::chip_init_() { calc_chksum += this->data_.coefficient[i]; } calc_chksum = ~calc_chksum; + const bool energy_configured = this->energy_sensors_[0] != nullptr || this->energy_sensors_[1] != nullptr; uint16_t coeff_chksum = this->read_(CSE7761_REG_COEFFCHKSUM, 2); if ((calc_chksum != coeff_chksum) || (!calc_chksum)) { ESP_LOGD(TAG, "Default calibration"); this->data_.coefficient[RMS_IAC] = CSE7761_IREF; this->data_.coefficient[RMS_UC] = CSE7761_UREF; this->data_.coefficient[POWER_PAC] = CSE7761_PREF; + if (energy_configured) { + ESP_LOGW(TAG, "No energy calibration, energy unavailable"); + } + } else if (energy_configured) { + uint32_t hf_const = 0; + if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) { + ESP_LOGW(TAG, "HFConst read failed, energy unavailable"); + } + for (uint8_t channel = 0; channel < 2; channel++) { + uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const; + this->energy_[channel].wh_per_pulse = static_cast(scale) * CSE7761_ENERGY_WH_SCALE; + } } this->write_(CSE7761_SPECIAL_COMMAND, CSE7761_CMD_ENABLE_WRITE); @@ -173,7 +207,8 @@ bool CSE7761Component::chip_init_() { if (sys_status & 0x10) { // Write enable to protected registers (WREN) this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04); this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183); - this->write_(CSE7761_REG_EMUCON2 | 0x80, 0x0FC1); + // WaveEN|ZxEN, needed for Ufreq + this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1); this->write_(CSE7761_REG_PULSE1SEL | 0x80, 0x3290); } else { ESP_LOGD(TAG, "Write failed at chip_init"); @@ -210,12 +245,35 @@ void CSE7761Component::get_data_() { this->voltage_sensor_->publish_state(voltage); } + uint32_t raw_reg = 0; + if (this->frequency_sensor_ != nullptr && this->read_(CSE7761_REG_UFREQ, 2, &raw_reg)) { + // Ufreq highest bit set means no signal + float frequency = (raw_reg == 0 || raw_reg >= 0x8000) ? 0.0f : CSE7761_UFREQ_CLOCK / raw_reg; + this->frequency_sensor_->publish_state(frequency); + } + + if (this->power_factor_sensor_1_ != nullptr && this->read_(CSE7761_REG_POWERFACTOR, 3, &raw_reg)) { + // ADC2ON makes the chip report channel A + int24_t pf{static_cast(raw_reg)}; + this->power_factor_sensor_1_->publish_state(static_cast(static_cast(pf)) / 0x800000); + } + for (uint8_t channel = 0; channel < 2; channel++) { // Active power = PowerPA * PowerPAC * 1000 / 0x80000000 float active_power = static_cast(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W float amps = static_cast(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps); + sensor::Sensor *energy_sensor = this->energy_sensors_[channel]; + CSE7761EnergyChannel &energy = this->energy_[channel]; + uint8_t energy_reg = (channel == 0) ? CSE7761_REG_ENERGY_PA : CSE7761_REG_ENERGY_PB; + uint32_t raw = 0; + if (energy_sensor != nullptr && energy.wh_per_pulse > 0.0f && this->read_(energy_reg, 3, &raw)) { + // 24-bit counter, not cleared on read + energy.pulses += (raw - energy.last_pulses) & 0xFFFFFF; + energy.last_pulses = raw; + energy_sensor->publish_state(energy.pulses * energy.wh_per_pulse); + } if (channel == 0) { if (this->power_sensor_1_ != nullptr) { this->power_sensor_1_->publish_state(active_power); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index e08ebf09cc9..557de0e3422 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -3,11 +3,11 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" #include "esphome/core/component.h" +#include "esphome/core/datatypes.h" namespace esphome::cse7761 { struct CSE7761DataStruct { - uint32_t frequency = 0; uint32_t voltage_rms = 0; uint32_t current_rms[2] = {0}; int32_t active_power[2] = {0}; @@ -15,14 +15,26 @@ struct CSE7761DataStruct { bool ready = false; }; +struct CSE7761EnergyChannel { + uint32_t last_pulses{0}; + uint32_t pulses{0}; + float wh_per_pulse{0.0f}; +}; + /// This class implements support for the CSE7761 UART power sensor. class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } + void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; } + void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) { + power_factor_sensor_1_ = power_factor_sensor_1; + } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } void set_current_1_sensor(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } + void set_energy_1_sensor(sensor::Sensor *energy_sensor_1) { this->energy_sensors_[0] = energy_sensor_1; } void set_active_power_2_sensor(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; } void set_current_2_sensor(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; } + void set_energy_2_sensor(sensor::Sensor *energy_sensor_2) { this->energy_sensors_[1] = energy_sensor_2; } void setup() override; void dump_config() override; void update() override; @@ -30,14 +42,19 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice protected: // Sensors sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *frequency_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_1_{nullptr}; sensor::Sensor *power_sensor_1_{nullptr}; sensor::Sensor *current_sensor_1_{nullptr}; sensor::Sensor *power_sensor_2_{nullptr}; sensor::Sensor *current_sensor_2_{nullptr}; + sensor::Sensor *energy_sensors_[2]{nullptr, nullptr}; CSE7761DataStruct data_; + CSE7761EnergyChannel energy_[2]; void write_(uint8_t reg, uint16_t data); bool read_once_(uint8_t reg, uint8_t size, uint32_t *value); + bool read_(uint8_t reg, uint8_t size, uint32_t *value); uint32_t read_(uint8_t reg, uint8_t size); uint32_t coefficient_by_unit_(uint32_t unit); bool chip_init_(); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 5f79be0255b..8ffbb44771a 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -2,15 +2,22 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( + CONF_FREQUENCY, CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_HERTZ, UNIT_VOLT, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -26,6 +33,9 @@ CONF_CURRENT_1 = "current_1" CONF_CURRENT_2 = "current_2" CONF_ACTIVE_POWER_1 = "active_power_1" CONF_ACTIVE_POWER_2 = "active_power_2" +CONF_ENERGY_1 = "energy_1" +CONF_ENERGY_2 = "energy_2" +CONF_POWER_FACTOR_1 = "power_factor_1" CONFIG_SCHEMA = ( cv.Schema( @@ -37,6 +47,17 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_FREQUENCY): sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR_1): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional(CONF_CURRENT_1): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=2, @@ -61,6 +82,18 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_ENERGY_1): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_ENERGY_2): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -83,15 +116,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - for key in [ - CONF_VOLTAGE, - CONF_CURRENT_1, - CONF_CURRENT_2, - CONF_ACTIVE_POWER_1, - CONF_ACTIVE_POWER_2, - ]: - if key not in config: - continue - conf = config[key] - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR_1, var.set_power_factor_1_sensor) + await sensors(CONF_CURRENT_1, var.set_current_1_sensor) + await sensors(CONF_CURRENT_2, var.set_current_2_sensor) + await sensors(CONF_ACTIVE_POWER_1, var.set_active_power_1_sensor) + await sensors(CONF_ACTIVE_POWER_2, var.set_active_power_2_sensor) + await sensors(CONF_ENERGY_1, var.set_energy_1_sensor) + await sensors(CONF_ENERGY_2, var.set_energy_2_sensor) diff --git a/tests/components/cse7761/common.yaml b/tests/components/cse7761/common.yaml index 77b19957b46..ae3a642709e 100644 --- a/tests/components/cse7761/common.yaml +++ b/tests/components/cse7761/common.yaml @@ -2,6 +2,10 @@ sensor: - platform: cse7761 voltage: name: CSE7761 Voltage + frequency: + name: CSE7761 Frequency + power_factor_1: + name: CSE7761 Power Factor 1 current_1: name: CSE7761 Current 1 current_2: @@ -10,3 +14,7 @@ sensor: name: CSE7761 Active Power 1 active_power_2: name: CSE7761 Active Power 2 + energy_1: + name: CSE7761 Energy 1 + energy_2: + name: CSE7761 Energy 2 From 36fbf5bf22d21802833c8ab21c554845383bfc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:07:39 +0200 Subject: [PATCH 254/435] [emontx] Add pause/resume actions to let another component take over the UART (#18756) Co-authored-by: Claude --- esphome/components/emontx/__init__.py | 13 ++++ esphome/components/emontx/emontx.cpp | 20 +++++- esphome/components/emontx/emontx.h | 11 +++- tests/components/emontx/common-actions.yaml | 18 ++++++ tests/components/emontx/emontx_test.cpp | 67 +++++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 79b0e83405b..2146545db52 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -157,3 +157,16 @@ automation.register_apply_action( CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal ), ) + + +# Let another component (e.g. serial_proxy) take exclusive ownership of the UART. + +EMONTX_ID_SCHEMA = automation.maybe_simple_id({cv.GenerateID(): cv.use_id(EmonTx)}) + +automation.register_apply_action( + "emontx.pause", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(true)") +) +automation.register_apply_action( + "emontx.resume", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(false)") +) +automation.register_apply_condition("emontx.is_paused", EMONTX_ID_SCHEMA, "is_paused()") diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 675744b5f58..a307ac0de2a 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -16,6 +16,20 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } * 2. If line starts with '{', parse as JSON and update sensors/callbacks */ void EmonTx::loop() { + if (this->is_paused()) { + this->was_paused_ = true; + return; + } + if (this->was_paused_) { + // Drop what arrived during the pause and resync at the next line start + this->was_paused_ = false; + this->buffer_pos_ = 0; + this->discarding_line_ = true; + uint8_t dropped; + while (this->available() > 0 && this->read_byte(&dropped)) + this->discarding_line_ = dropped != '\n'; + } + // Read all available data to prevent UART buffer overflow while (this->available() > 0) { uint8_t received; @@ -45,7 +59,7 @@ void EmonTx::loop() { } } } else if (received == '\r' || this->discarding_line_) { - // Ignore CR, and the rest of a line that overflowed the buffer + // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; @@ -100,6 +114,10 @@ void EmonTx::dump_config() { * @param command The command string to send (LF will be appended automatically). */ void EmonTx::send_command(const char *command) { + if (this->is_paused()) { + ESP_LOGW(TAG, "Not sending command, UART is paused: %s", command); + return; + } ESP_LOGD(TAG, "Sending command to emonTx: %s", command); this->write_str(command); this->write_byte('\n'); diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index f97c5aa6cb6..83ee4fdbabe 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -8,6 +8,7 @@ #include "esphome/components/json/json_util.h" #include +#include #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" @@ -41,6 +42,12 @@ class EmonTx final : public Component, public uart::UARTDevice { void send_command(const char *command); void send_command(const std::string &command) { this->send_command(command.c_str()); } + /// Stop reading the UART and sending commands, so another component can use it. + /// Atomic, so it can be called from any task. Hold the pause for at least one loop() pass. + void set_paused(bool paused) { this->paused_.store(paused, std::memory_order_relaxed); } + + bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } + #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } void register_sensor(const char *tag_name, sensor::Sensor *sensor); @@ -55,7 +62,9 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; - bool discarding_line_{false}; // Set after an overflow until the end of that line + std::atomic paused_{false}; + bool was_paused_{false}; + bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line std::array buffer_{}; }; diff --git a/tests/components/emontx/common-actions.yaml b/tests/components/emontx/common-actions.yaml index af8d44896be..294e7367fbd 100644 --- a/tests/components/emontx/common-actions.yaml +++ b/tests/components/emontx/common-actions.yaml @@ -5,3 +5,21 @@ button: - emontx.send_command: id: test_emontx command: !lambda return "l"; + - platform: template + name: Pause emontx + on_press: + - if: + condition: + emontx.is_paused: test_emontx + then: + - emontx.resume: test_emontx + else: + - emontx.pause: test_emontx + - platform: template + name: Pause emontx explicit id + on_press: + - emontx.pause: + id: test_emontx + - delay: 1s + - emontx.resume: + id: test_emontx diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 20d3adf0dc0..8530b0a0036 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -91,4 +92,70 @@ TEST_F(EmonTxTest, OverflowAcrossLoopCalls) { EXPECT_EQ(this->lines_[0], "MSG:3"); } +TEST_F(EmonTxTest, LoopLeavesBytesUntouchedWhenPaused) { + this->uart_.push_rx("{\"P1\":123}\n"); + this->sut_.set_paused(true); + this->sut_.loop(); + + EXPECT_EQ(this->uart_.available(), std::strlen("{\"P1\":123}\n")); + EXPECT_TRUE(this->lines_.empty()); +} + +TEST_F(EmonTxTest, SendCommandIsSuppressedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.send_command("x"); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->sut_.set_paused(false); + this->sut_.send_command("x"); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "x\n"); +} + +// Neither the part before the pause nor the part after it may reach on_data +TEST_F(EmonTxTest, ResumeDiscardsInterruptedLine) { + this->uart_.push_rx("{\"P1\":4"); + this->sut_.loop(); + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + + this->uart_.push_rx("56}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("{\"P1\":789}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":789}"); +} + +TEST_F(EmonTxTest, ResumeDropsBacklogReceivedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n{\"P1\":2}\n{\"P1\":"); + this->sut_.set_paused(false); + + this->sut_.loop(); + EXPECT_EQ(this->uart_.available(), 0u); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("3}\n{\"P1\":4}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":4}"); +} + +TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n"); + this->sut_.set_paused(false); + + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":2}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":2}"); +} + } // namespace esphome::emontx::testing From f2db62adf277e398b68e1ff9715587309f2a9224 Mon Sep 17 00:00:00 2001 From: Maciej Kucharek Date: Sun, 11 Oct 2026 23:08:02 +0200 Subject: [PATCH 255/435] [mipi_dsi] Add JC1060P470-V2 (#18695) Co-authored-by: J. Nick Koston --- esphome/components/mipi_dsi/models/guition.py | 57 +++++++++++++++++++ .../mipi_dsi/validate-v2.esp32-p4-idf.yaml | 18 ++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 914361a4ac5..3ebaf53f5b7 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,3 +1,5 @@ +from esphome.components.mipi import SLPOUT, delay + from . import DsiDriverChip # fmt: off @@ -34,6 +36,61 @@ DsiDriverChip( ], ) +# JC1060P470-V2: 2026 batch of the JC1060P470 with a different 7" panel behind the same JD9165 driver. +# The rear label shows "V2" after the material number. Timings and init sequence taken from Guition's +# reference driver JC1060P470C_I_W_Y.zip (2026-07-13), +# 1-Demo/.../JC1060P470C_I_W_Y_New_Panel/lvgl_demo_v8/src/lcd/esp_lcd_jd9165.{c,h} +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 1024x600 +# * PCLK Frequency: 52 MHz +# * DSI Lane Bit Rate: 750 Mbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=24, hsync_back_porch=136, hsync_front_porch=160) +# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=21, vsync_front_porch=12) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC1060P470-V2", + width=1024, + height=600, + hsync_back_porch=136, + hsync_pulse_width=24, + hsync_front_porch=160, + vsync_back_porch=21, + vsync_pulse_width=2, + vsync_front_porch=12, + pclk_frequency="52MHz", + lane_bit_rate="750Mbps", + color_order="RGB", + # LCD_RST is GPIO0 on the JC1060P470C_I_W_Y schematic (FPC1 pin 4); the JD9165 needs 120 ms after reset + # before it accepts the vendor table and 120 ms after SLPOUT before DISPON (Guition esp_lcd_jd9165.c); + # the framework adds another 10 ms after SLPOUT. + # SLPOUT is in the init sequence with its own delay, so the framework must not append a second one. + reset_pin=0, + no_slpout=True, + initsequence=[ + delay(120), + (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x08), (0x0B, 0x11), + (0x20, 0x04), (0x1F, 0x05), (0x23, 0x38), (0x28, 0x18), (0x29, 0x29), (0x2A, 0x01), (0x2B, 0x29), + (0x2C, 0x01), (0x30, 0x02), (0x00, 0x05), (0x01, 0x22), (0x02, 0x08), (0x03, 0x12), (0x04, 0x16), + (0x05, 0x64), (0x06, 0x00), (0x07, 0x00), (0x08, 0x78), (0x09, 0x00), (0x0A, 0x04), + (0x0B, 0x16, 0x17, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x07, 0x07, 0x09), + (0x0C, 0x09, 0x1E, 0x1E, 0x1C, 0x1C, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0D, 0x0A, 0x05, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x06, 0x06, 0x08), + (0x0E, 0x08, 0x1F, 0x1F, 0x1D, 0x1D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0F, 0x0A, 0x05, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1D, 0x1D, 0x1F), + (0x10, 0x1F, 0x08, 0x08, 0x06, 0x06, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x11, 0x16, 0x17, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1C, 0x1C, 0x1E), + (0x12, 0x1E, 0x09, 0x09, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x13, 0x00, 0x00, 0x00, 0x00), (0x14, 0x00, 0x00, 0x41, 0x41), (0x15, 0x00, 0x00, 0x00, 0x00), + (0x17, 0x00), (0x18, 0x85), (0x19, 0x06, 0x09), (0x1A, 0x05, 0x08), (0x1B, 0x0A, 0x04), (0x26, 0x00), + (0x27, 0x00), (0x30, 0x06), + (0x12, 0x3F, 0x25, 0x27, 0x35, 0x1D, 0x1B, 0x1B, 0x1A, 0x18, 0x0A, 0x2A, 0x21, 0x19, 0x30), + (0x13, 0x3F, 0x26, 0x27, 0x35, 0x1E, 0x1C, 0x1C, 0x1A, 0x18, 0x0B, 0x2A, 0x21, 0x19, 0x30), + (0x30, 0x0A), (0x02, 0x4F), (0x0B, 0x40), (0x30, 0x0D), (0x0D, 0x04), (0x10, 0x05), (0x11, 0x0C), + (0x12, 0x05), (0x13, 0x0C), (0x30, 0x00), + (SLPOUT,), delay(120), + ], +) + # JC4880P443 Driver Configuration (ST7701) # Using parameters from esp_lcd_st7701.h and the working full init sequence diff --git a/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml new file mode 100644 index 00000000000..bbb4a7269a8 --- /dev/null +++ b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml @@ -0,0 +1,18 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-p4-idf.yaml + +esp_ldo: + - id: ldo_id + channel: 3 + voltage: 2.5V + +display: + - platform: mipi_dsi + model: JC1060P470-V2 + enable_pin: GPIO22 + +#light: + #- platform: mipi_dsi + #id: backlight_id + +psram: From 99988490091efe78a10e64c4ebfa4289075a9848 Mon Sep 17 00:00:00 2001 From: heikkikii <11836264+heikkikii@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:50 +1100 Subject: [PATCH 256/435] [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860) Co-authored-by: J. Nick Koston --- .../components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 15 ++++++++++++--- .../components/seeed_mr24hpc1/seeed_mr24hpc1.h | 1 + .../components/seeed_mr24hpc1/test.esp32-idf.yaml | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 tests/components/seeed_mr24hpc1/test.esp32-idf.yaml diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 1bc3e0aeb42..8d5103cbbaf 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -9,6 +9,8 @@ namespace esphome::seeed_mr24hpc1 { ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); +static constexpr uint32_t STARTUP_DELAY_MS = 1000; + // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. void MR24HPC1Component::dump_config() { @@ -76,8 +78,7 @@ void MR24HPC1Component::setup() { this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); } #endif - this->set_custom_end_mode(); - this->poll_time_base_func_check_ = true; + this->poll_time_base_func_check_ = false; this->check_dev_inf_sign_ = true; this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE; this->sg_data_len_ = 0; @@ -92,7 +93,13 @@ void MR24HPC1Component::setup() { memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE); memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE); - this->set_interval(8000, [this]() { this->update_(); }); + // The module locks up when queried during its own boot, so hold all TX for a second + this->set_timeout(STARTUP_DELAY_MS, [this]() { + this->module_ready_ = true; + this->set_custom_end_mode(); + this->poll_time_base_func_check_ = true; + this->set_interval(8000, [this]() { this->update_(); }); + }); } // Timed polling of radar data @@ -680,6 +687,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { // Sending data frames void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) { + if (!this->module_ready_) + return; this->write_array(query, string_length); } diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b231bab33e5..23cecfc9863 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component, int sg_start_query_data_; bool check_dev_inf_sign_; bool poll_time_base_func_check_; + bool module_ready_{false}; void update_(); void r24_split_data_frame_(uint8_t value); diff --git a/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..3853bf3e8dc --- /dev/null +++ b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr24hpc1: !include common.yaml From 9b34db14d44d531b1d99eec31f9da742b0d28a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:15:15 +0200 Subject: [PATCH 257/435] [mqtt] Reject deferring actions in on_json_message (#20538) Co-authored-by: Claude Opus 5.5 --- esphome/components/mqtt/__init__.py | 21 ++++---- tests/component_tests/mqtt/__init__.py | 0 .../mqtt/test_json_message_automation.py | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/mqtt/__init__.py create mode 100644 tests/component_tests/mqtt/test_json_message_automation.py diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index b21dc2e2f00..826d4e32ef2 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -224,6 +224,17 @@ def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: return config +# The parsed JSON document is freed once the automation returns +JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( + "on_json_message", + "the parsed JSON document", + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), + cv.Required(CONF_TOPIC): cv.subscribe_topic, + cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, + }, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -304,15 +315,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PAYLOAD): cv.string_strict, } ), - cv.Optional(CONF_ON_JSON_MESSAGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTJsonMessageTrigger - ), - cv.Required(CONF_TOPIC): cv.subscribe_topic, - cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, - } - ), + cv.Optional(CONF_ON_JSON_MESSAGE): JSON_MESSAGE_AUTOMATION_SCHEMA, cv.Optional(CONF_PUBLISH_NAN_AS_NONE, default=False): cv.boolean, cv.Optional(CONF_WAIT_FOR_CONNECTION, default=False): cv.boolean, } diff --git a/tests/component_tests/mqtt/__init__.py b/tests/component_tests/mqtt/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mqtt/test_json_message_automation.py b/tests/component_tests/mqtt/test_json_message_automation.py new file mode 100644 index 00000000000..a6bb84c7c71 --- /dev/null +++ b/tests/component_tests/mqtt/test_json_message_automation.py @@ -0,0 +1,52 @@ +"""Tests that mqtt on_json_message automations reject deferring actions. + +The trigger passes a view into the parsed JSON document, which is freed once the automation +returns, so the deferring-actions guard is a safety property. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.mqtt import CONFIG_SCHEMA, JSON_MESSAGE_AUTOMATION_SCHEMA +from esphome.const import CONF_ON_JSON_MESSAGE +from esphome.core import Lambda +from esphome.types import ConfigType + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _automation(*actions: ConfigType) -> ConfigType: + return {"topic": "test/topic", "then": list(actions)} + + +def test_schema_wired_to_on_json_message() -> None: + schema = CONFIG_SCHEMA.validators[0].schema + assert schema[cv.Optional(CONF_ON_JSON_MESSAGE)] is JSON_MESSAGE_AUTOMATION_SCHEMA + + +def test_synchronous_automation_accepted() -> None: + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(SYNCHRONOUS_ACTION)]) + + +def test_deferring_action_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION, DEFERRING_ACTION)] + ) + + +def test_nested_deferring_action_rejected() -> None: + action = {"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}} + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(action)]) + + +def test_deferring_action_in_second_automation_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION), _automation(DEFERRING_ACTION)] + ) From 3035a08b0a61055c4ae8b6b52523495223b953cf Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:19:43 +0200 Subject: [PATCH 258/435] [zigbee] Improve basic cluster and add manufacturer and app version setting (#20519) --- esphome/components/zigbee/__init__.py | 64 ++++++++++++++++++- esphome/components/zigbee/const.py | 2 + esphome/components/zigbee/zigbee_esp32.cpp | 72 +++++++++++++--------- esphome/components/zigbee/zigbee_esp32.h | 6 +- esphome/components/zigbee/zigbee_esp32.py | 10 ++- esphome/components/zigbee/zigbee_zephyr.py | 14 +++-- tests/components/zigbee/common.yaml | 8 +++ tests/components/zigbee/common_esp32.yaml | 2 + tests/components/zigbee/common_nrf52.yaml | 2 + 9 files changed, 140 insertions(+), 40 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index d01eddb9ff7..1c85553c57d 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import DOMAIN as ESP32_DOMAIN, only_on_variant from esphome.components.esp32.const import ( VARIANT_ESP32C5, @@ -14,14 +15,19 @@ from esphome.components.esp32.const import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ESPHOME, CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START, CONF_POLLING_INTERVAL, + CONF_PROJECT, + CONF_VERSION, + __version__ as ESPHOME_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( @@ -36,6 +42,8 @@ from .const import ( CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, REPORT, ZigbeeComponent, @@ -143,12 +151,33 @@ def _validate_router_sleepy(config: ConfigType) -> ConfigType: return config +def _validate_ascii(value): + """Validate that a string contains only ascii characters.""" + if not isinstance(value, str): + raise cv.Invalid("expected string") + if not value.isascii(): + raise cv.Invalid("Only ASCII characters are allowed") + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), cv.Optional(CONF_MODEL, default=CORE.name): cv.All( - cv.string, cv.Length(max=31) + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional( + CONF_MANUFACTURER, default="esphome", visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional(CONF_VERSION, default=0): cv.All( + cv.int_range(min=0x00, max=0xFF), ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), @@ -207,7 +236,7 @@ CONFIG_SCHEMA = cv.All( def validate_number_of_ep(config: ConfigType) -> ConfigType: if not CORE.is_nrf52: return config - if KEY_ZIGBEE not in CORE.data: + if KEY_EP_NUMBER not in CORE.data.get(KEY_ZIGBEE, {}): raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) if count == 1: @@ -220,7 +249,38 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: return config +def final_validate_strings(config: ConfigType) -> ConfigType: + version_str = ESPHOME_VERSION + if ( + proj_version := fv.full_config.get() + .get(CONF_ESPHOME, {}) + .get(CONF_PROJECT, {}) + .get(CONF_VERSION) + ): + version_str = proj_version.encode("ascii", "ignore")[:15].decode() + if version_str != proj_version: + _LOGGER.warning( + "esphome.project.version %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + proj_version, + version_str, + ) + area_str = "" + if CORE.area: + area_str = CORE.area.encode("ascii", "ignore")[:15].decode() + if area_str != CORE.area: + _LOGGER.warning( + "esphome.area %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + CORE.area, + area_str, + ) + data: dict[str, Any] = CORE.data.setdefault(KEY_ZIGBEE, {}) + data[KEY_ZIGBEE_VERSION_STR] = version_str + data[KEY_ZIGBEE_AREA_STR] = area_str + return config + + FINAL_VALIDATE_SCHEMA = cv.All( + final_validate_strings, validate_number_of_ep, final_validate_esp32, ) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index aad2b5c0e39..109b953de5c 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -85,6 +85,8 @@ POWER_SOURCE = { } KEY_ZIGBEE = "zigbee" +KEY_ZIGBEE_AREA_STR = "zigbee_area_str" +KEY_ZIGBEE_VERSION_STR = "zigbee_version_str" # BACnet engineering units mapping (ZCL uses BACnet unit codes) # See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 7c197133c2b..ffb0d7ec7a7 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -238,7 +238,8 @@ void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } -void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, const char *location, + const char *sw_build_id, uint8_t power_source, uint8_t app_version) { char date_buf[16]; time_t time_val = App.get_build_time(); struct tm *timeinfo = localtime(&time_val); @@ -247,7 +248,10 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu .model = get_zcl_string(model, 31), .manufacturer = get_zcl_string(manufacturer, 31), .date = get_zcl_string(date_buf, 15), + .location = get_zcl_string(location, 15), + .sw_build_id = get_zcl_string(sw_build_id, 15), .power_source = power_source, + .app_version = app_version, }; } @@ -267,6 +271,16 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_APPLICATION_VERSION_ID, + &(this->basic_cluster_data_.app_version)); + uint8_t stack_version = 0; + uint8_t hw_version = 0; + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_STACK_VERSION_ID, &stack_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_HW_VERSION_ID, &hw_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_LOCATION_DESCRIPTION_ID, + this->basic_cluster_data_.location); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_SW_BUILD_ID_ID, + this->basic_cluster_data_.sw_build_id); } bool ZigbeeComponent::register_device() { @@ -420,37 +434,35 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %.*s\n" + " Manufacturer: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " App Version: %u", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), + this->basic_cluster_data_.manufacturer[0], + reinterpret_cast(this->basic_cluster_data_.manufacturer + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + this->basic_cluster_data_.app_version); if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - ESP_LOGCONFIG( - TAG, - "Zigbee\n" - " Model: %.*s\n" -#ifdef CONFIG_ZB_ZCZR - " Router: %s\n" -#else - " Sleepy end device: %s\n" - " Poll interval: %" PRIu32 " ms\n" -#endif - " Device is joined to the network: %s\n" - " Current channel: %d\n" - " Short addr: 0x%04X\n" - " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), -#ifdef CONFIG_ZB_ZCZR - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), -#else - YESNO(this->sleepy_), this->keep_alive_, -#endif - YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); - esp_zigbee_lock_release(); - } else { ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), + ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 28ea64c8cea..f5c7ff8de3b 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -44,7 +44,8 @@ class ZigbeeComponent final : public Component { void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } - void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); + void set_basic_cluster(const char *model, const char *manufacturer, const char *location, const char *sw_build_id, + uint8_t power_source, uint8_t app_version); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); bool register_device(); @@ -80,7 +81,10 @@ class ZigbeeComponent final : public Component { uint8_t *model; uint8_t *manufacturer; uint8_t *date; + uint8_t *location; + uint8_t *sw_build_id; uint8_t power_source; + uint8_t app_version; } basic_cluster_data_; #ifdef CONFIG_ZB_ZED ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 68136d57192..fec8ceba744 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -4,6 +4,7 @@ import re from typing import Any import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, add_idf_component, @@ -28,6 +29,7 @@ from esphome.const import ( CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, + CONF_VERSION, CONF_WIFI, DEVICE_CLASS_OUTLET, ) @@ -49,6 +51,8 @@ from .const import ( CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, ) @@ -418,12 +422,14 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": cg.add(var.set_sleepy(True)) if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: cg.add(var.set_keep_alive(keep_alive)) - cg.add( var.set_basic_cluster( config[CONF_MODEL], - "esphome", + config[CONF_MANUFACTURER], + zb_data[KEY_ZIGBEE_AREA_STR], + zb_data[KEY_ZIGBEE_VERSION_STR], POWER_SOURCE[config[CONF_POWER_SOURCE]], + config[CONF_VERSION], ) ) for ep_num, ep in ep_dict.items(): diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 8197796b6db..d468c9881d0 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -2,6 +2,7 @@ import datetime import random import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( @@ -9,7 +10,7 @@ from esphome.const import ( CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - __version__, + CONF_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.cpp_generator import ( @@ -27,6 +28,8 @@ from .const import ( CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, AnalogAttrs, AnalogAttrsOutput, @@ -158,15 +161,16 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list + zb_data = CORE.data.get(KEY_ZIGBEE, {}) basic_attrs = zigbee_new_variable("zigbee_basic_attrs", ZB_ZCL_BASIC_ATTRS_EXT_T) zigbee_new_attr_list( "zigbee_basic_attrib_list", "ZB_ZCL_DECLARE_BASIC_ATTRIB_LIST_EXT", zigbee_assign(basic_attrs.zcl_version, cg.RawExpression("ZB_ZCL_VERSION")), - zigbee_assign(basic_attrs.app_version, 0), + zigbee_assign(basic_attrs.app_version, config[CONF_VERSION]), zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), - zigbee_set_string(basic_attrs.mf_name, "esphome"), + zigbee_set_string(basic_attrs.mf_name, config[CONF_MANUFACTURER]), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( basic_attrs.date_code, @@ -178,11 +182,11 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.power_source, POWER_SOURCE[config[CONF_POWER_SOURCE]], ), - zigbee_set_string(basic_attrs.location_id, ""), + zigbee_set_string(basic_attrs.location_id, zb_data[KEY_ZIGBEE_AREA_STR]), zigbee_assign( basic_attrs.ph_env, cg.RawExpression("ZB_ZCL_BASIC_ENV_UNSPECIFIED") ), - zigbee_set_string(basic_attrs.sw_ver, __version__), + zigbee_set_string(basic_attrs.sw_ver, zb_data[KEY_ZIGBEE_VERSION_STR]), ) # Create the identify attributes structure and attribute list diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 8ef49570729..4851ec7dea0 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -1,3 +1,11 @@ +esphome: + area: + id: testing_area + name: Testing Area + project: + name: esphome.test + version: "1.1" + binary_sensor: - platform: template name: "Garage Door Open 1" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 84b79bb84f0..9df43d957bb 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -58,6 +58,8 @@ switch: zigbee: model: zigbee_test + manufacturer: test + version: 1 sleepy: true polling_interval: 2s power_source: BATTERY diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index 2da65706182..a99f6bd92e3 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -3,6 +3,8 @@ packages: zigbee: model: zigbee_test + manufacturer: test + version: 1 wipe_on_boot: true on_join: then: From 2ef95bbc28f1fa112d13d292b315f9f2e4264011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:22:52 +0200 Subject: [PATCH 259/435] [core] Copy trigger arguments in wait_until and script.wait (#20536) Co-authored-by: Claude Opus 5.5 --- esphome/components/script/script.h | 8 +- esphome/core/automation.h | 8 ++ esphome/core/base_automation.h | 21 ++-- .../core/test_wait_until_action.cpp | 109 ++++++++++++++++++ .../script/test_script_wait_action.cpp | 96 +++++++++++++++ 5 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 tests/components/core/test_wait_until_action.cpp create mode 100644 tests/components/script/test_script_wait_action.cpp diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 97b27af498d..f00ad7f4afa 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -306,7 +306,7 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, std::make_index_sequence{}); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -323,12 +323,8 @@ template class ScriptWaitAction final : public Action void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->play_next_(std::get(tuple)...); - } - C *script_; - std::list> param_queue_; + std::list...>> param_queue_; }; } // namespace esphome::script diff --git a/esphome/core/automation.h b/esphome/core/automation.h index d3f1fe59e59..35c2a17c247 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -8,6 +8,7 @@ #include "esphome/core/string_ref.h" #include #include +#include #include #include @@ -494,6 +495,13 @@ template class TemplatableValue { }; }; +/// How deferring actions store a trigger argument: a copy, since a referenced object may be gone once the trigger +/// returns, unless the type cannot be copied (e.g. an entity, which outlives the wait). A copyable `Base &` would be +/// stored sliced, so entity-like argument types must not be copyable. +template +using stored_trigger_arg_t = + std::conditional_t>, std::remove_cvref_t, T>; + /** Base class for all automation conditions. * * @tparam Ts The template parameters to pass when executing. diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 8762cbe7110..69def222da2 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -500,7 +501,7 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, x...); // Do immediate check with fresh timestamp - don't call loop() synchronously! // Let the event loop call it to avoid reentrancy issues @@ -532,7 +533,13 @@ template class WaitUntilAction : public Action, public Co } protected: - using QueueItem = std::tuple, std::tuple>; + struct QueueItem { + QueueItem(uint32_t start, optional timeout, const Ts &...x) + : start(start), timeout(timeout), args(x...) {} + uint32_t start; + optional timeout; + std::tuple...> args; + }; // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items @@ -546,14 +553,14 @@ template class WaitUntilAction : public Action, public Co std::list pending; while (!queue.empty()) { auto it = queue.begin(); - auto start = std::get(*it); - auto timeout = std::get>(*it); + auto start = it->start; + auto timeout = it->timeout; // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) { pending.splice(pending.end(), queue, it); continue; } @@ -564,7 +571,7 @@ template class WaitUntilAction : public Action, public Co std::list completed; completed.splice(completed.begin(), queue, it); uint8_t generation = this->stop_generation_; - this->play_next_tuple_(std::get>(completed.front())); + std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args); if (generation != this->stop_generation_) { // stop() ran inside the chain - the items still held locally were cancelled pending.clear(); @@ -581,7 +588,7 @@ template class WaitUntilAction : public Action, public Co Condition *condition_; std::list var_queue_{}; - // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_() uint8_t stop_generation_{0}; }; diff --git a/tests/components/core/test_wait_until_action.cpp b/tests/components/core/test_wait_until_action.cpp new file mode 100644 index 00000000000..56cfa10d513 --- /dev/null +++ b/tests/components/core/test_wait_until_action.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include + +#include "esphome/core/base_automation.h" + +namespace esphome::testing { + +class FlagCondition : public Condition { + public: + bool check(const std::string & /*value*/) override { return this->ready; } + bool ready{false}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(WaitUntilActionTest, KeepsCopyOfReferenceArguments) { + FlagCondition condition; + WaitUntilAction wait(&condition); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + condition.ready = true; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class CounterCondition : public Condition { + public: + bool check(Counter & /*counter*/) override { return this->ready; } + bool ready{false}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +// A non-const reference argument used to fail to compile +TEST(WaitUntilActionTest, AcceptsMutableReferenceArguments) { + CounterCondition condition; + WaitUntilAction wait(&condition); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + condition.ready = true; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityCondition : public Condition { + public: + bool check(Entity & /*entity*/) override { return this->ready; } + bool ready{false}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(WaitUntilActionTest, KeepsReferenceToNonCopyableArguments) { + EntityCondition condition; + WaitUntilAction wait(&condition); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + condition.ready = true; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::testing diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp new file mode 100644 index 00000000000..17f553996ea --- /dev/null +++ b/tests/components/script/test_script_wait_action.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class FakeScript { + public: + bool is_running() const { return this->running; } + bool running{true}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(ScriptWaitActionTest, KeepsCopyOfReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +TEST(ScriptWaitActionTest, KeepsCopyOfMutableReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + script.running = false; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + script.running = false; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::script::testing From 7d7db6bf90c8f716d857e294efcdeb17dcab7471 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:31:49 +0200 Subject: [PATCH 260/435] [zigbee] add report action on esp32 (#20488) Co-authored-by: J. Nick Koston --- esphome/components/zigbee/__init__.py | 4 +-- .../zigbee/zigbee_attribute_esp32.cpp | 11 ++++++++ .../zigbee/zigbee_attribute_esp32.h | 1 + esphome/components/zigbee/zigbee_ep_esp32.py | 21 +------------- esphome/components/zigbee/zigbee_esp32.cpp | 6 ++++ esphome/components/zigbee/zigbee_esp32.h | 1 + esphome/components/zigbee/zigbee_esp32.py | 28 +++++++++++++++++-- tests/components/zigbee/common_esp32.yaml | 4 +++ 8 files changed, 52 insertions(+), 24 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 1c85553c57d..44f83696f25 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -78,9 +78,9 @@ CONFLICTS_WITH = ["openthread"] def _check_report_deprecation(value: str) -> str: - if str(value).lower() in ("coordinator", "enable"): + if str(value).lower() == "coordinator": _LOGGER.warning( - "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + "Report option 'coordinator' is deprecated and will be removed in a future release. Use 'default' instead." ) return value diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index b73c26a8361..e2ff542976b 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -62,6 +62,9 @@ void ZigbeeAttribute::report_(bool has_lock) { } void ZigbeeAttribute::set_report(ZigbeeReportT report) { + if (report == ZigbeeReportT::ZIGBEE_REPORT_DEFAULT || report == ZigbeeReportT::ZIGBEE_REPORT_COORDINATOR) { + return; + } this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { this->force_report_ = true; @@ -73,6 +76,14 @@ void ZigbeeAttribute::set_report(ZigbeeReportT report) { }); } +void ZigbeeAttribute::report() { + if (!this->report_enabled) { + return; + } + this->report_requested_ = true; + this->enable_loop(); +} + void ZigbeeAttribute::loop() { if (this->set_attr_requested_) { this->set_attr_(); diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 305febf171a..adaa60646d1 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -53,6 +53,7 @@ class ZigbeeAttribute final : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); + void report(); template void add_on_value_callback(F &&callback) { on_value_callback_.add(std::forward(callback)); } void on_value(ezb_zcl_attribute_t attribute) { diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 88f49ef3c16..1616ce0cebf 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -27,13 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, Lambda -from .const import ( - CONF_MAX_EP_NUMBER, - CONF_REPORT, - CONF_USE_DEVICE_TYPE, - KEY_ZIGBEE, - REPORT, -) +from .const import CONF_MAX_EP_NUMBER, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE from .const_esp32 import ( ALLOWED_UNITS, CONF_ATTRIBUTE_ID, @@ -58,7 +52,6 @@ ANALOG_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -87,7 +80,6 @@ ANALOG_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -117,7 +109,6 @@ BINARY_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -146,7 +137,6 @@ BINARY_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -178,7 +168,6 @@ def _pressure_ep(device_type: bool = False) -> dict[str, Any]: { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_HECTOPASCAL: 1, @@ -208,7 +197,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -226,7 +214,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -247,7 +234,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_LITRE_PER_HOUR: 0.01, @@ -269,7 +255,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_LAMBDA: cv.lambda_( Lambda( "if (x < 0.0f || std::isnan(x)) return 0xFFFF;" # NaN @@ -294,7 +279,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -316,7 +300,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0000, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: 0.000001, }, @@ -339,7 +322,6 @@ BINARY_SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "MAP8", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -369,7 +351,6 @@ SWITCH_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, ], diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index ffb0d7ec7a7..68c0d005b57 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,6 +49,12 @@ void ZigbeeComponent::factory_reset() { esp_zigbee_lock_release(); } +void ZigbeeComponent::report() { + for (const auto &[_, attribute] : this->attributes_) { + attribute->report(); + } +} + void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index f5c7ff8de3b..d5ed7439bd2 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -61,6 +61,7 @@ class ZigbeeComponent final : public Component { static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); void factory_reset(); + void report(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index fec8ceba744..99f075ecb2f 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -3,10 +3,12 @@ import logging import re from typing import Any +from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, + DOMAIN as ESP32_DOMAIN, add_idf_component, add_idf_sdkconfig_option, add_partition, @@ -55,6 +57,7 @@ from .const import ( KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, + ZigbeeComponent, ) from .const_esp32 import ( ALLOWED_UNITS, @@ -204,8 +207,11 @@ def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None attr[CONF_VALUE] = str(name) attr[CONF_MAX_LENGTH] = len(str(name)) if attr.get(CONNECT): # connect device - if CONF_REPORT in config: - attr[CONF_REPORT] = config[CONF_REPORT] + if (report := config.get(CONF_REPORT, "default")) not in ( + "default", + "coordinator", + ): + attr[CONF_REPORT] = report attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) attr_list = config.setdefault(KEY_ZIGBEE_ATTRIBUTES, []) attr_list.append(attr) @@ -460,3 +466,21 @@ async def add_component(entity: cg.MockObj, config: ConfigType) -> None: cg.add(zb_attr.connect(template_arg, entity, lambda_)) else: cg.add(zb_attr.connect(template_arg, entity)) + + +ZIGBEE_ACTION_SCHEMA_ESP32 = automation.maybe_simple_id( + cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(ZigbeeComponent), + }, + ), + cv.requires_component(ESP32_DOMAIN), + ) +) + +automation.register_apply_action( + "zigbee.report", + ZIGBEE_ACTION_SCHEMA_ESP32, + automation.ApplyCall("report()"), +) diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 9df43d957bb..2b6fc3b6c54 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -16,6 +16,9 @@ binary_sensor: name: "Garage Door Open 13" endpoint: 2 use_device_type: false + on_press: + then: + - zigbee.report sensor: - platform: template @@ -26,6 +29,7 @@ sensor: endpoint: 1 use_device_type: true cluster: device_class + report: enable - platform: template name: "Light Sensor" lambda: return 100.0; From 8060dadd6c87d7944222875a9aeba23c8ee08921 Mon Sep 17 00:00:00 2001 From: embe <31192394+mbrudka@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:49:49 +0200 Subject: [PATCH 261/435] [micro_wake_word] Add opt-in internal RAM placement (#20529) Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 6 ++ .../micro_wake_word/micro_wake_word.cpp | 7 ++- .../micro_wake_word/micro_wake_word.h | 4 +- .../micro_wake_word/streaming_model.cpp | 56 +++++++++++++++++-- .../micro_wake_word/streaming_model.h | 16 ++++-- esphome/core/defines.h | 1 + tests/components/micro_wake_word/common.yaml | 1 + .../test-internal.esp32-idf.yaml | 9 +++ .../micro_wake_word/test.esp32-s3-idf.yaml | 4 +- 9 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 tests/components/micro_wake_word/test-internal.esp32-idf.yaml diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index f04f784ebcf..9477ebc850e 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -44,6 +44,7 @@ DOMAIN = "micro_wake_word" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" +CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" CONF_SLIDING_WINDOW_AVERAGE_SIZE = "sliding_window_average_size" CONF_SLIDING_WINDOW_SIZE = "sliding_window_size" @@ -426,6 +427,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." @@ -519,6 +521,8 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + if config[CONF_PREFER_INTERNAL_RAM]: + cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() if config.get(CONF_TASK_STACK_IN_PSRAM): @@ -569,6 +573,7 @@ async def to_code(config): cg.add( var.add_vad_model( prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_MICRO][CONF_TENSOR_ARENA_SIZE], @@ -581,6 +586,7 @@ async def to_code(config): model_parameters[CONF_ID], str(model_parameters[CONF_ID]), prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_WAKE_WORD], diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6d76e399868..4ae66d69bf0 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -386,9 +386,10 @@ WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { } #ifdef USE_MICRO_WAKE_WORD_VAD -void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { - this->vad_model_ = make_unique(model_start, probability_cutoff, sliding_window_size, tensor_arena_size); +void MicroWakeWord::add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { + this->vad_model_ = + make_unique(model_start, model_size, probability_cutoff, sliding_window_size, tensor_arena_size); } #endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 03f4a86fd47..862d40733bf 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -93,8 +93,8 @@ class MicroWakeWord final : public Component uint8_t get_features_step_size() const { return this->features_step_size_; } #ifdef USE_MICRO_WAKE_WORD_VAD - void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + void add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); // Intended for the voice assistant component to fetch VAD status bool get_vad_state() { return this->vad_state_; } diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 7cbd11711a2..14e6046ba04 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -4,6 +4,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM +#include +#endif ESPHOME_LOG_TAG(TAG, "micro_wake_word"); @@ -62,16 +66,43 @@ bool StreamingModel::load_model_() { } if (this->tensor_arena_ == nullptr) { +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + RAMAllocator preferred_allocator(RAMAllocator::PREFER_INTERNAL); + this->tensor_arena_ = preferred_allocator.allocate(this->tensor_arena_size_); + if (this->tensor_arena_ != nullptr && !esp_ptr_internal(this->tensor_arena_)) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for tensor arena; using default memory", + this->tensor_arena_size_); + } +#else this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); +#endif if (this->tensor_arena_ == nullptr) { ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); return false; } } +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (!esp_ptr_internal(this->model_start_) && this->model_copy_ == nullptr) { + RAMAllocator internal_allocator(RAMAllocator::ALLOC_INTERNAL); + this->model_copy_ = internal_allocator.make_unique_array_for_overwrite(this->model_size_); + if (this->model_copy_ == nullptr) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for model; using original model data", + this->model_size_); + } else { + std::memcpy(this->model_copy_.get(), this->model_start_, this->model_size_); + } + } +#endif + if (this->interpreter_ == nullptr) { + const uint8_t *model_data = this->model_start_; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (this->model_copy_ != nullptr) + model_data = this->model_copy_.get(); +#endif this->interpreter_ = - make_unique(tflite::GetModel(this->model_start_), this->streaming_op_resolver_, + make_unique(tflite::GetModel(model_data), this->streaming_op_resolver_, this->tensor_arena_, this->tensor_arena_size_, this->mrv_); if (this->interpreter_->AllocateTensors() != kTfLiteOk) { ESP_LOGE(TAG, "Failed to allocate tensors for the streaming model"); @@ -176,6 +207,9 @@ size_t StreamingModel::probe_arena_size_() { void StreamingModel::unload_model() { this->interpreter_.reset(); +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_copy_.reset(); +#endif RAMAllocator arena_allocator; @@ -255,11 +289,15 @@ void StreamingModel::reset_probabilities() { this->ignore_windows_ = -MIN_SLICES_BEFORE_DETECTION; } -WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only) { +WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, + bool internal_only) { this->id_ = id; this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_average_size; @@ -291,6 +329,9 @@ WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr m // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is // never loaded. this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = this->model_data_ ? this->model_data_->size() : 0; +#endif if (this->model_start_ == nullptr) { ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); } @@ -354,9 +395,12 @@ DetectionEvent WakeWordModel::determine_detected() { return detection_event; } -VADModel::VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { +VADModel::VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_size; diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 1cb9d6eba54..730afe32bf7 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -5,6 +5,7 @@ #include "preprocessor_settings.h" #include "model_data.h" +#include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include @@ -96,6 +97,10 @@ class StreamingModel { std::vector recent_streaming_probabilities_; const uint8_t *model_start_{nullptr}; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + size_t model_size_{0}; + RAMUniquePtr model_copy_; +#endif uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -108,6 +113,7 @@ class WakeWordModel final : public StreamingModel { /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer + /// @param model_size (size_t) size of the compiled-in model data /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling /// probability @@ -115,9 +121,9 @@ class WakeWordModel final : public StreamingModel { /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena /// @param default_enabled (bool) If true, it will be enabled by default on first boot /// @param internal_only (bool) If true, the model will not be exposed to HomeAssistant as an available model - WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only); + WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, const std::string &wake_word, + size_t tensor_arena_size, bool default_enabled, bool internal_only); /// @brief Constructs a wake word model object with a runtime-downloaded model /// @param id (std::string) identifier for this model @@ -173,8 +179,8 @@ class WakeWordModel final : public StreamingModel { class VADModel final : public StreamingModel { public: - VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); void log_model_config() override; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 730e17fb53a..c4c20e80a6d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -523,6 +523,7 @@ #ifdef USE_ESP32 #define USE_MICRO_WAKE_WORD +#define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index 9ac1056ba49..2b9df607c87 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -13,6 +13,7 @@ microphone: micro_wake_word: microphone: echo_microphone task_stack_in_psram: true + vad: on_wake_word_detected: - logger.log: "Wake word detected" - micro_wake_word.stop: diff --git a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml new file mode 100644 index 00000000000..bddab918c2a --- /dev/null +++ b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + micro_wake_word: !include common.yaml + +micro_wake_word: + prefer_internal_ram: true diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index a1b7b754236..8806d8046d9 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -3,5 +3,7 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + micro_wake_word: !include common.yaml -<<: !include common.yaml +micro_wake_word: + prefer_internal_ram: true From 61d98534f64ec63ecea0c6bf919b13aba3f16c70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 11:56:21 -1000 Subject: [PATCH 262/435] [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) --- .../uart/software_serial_rx_decoder.h | 179 +++++++++++ .../uart/uart_component_esp8266.cpp | 75 +++-- .../components/uart/uart_component_esp8266.h | 21 +- .../uart/test_software_serial_rx_decoder.cpp | 285 ++++++++++++++++++ 4 files changed, 533 insertions(+), 27 deletions(-) create mode 100644 esphome/components/uart/software_serial_rx_decoder.h create mode 100644 tests/components/uart/test_software_serial_rx_decoder.cpp diff --git a/esphome/components/uart/software_serial_rx_decoder.h b/esphome/components/uart/software_serial_rx_decoder.h new file mode 100644 index 00000000000..d80dbf1a854 --- /dev/null +++ b/esphome/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index a32677b3daa..b81c0af85f1 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -16,6 +16,12 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); +// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes. +static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200; +// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a +// CpuFrequencyBoost; the cycle counter does not. +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) uint32_t ESP8266UartComponent::get_config() { @@ -158,6 +164,11 @@ void ESP8266UartComponent::dump_config() { "\n Wake on data RX: ENABLED" #endif ); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX decoder: %s", + this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge") + : LOG_STR_LITERAL("start bit sampler")); + } } this->check_logger_conflict(); } @@ -239,8 +250,10 @@ UARTFlushResult ESP8266UartComponent::flush() { void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size) { + // load_settings() re-enters here + if (this->gpio_rx_pin_ != nullptr) + this->gpio_rx_pin_->detach_interrupt(); this->bit_time_ = F_CPU / baud_rate; - this->rx_buffer_size_ = rx_buffer_size; this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; @@ -254,8 +267,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); rx_pin_ = gpio_rx_pin_->to_isr(); - rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT - gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + uint8_t *buffer = this->rx_.buffer(); + if (this->rx_.buffer_size() != rx_buffer_size) { + delete[] buffer; // NOLINT + buffer = new uint8_t[rx_buffer_size]; // NOLINT + } + this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE, + stop_bits, buffer, rx_buffer_size); + this->rx_.reset(rx_now(), this->rx_pin_.digital_read()); + if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE); + } else { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + } } } // A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles @@ -288,8 +312,7 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { if (arg->stop_bits_ == 2) arg->wait_(&wait, start, bit_time); - arg->rx_buffer_[arg->rx_in_pos_] = rec; - arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; + arg->rx_.push_byte(rec); // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -299,6 +322,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { wake_loop_isrsafe(); #endif } +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) { + const uint32_t now = rx_now(); + const bool level = arg->rx_pin_.digital_read(); + [[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level); +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (wake) + wake_loop_isrsafe(); +#endif +} +void ESP8266SoftwareSerial::rx_finalize_pending_() { + if (!this->rx_.finalize_due(rx_now())) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Tail not elapsed; wake again only once drained + if (this->rx_.available() == 0) + wake_loop_threadsafe(); +#endif + return; + } + InterruptLock lock; + this->rx_.finalize(rx_now()); +} void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { ESP_LOGE(TAG, "UART doesn't have TX pins set!"); @@ -350,28 +394,19 @@ void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - uint8_t data = this->rx_buffer_[this->rx_out_pos_]; - this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_; - return data; + this->rx_sync_(); + return this->rx_.read_byte(); } uint8_t ESP8266SoftwareSerial::peek_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - return this->rx_buffer_[this->rx_out_pos_]; + this->rx_sync_(); + return this->rx_.peek_byte(); } void ESP8266SoftwareSerial::flush() { // Flush is a NO-OP with software serial, all bytes are written immediately. } size_t ESP8266SoftwareSerial::available() { - // Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR. - // When in >= out, data is contiguous: [out..in). - // When in < out, data wraps: [out..buf_size) + [0..in). - size_t in = this->rx_in_pos_; - if (in >= this->rx_out_pos_) - return in - this->rx_out_pos_; - return this->rx_buffer_size_ - this->rx_out_pos_ + in; + this->rx_sync_(); + return this->rx_.available(); } } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 0098e752b2d..3d4a2ba85bc 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -7,6 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" #include "uart_component.h" namespace esphome::uart { @@ -26,24 +27,30 @@ class ESP8266SoftwareSerial { size_t available(); protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(ESP8266SoftwareSerial *arg); void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding uint32_t bit_time_{0}; - uint8_t *rx_buffer_{nullptr}; - size_t rx_buffer_size_; - volatile size_t rx_in_pos_{0}; - size_t rx_out_pos_{0}; - uint8_t stop_bits_; - uint8_t data_bits_; - UARTParityOptions parity_; InternalGPIOPin *gpio_tx_pin_{nullptr}; ISRInternalGPIOPin tx_pin_; InternalGPIOPin *gpio_rx_pin_{nullptr}; ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; }; class ESP8266UartComponent final : public UARTComponent, public Component { diff --git a/tests/components/uart/test_software_serial_rx_decoder.cpp b/tests/components/uart/test_software_serial_rx_decoder.cpp new file mode 100644 index 00000000000..b65e8a659fd --- /dev/null +++ b/tests/components/uart/test_software_serial_rx_decoder.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/uart/software_serial_rx_decoder.h" + +namespace esphome::uart::testing { + +namespace { + +// Firmware time base: micros() << 4 +constexpr uint32_t TICKS_PER_SECOND = 16000000; +constexpr uint32_t TICKS_PER_US = 16; + +// Simulated line: frames become edges at exact baud timing plus jitter, truncated +// to whole microseconds like micros(); poll() emulates the main loop. + +class LineSim { + public: + LineSim(uint32_t baud, uint8_t data_bits, bool parity, bool odd, uint8_t stop_bits, size_t buffer_size = 64) + : data_bits_(data_bits), parity_(parity), odd_(odd), stop_bits_(stop_bits), buffer_(buffer_size) { + this->bit_ = (TICKS_PER_SECOND + baud / 2) / baud; + this->exact_bit_ = static_cast(TICKS_PER_SECOND) / baud; + this->dec_.setup(this->bit_, data_bits, parity, stop_bits, this->buffer_.data(), buffer_size); + this->dec_.reset(this->now_, true); + } + + uint32_t bit_ticks() const { return this->bit_; } + SoftwareSerialRxDecoder &decoder() { return this->dec_; } + const std::vector &received() const { return this->received_; } + + void edge(uint32_t at, bool level) { + this->now_ = at; + this->line_ = level; + this->dec_.on_edge(at, level); + } + + // Main loop pass at cycle `at`. + void poll(uint32_t at) { + this->now_ = at; + if (this->dec_.pending() && this->dec_.finalize_due(at)) + this->dec_.finalize(at); + while (this->dec_.available() > 0) + this->received_.push_back(this->dec_.read_byte()); + } + + // Emit one frame at `start` with per edge `jitter` in ticks; returns the end tick. + uint32_t send( + uint8_t value, uint32_t start, const std::function &jitter = [] { return 0; }) { + std::vector bits; + bits.push_back(false); + int ones = 0; + for (int i = 0; i < this->data_bits_; i++) { + bool b = (value >> i) & 1; + bits.push_back(b); + ones += b; + } + if (this->parity_) + bits.push_back(this->odd_ ? !(ones & 1) : (ones & 1)); + for (int i = 0; i < this->stop_bits_; i++) + bits.push_back(true); + double t = start; + for (bool b : bits) { + if (b != this->line_) { + const auto at = static_cast(static_cast(t) + jitter()); + this->edge(at / TICKS_PER_US * TICKS_PER_US, b); + } + t += this->exact_bit_; + } + return static_cast(t); + } + + protected: + uint8_t data_bits_; + bool parity_; + bool odd_; + uint8_t stop_bits_; + uint32_t bit_{0}; + double exact_bit_{0}; + uint32_t now_{1000}; + bool line_{true}; + std::vector buffer_; + std::vector received_; + SoftwareSerialRxDecoder dec_; +}; + +struct FrameFormat { + uint32_t baud; + uint8_t data_bits; + bool parity; + bool odd; + uint8_t stop_bits; +}; + +// Random bytes, back to back or with idle gaps, with bounded edge jitter. +void run_stream(const FrameFormat &f, double jitter_bits, uint32_t gap_bits, int count, unsigned seed) { + LineSim sim(f.baud, f.data_bits, f.parity, f.odd, f.stop_bits); + std::mt19937 rng(seed); + std::uniform_real_distribution jit(-jitter_bits, jitter_bits); + const uint8_t mask = static_cast((1U << f.data_bits) - 1); + std::vector sent; + uint32_t t = 5000; + for (int n = 0; n < count; n++) { + uint8_t b = rng() & mask; + sent.push_back(b); + t = sim.send(b, t, [&] { return static_cast(jit(rng) * sim.bit_ticks()); }); + if (gap_bits != 0 && n % 3 == 2) { + t += gap_bits * sim.bit_ticks(); + sim.poll(t); + } else if (rng() % 4 == 0) { + sim.poll(t); + } + } + sim.poll(t + 20 * sim.bit_ticks()); + EXPECT_EQ(sim.received(), sent) << "baud " << f.baud << " jitter " << jitter_bits; +} + +} // namespace + +TEST(SoftwareSerialRxDecoder, DecodesBackToBackFramesAtCommonBaudRates) { + for (uint32_t baud : {2400U, 4800U, 9600U, 19200U, 38400U}) { + run_stream({baud, 8, false, false, 1}, 0.0, 0, 300, baud); + } +} + +TEST(SoftwareSerialRxDecoder, ToleratesEdgeJitterUpToAQuarterBit) { + // A quarter bit per edge keeps each run within the half bit rounding budget. + run_stream({9600, 8, false, false, 1}, 0.24, 0, 2000, 1); + run_stream({9600, 8, false, false, 1}, 0.24, 7, 2000, 2); + run_stream({19200, 8, false, false, 1}, 0.24, 4, 2000, 3); // edge decoder limit +} + +TEST(SoftwareSerialRxDecoder, HandlesParityDataBitsAndStopBits) { + run_stream({9600, 8, true, false, 1}, 0.2, 3, 1000, 4); // 8E1 + run_stream({4800, 8, true, true, 2}, 0.2, 2, 1000, 5); // 8O2 + run_stream({19200, 7, false, false, 2}, 0.2, 5, 1000, 6); // 7N2 + run_stream({2400, 5, false, false, 1}, 0.2, 1, 1000, 7); // 5N1 +} + +TEST(SoftwareSerialRxDecoder, AllOnesByteCompletesOnlyByFinalize) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + // 0xFF: start bit, then the line stays high with no closing edge. + sim.edge(t, false); + sim.edge(t + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.poll(t + bit + 8 * bit); + EXPECT_TRUE(sim.received().empty()); + EXPECT_FALSE(sim.decoder().finalize_due(t + bit + 8 * bit)); + sim.poll(t + bit + 10 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xFF); + EXPECT_FALSE(sim.decoder().pending()); +} + +TEST(SoftwareSerialRxDecoder, LastByteOfBurstIsFinalizedThenNextFrameDecodes) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = sim.send(0xA5, 5000); + t = sim.send(0xF0, t); // ends high, needs finalize + EXPECT_TRUE(sim.received().empty()); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 2u); + EXPECT_EQ(sim.received()[0], 0xA5); + EXPECT_EQ(sim.received()[1], 0xF0); + // The stale last edge must not confuse the next start bit. + t = sim.send(0x3C, t + 50 * bit); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 3u); + EXPECT_EQ(sim.received()[2], 0x3C); +} + +TEST(SoftwareSerialRxDecoder, BreakConditionIsDroppedAndResyncs) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + sim.edge(t, false); + t += 25 * bit; // line held low for far longer than a frame + sim.edge(t, true); + t += 3 * bit; + sim.poll(t); + EXPECT_TRUE(sim.received().empty()); + t = sim.send(0xA5, t); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xA5); +} + +TEST(SoftwareSerialRxDecoder, CollapsedEdgeIsIgnoredAndStreamRealignsAtIdle) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + // 0x31: lose the rising edge of bit 4, so the next edge repeats the last level. + uint32_t t = 5000; + sim.edge(t, false); // start + sim.edge(t + 1 * bit, true); // bit0 = 1 + sim.edge(t + 2 * bit, false); // bits1..3 = 0 + sim.decoder().on_edge(t + 7 * bit, false); // should have been bit6 falling edge; level still low + sim.edge(t + 9 * bit, true); // stop + t += 10 * bit; + // Next frame decodes correctly once the line has idled. + t = sim.send(0x5A, t + 12 * bit); + sim.poll(t + 2 * bit); + ASSERT_FALSE(sim.received().empty()); + EXPECT_EQ(sim.received().back(), 0x5A); +} + +TEST(SoftwareSerialRxDecoder, DropsBytesWhenBufferIsFullAndKeepsOldest) { + LineSim sim(9600, 8, false, false, 1, 8); + uint32_t t = 5000; + for (int n = 0; n < 20; n++) + t = sim.send(static_cast(n), t); + sim.poll(t + 20 * sim.bit_ticks()); + ASSERT_EQ(sim.received().size(), 7u); // capacity is size - 1 + for (int n = 0; n < 7; n++) + EXPECT_EQ(sim.received()[n], n); +} + +TEST(SoftwareSerialRxDecoder, SetupAgainDropsStaleStateAndUsesNewBufferAndFraming) { + // Mirrors load_settings(): buffered 8N1 bytes and an open frame, then setup() as 5E2. + LineSim sim(9600, 8, false, false, 1); + uint32_t t = 5000; + for (int n = 0; n < 10; n++) + t = sim.send(static_cast(0x40 + n), t); + sim.edge(t + 8 * sim.bit_ticks(), false); // open a frame, never closed + SoftwareSerialRxDecoder &dec = sim.decoder(); + ASSERT_GE(dec.available(), 9u); + + std::vector small(4, 0xEE); + const uint32_t bit = TICKS_PER_SECOND / 2400; + dec.setup(bit, 5, true, 2, small.data(), small.size()); + EXPECT_EQ(dec.available(), 0u); + EXPECT_FALSE(dec.pending()); + EXPECT_EQ(dec.read_byte(), 0); + + // Capacity of the 4 byte buffer is 3; the spare slot must stay untouched. + auto send_5e2 = [&](uint8_t value, uint32_t start) { + bool line = true; + uint32_t at = start; + auto put = [&](bool b) { + if (b != line) { + dec.on_edge(at, b); + line = b; + } + at += bit; + }; + put(false); + int ones = 0; + for (int i = 0; i < 5; i++) { + bool b = (value >> i) & 1; + ones += b; + put(b); + } + put(ones & 1); + put(true); + put(true); + return at; + }; + uint32_t t2 = 5000; + for (int n = 1; n <= 6; n++) + t2 = send_5e2(static_cast(n), t2); + dec.finalize(t2 + 20 * bit); + ASSERT_EQ(dec.available(), 3u); + EXPECT_EQ(dec.read_byte(), 1); + EXPECT_EQ(dec.read_byte(), 2); + EXPECT_EQ(dec.read_byte(), 3); + EXPECT_EQ(small[3], 0xEE); // capacity slot is never written +} + +TEST(SoftwareSerialRxDecoder, ResetDiscardsPartialFrame) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + sim.edge(5000, false); + sim.edge(5000 + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.decoder().reset(5000 + 2 * bit, true); + EXPECT_FALSE(sim.decoder().pending()); + sim.poll(5000 + 30 * bit); + EXPECT_TRUE(sim.received().empty()); +} + +} // namespace esphome::uart::testing From 4155b82f84dd6d6035355658dd627cb5b1221c0a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:58:31 -0400 Subject: [PATCH 263/435] [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) --- esphome/components/uart/uart.cpp | 4 +- esphome/components/uart/uart_component.h | 3 ++ .../components/uart/uart_component_host.cpp | 2 + .../uart/uart_component_libretiny.cpp | 2 + esphome/components/usb_uart/__init__.py | 8 ++- esphome/components/usb_uart/ch34x.cpp | 13 ++--- esphome/components/usb_uart/cp210x.cpp | 4 +- esphome/components/usb_uart/ft23xx.cpp | 30 +---------- esphome/components/usb_uart/pl2303.cpp | 29 +--------- esphome/components/usb_uart/usb_uart.cpp | 50 +++++++++++++---- esphome/components/usb_uart/usb_uart.h | 53 +++++++++---------- 11 files changed, 91 insertions(+), 107 deletions(-) diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 20f6385b6a7..56b8e6401e8 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -10,8 +10,8 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); -// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD -PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN"); +// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE +PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN"); void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, uint8_t data_bits) { diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 2543cb54768..6d446fd6a6f 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -18,6 +18,9 @@ enum UARTParityOptions { UART_CONFIG_PARITY_NONE, UART_CONFIG_PARITY_EVEN, UART_CONFIG_PARITY_ODD, + // Only USB serial adapters (usb_uart) support these + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, }; #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 9e2f273f47c..7fb704536dd 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -158,6 +158,8 @@ void HostUartComponent::setup() { options.c_cflag |= PARENB; options.c_cflag |= PARODD; break; + default: + break; }; // Set stop bits if (this->stop_bits_ == 2) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 5a77bf26d9e..802a88ba80d 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -38,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() { case UART_CONFIG_PARITY_ODD: config |= SERIAL_PARITY_ODD; break; + default: + break; } config |= (this->data_bits_ - 4) << 8; diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 2898576b323..581727b326a 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent +from esphome.components.uart import ( + CONF_DEBUG_PREFIX, + CONF_FLUSH_TIMEOUT, + UARTComponent, + UARTParityOptions, +) from esphome.components.usb_host import ( get_max_packet_size, register_usb_client, @@ -39,7 +44,6 @@ def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: ) -UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 00c5e0b0694..ed5a034b82f 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo factor = 256 - factor; uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2) value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; - } + // Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space + const LineParity parity = channel->line_parity_(); + if (parity != LineParity::LINE_PARITY_NONE) + value |= 8 | ((static_cast(parity) - 1) << 4); value |= channel->data_bits_ - 5; value <<= 8; value |= 0x8C; diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1d..e920da6c267 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); return true; case 1: { - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; + uint16_t line_control = static_cast(channel->line_stop_bits_()); + line_control |= static_cast(channel->line_parity_()) << 4; line_control |= channel->data_bits_ << 8; ESP_LOGD(TAG, "Line control value 0x%X", line_control); this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd95..e10da78aaf0 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b } case 2: { // set line properties (data bits / parity / stop bits) uint16_t value = channel->data_bits_; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - switch (channel->stop_bits_) { - default: // 1 bit - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } + value |= static_cast(channel->line_parity_()) << 8; + value |= static_cast(channel->line_stop_bits_()) << 11; value |= (0x00 << 14); this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, channel->cdc_dev_.bulk_interface_number + 1); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308d..36107c093c4 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b encode_baud_divisor(line_coding, baud); } - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } + line_coding[4] = static_cast(channel->line_stop_bits_()); - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } + line_coding[5] = static_cast(channel->line_parity_()); // Data bits line_coding[6] = channel->get_data_bits(); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 86dfa6d729f..f61642566cb 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -270,9 +270,10 @@ void USBUartComponent::dump_config() { " Flush Timeout: %" PRIu32 " ms\n" " Debug: %s\n" " Dummy receiver: %s", - channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_], - STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_), - YESNO(channel->dummy_receiver_)); + channel->index_, channel->baud_rate_, channel->data_bits_, + PARITY_NAMES[static_cast(channel->line_parity_())], + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], channel->flush_timeout_ms_, + YESNO(channel->debug_), YESNO(channel->dummy_receiver_)); } } void USBUartComponent::start_input(USBUartChannelBase *channel) { @@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // sends RSTACK. uint32_t baud = channel->baud_rate_; std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits + static_cast(baud & 0xFF), + static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), + static_cast((baud >> 24) & 0xFF), + static_cast(channel->line_stop_bits_()), // bCharFormat + static_cast(channel->line_parity_()), // bParityType + static_cast(channel->data_bits_), // bDataBits }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud, + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], + PARITY_NAMES[static_cast(channel->line_parity_())], channel->data_bits_); this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, line_coding); return true; @@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() { return true; } +LineStopBits USBUartChannelBase::line_stop_bits_() const { + switch (this->stop_bits_) { + case UART_CONFIG_STOP_BITS_1_5: + return LineStopBits::LINE_STOP_BITS_1_5; + case UART_CONFIG_STOP_BITS_2: + return LineStopBits::LINE_STOP_BITS_2; + default: + return LineStopBits::LINE_STOP_BITS_1; + } +} + +LineParity USBUartChannelBase::line_parity_() const { + switch (this->parity_) { + case uart::UART_CONFIG_PARITY_ODD: + return LineParity::LINE_PARITY_ODD; + case uart::UART_CONFIG_PARITY_EVEN: + return LineParity::LINE_PARITY_EVEN; + case uart::UART_CONFIG_PARITY_MARK: + return LineParity::LINE_PARITY_MARK; + case uart::UART_CONFIG_PARITY_SPACE: + return LineParity::LINE_PARITY_SPACE; + default: + return LineParity::LINE_PARITY_NONE; + } +} + void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c6ae0b4eef5..4e52fb60e20 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -71,21 +71,33 @@ enum CH34xChipType : uint8_t { CHIP_UNKNOWN = 0xFF, }; -enum UARTParityOptions { - UART_CONFIG_PARITY_NONE = 0, - UART_CONFIG_PARITY_ODD, - UART_CONFIG_PARITY_EVEN, - UART_CONFIG_PARITY_MARK, - UART_CONFIG_PARITY_SPACE, -}; - +// 1 and 2 are the stop bit count the uart component uses, so a count set through the +// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count. enum UARTStopBitsOptions { - UART_CONFIG_STOP_BITS_1 = 0, - UART_CONFIG_STOP_BITS_1_5, - UART_CONFIG_STOP_BITS_2, + UART_CONFIG_STOP_BITS_1 = 1, + UART_CONFIG_STOP_BITS_2 = 2, + UART_CONFIG_STOP_BITS_1_5 = 3, }; +// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the +// CP210x, FTDI and PL2303 requests, all use these values +enum class LineStopBits : uint8_t { + LINE_STOP_BITS_1 = 0, + LINE_STOP_BITS_1_5 = 1, + LINE_STOP_BITS_2 = 2, +}; + +enum class LineParity : uint8_t { + LINE_PARITY_NONE = 0, + LINE_PARITY_ODD = 1, + LINE_PARITY_EVEN = 2, + LINE_PARITY_MARK = 3, + LINE_PARITY_SPACE = 4, +}; + +// Indexed by LineParity static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +// Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; class RingBuffer { @@ -156,22 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; - // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. - // MARK/SPACE have no uart:: equivalent and report as NONE. - switch (parity) { - case UART_CONFIG_PARITY_EVEN: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); - break; - case UART_CONFIG_PARITY_ODD: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); - break; - default: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); - break; - } - } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } @@ -194,6 +190,8 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented output_queue_; @@ -205,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_started_{true}; From 94894d60b77e36f7c6e72dc74116b175fbbb7b4b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 12 Oct 2026 00:15:23 +0200 Subject: [PATCH 264/435] [nrf52] fix zigbee time component (#20527) Co-authored-by: Claude --- esphome/components/time/__init__.py | 8 +++++++ esphome/components/zigbee/time/__init__.py | 2 ++ .../zigbee/time/zigbee_time_zephyr.cpp | 22 ++++++++++++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index a017109e7d3..2ad3cd9c314 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -26,6 +26,8 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -455,6 +457,12 @@ async def register_time(time_var, config): async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("POSIX_CLOCK", True) + # From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(), + # which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time() + # always returns -1. + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0): + zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True) + zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True) cg.add_define("USE_TIME") cg.add_global(time_ns.using) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 74f81df2e96..0572e591cfc 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -14,6 +14,7 @@ from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, get_slot_index, + zigbee_assign, zigbee_new_attr_list, zigbee_new_cluster_list, zigbee_new_variable, @@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None: # Create the binary attributes structure time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t") + zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE")) attr_list = zigbee_new_attr_list( attr_list_name, "ZB_ZCL_DECLARE_TIME_ATTR_LIST", diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 7f0a5f979ce..0de0ee0ee48 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include namespace esphome::zigbee { @@ -9,12 +10,14 @@ ESPHOME_LOG_TAG(TAG, "zigbee.time"); // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; +// Largest ZCL time that still fits the 32-bit epoch. Also rejects ZB_ZCL_TIME_TIME_INVALID_VALUE. +constexpr uint32_t MAX_ZCL_TIME = std::numeric_limits::max() - EPOCH_2000; ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t short_addr, zb_uint8_t endpoint, zb_uint32_t nw_time) { - if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT) { + if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT && nw_time <= MAX_ZCL_TIME) { global_time->set_epoch_time(nw_time + EPOCH_2000); } else if (status != RET_TIMEOUT || !global_time->has_time_) { ESP_LOGE(TAG, "Status: %d, auth_level: %u, short_addr: %d, endpoint: %d, nw_time: %u", status, auth_level, @@ -25,7 +28,6 @@ void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t void ZigbeeTime::setup() { global_time = this; this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); - synchronize_epoch_(EPOCH_2000); this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); } @@ -38,8 +40,12 @@ void ZigbeeTime::dump_config() { } void ZigbeeTime::update() { - time_t time = timestamp_now(); - this->cluster_attributes_->time = time - EPOCH_2000; + const time_t now = this->timestamp_now(); + if (now < EPOCH_2000) { + this->cluster_attributes_->time = ZB_ZCL_TIME_TIME_INVALID_VALUE; + } else { + this->cluster_attributes_->time = static_cast(now - EPOCH_2000); + } } void ZigbeeTime::set_epoch_time(uint32_t epoch) { @@ -61,8 +67,12 @@ void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { if (cluster_id == ZB_ZCL_CLUSTER_ID_TIME) { if (attr_id == ZB_ZCL_ATTR_TIME_TIME_ID) { zb_uint32_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data32; - ESP_LOGI(TAG, "Synchronize time to %u", value); - this->defer([this, value]() { synchronize_epoch_(value + EPOCH_2000); }); + if (value <= MAX_ZCL_TIME) { + ESP_LOGI(TAG, "Synchronize time to %u", value); + this->defer([this, value]() { this->synchronize_epoch_(value + EPOCH_2000); }); + } else { + ESP_LOGW(TAG, "Ignoring invalid time %u", value); + } } else if (attr_id == ZB_ZCL_ATTR_TIME_TIME_STATUS_ID) { zb_uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8; ESP_LOGI(TAG, "Time status %hd", value); From 96eaf423366847d8e1e9380c36785950352420fd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:22:29 +1300 Subject: [PATCH 265/435] [api] Let a wizard input pick multiple entities (#20537) --- esphome/components/api/api.proto | 21 +++- esphome/components/api/api_wizard.cpp | 56 +++++++++-- esphome/components/api/api_wizard.h | 35 ++++++- esphome/components/api/wizard.py | 55 +++++++++-- esphome/core/defines.h | 1 + tests/component_tests/api/test_wizard.py | 118 ++++++++++++++++++++++- tests/components/api/__init__.py | 3 +- tests/components/api/common-wizard.yaml | 20 ++++ tests/components/api/test_wizard.cpp | 73 ++++++++++++++ 9 files changed, 360 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d4ae82fd9f1..1b06348e2ce 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -444,7 +444,7 @@ message DeviceWizardRequest { // {"version": 1, // "pages": [{"title": "...", "description": "...", // "entities": [{"key": 123, "device_id": 456, "description": "..."}], -// "inputs": [{"key": 789, "description": "...", +// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, // "entity_filters": [{"integration": "...", "domain": ["..."], // "device_class": ["..."], "supported_features": ["..."]}]}]}]} // @@ -468,6 +468,14 @@ message DeviceWizardRequest { // an entity matches a filter when it satisfies every field that is set, and an entity // that matches any one of the filters is accepted. // +// An input with "multiple": true lets the user pick several entities. The client sends +// them in one WizardInputSetRequest as a comma separated list with no spaces +// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the +// list without them. "max_length" (only on a multiple input, 255 when left out) is the +// longest list in bytes the device accepts. The version stays 1: a client that does not +// know "multiple" ignores it, shows a single picker and sends one entity id, which a +// multiple input accepts. +// // The device keeps the entity id of an input in RAM only and does not store it across // restarts. The client therefore stores the choice itself and, after every connection // and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest @@ -483,8 +491,13 @@ message DeviceWizardResponse { // Sets the entity id of a wizard input (see WizardInputField.key). Clients send // this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no -// reply. The device ignores a request whose key matches no input or whose entity_id -// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// reply. The device ignores a request whose key matches no input. A single input is +// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple +// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no +// spaces, the device trims spaces around each id and stores the list without them. The +// whole request is ignored when any id is empty or has no '.', or when the list without +// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id +// or list in RAM, // not across restarts, and when the input belongs to a homeassistant platform entity // it subscribes to that entity's state. Clients send this for every input right // after connecting, before SubscribeHomeAssistantStatesRequest. A request that @@ -496,7 +509,7 @@ message WizardInputSetRequest { option (ifdef) = "USE_API_WIZARD_INPUTS"; fixed32 key = 1; - string entity_id = 2 [(max_data_length) = 255]; + string entity_id = 2 [(max_data_length) = 4095]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index 10a5cbc70cc..9467efcfa0c 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -14,26 +14,68 @@ namespace esphome::api { ESPHOME_LOG_TAG(TAG, "api.wizard"); #ifdef USE_API_WIZARD_INPUTS +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined +// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr. +static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) { + size_t joined = 0; + const char *end = value + length; + while (true) { + const char *comma = static_cast(memchr(value, ',', end - value)); + const char *id_end = comma != nullptr ? comma : end; + while (value < id_end && *value == ' ') + value++; + while (id_end > value && id_end[-1] == ' ') + id_end--; + size_t id_length = id_end - value; + if (id_length == 0 || memchr(value, '.', id_length) == nullptr) + return 0; + if (joined != 0) { + if (out != nullptr) + out[joined] = ','; + joined++; + } + if (out != nullptr) + memcpy(out + joined, value, id_length); + joined += id_length; + if (comma == nullptr) + return joined; + value = comma + 1; + } +} +#endif // USE_API_WIZARD_MULTIPLE_INPUTS + static bool wizard_entity_id_valid(const char *entity_id, size_t length) { return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; } const char *wizard_set_input(const WizardInputSetRequest &msg) { - if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { - ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); - return nullptr; - } + const char *value = msg.entity_id.c_str(); + const size_t length = msg.entity_id.size(); for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { // The table is in flash, which ESP8266 can only read through progmem_memcpy WizardInputEntry entry; progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); if (entry.key != msg.key) continue; - memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); - entry.entity_id[msg.entity_id.size()] = '\0'; +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + if (entry.size != 0) { + // Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged + const size_t joined = wizard_join_entity_ids(value, length, nullptr); + if (joined == 0 || joined >= entry.size) + break; + wizard_join_entity_ids(value, length, entry.entity_id); + entry.entity_id[joined] = '\0'; + return entry.entity_id; + } +#endif + if (!wizard_entity_id_valid(value, length)) + break; + memcpy(entry.entity_id, value, length); + entry.entity_id[length] = '\0'; return entry.entity_id; } - ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid"); return nullptr; } #endif // USE_API_WIZARD_INPUTS diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 7d5a566b550..6bdc1654bcb 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -6,6 +6,9 @@ #include #include +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +#include +#endif #include "esphome/core/hal.h" #include "proto.h" @@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM; /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { uint32_t key; // FNV-1 hash of the ESPHome id of the input - char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity + char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + uint16_t size; // Buffer size of a multiple input, 0 for a single input +#endif }; /// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. @@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg); class WizardInput { public: explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} - /// The Home Assistant entity ID, empty until the wizard sets one. + /// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole + /// comma separated list, which homeassistant.action data can use as it is. StringRef entity_id() const { return StringRef(this->entity_id_); } bool has_entity_id() const { return this->entity_id_[0] != '\0'; } +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + /// The number of entity IDs in the list, 0 until the wizard sets one. + size_t entity_id_count() const { + if (!this->has_entity_id()) + return 0; + size_t count = 1; + for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++) + count++; + return count; + } + /// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is + /// not null terminated, so print it with "%.*s". + template void for_each_entity_id(F &&callback) const { + // The device stores the list without spaces or empty entries + const char *start = this->entity_id_; + while (*start != '\0') { + const char *end = strchr(start, ','); + if (end == nullptr) + end = start + strlen(start); + callback(StringRef(start, end - start)); + start = *end == ',' ? end + 1 : end; + } + } +#endif protected: const char *entity_id_; diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 3ab80a063ee..8e5c808e24c 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -20,6 +20,8 @@ from esphome.const import ( CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL, + CONF_MAX_LENGTH, + CONF_MULTIPLE, CONF_NAME, CONF_PAGES, CONF_PLATFORM, @@ -51,6 +53,10 @@ WizardInput = _API.class_("WizardInput") WIZARD_ENTITY_ID_BUFFER_SIZE = ( 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes ) +WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1 +# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266; +# api.proto max_data_length documents the same value +WIZARD_MULTIPLE_MAX_LENGTH = 4095 # One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, # Noise) and footer (16 bytes, Noise MAC) WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 @@ -106,6 +112,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema( ) +def _validate_multiple(conf: ConfigType) -> ConfigType: + if conf[CONF_MULTIPLE] and CONF_ENTITY in conf: + raise cv.Invalid( + "A linked input follows the state of exactly one Home Assistant entity, so multiple " + "is only for standalone inputs (id)", + path=[CONF_MULTIPLE], + ) + if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]: + raise cv.Invalid( + "max_length only applies to an input with multiple: true", + path=[CONF_MAX_LENGTH], + ) + return conf + + # An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no # entity_id of its own: Home Assistant sets it WIZARD_INPUT_SCHEMA = cv.All( @@ -114,6 +135,10 @@ WIZARD_INPUT_SCHEMA = cv.All( cv.Optional(CONF_ID): cv.declare_id(WizardInput), cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_MULTIPLE, default=False): cv.boolean, + cv.Optional(CONF_MAX_LENGTH): cv.int_range( + min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH + ), cv.Optional(CONF_TARGET): cv.Schema( { cv.Required(CONF_ENTITY): cv.All( @@ -124,6 +149,7 @@ WIZARD_INPUT_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), + _validate_multiple, ) WIZARD_PAGE_SCHEMA = cv.All( @@ -248,6 +274,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]: defines.add("USE_API_WIZARD_LINKED_INPUTS") if any(CONF_ID in conf for conf in inputs): defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + if any(conf.get(CONF_MULTIPLE) for conf in inputs): + defines.add("USE_API_WIZARD_MULTIPLE_INPUTS") return defines @@ -371,6 +399,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} if description := conf.get(CONF_DESCRIPTION): document[CONF_DESCRIPTION] = description + if conf.get(CONF_MULTIPLE): + document["multiple"] = True + if (max_length := conf.get(CONF_MAX_LENGTH)) is not None: + document["max_length"] = max_length if filters := _wizard_input_filters(conf, config): document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] return document @@ -384,7 +416,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi {"version": 1, "pages": [{"title": "...", "description": "...", "entities": [{"key": 123, "device_id": 456, "description": "..."}], - "inputs": [{"key": 789, "description": "...", + "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, "entity_filters": [{"integration": "...", "domain": ["..."], "device_class": ["..."], "supported_features": ["..."]}]}]}]} @@ -396,6 +428,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi the device it belongs to (esphome/core/config.py), and is left out for the main device. - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - multiple means the user may pick several entities. The client sends them in one + WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the + device trims spaces around each id and stores the list without them. The whole request is + ignored when any id is empty or has no '.', or when the list without spaces does not fit + max_length (only on a multiple input, 255 when left out). A single input is ignored when the id + is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know + multiple ignores it, shows a single picker and sends one entity id, which a multiple input + accepts. - entity_filters are the filters of the input, or the default filters of a linked switch, number, text, select or button. """ @@ -441,14 +481,15 @@ async def to_code(wizard: ConfigType) -> None: input_id = _wizard_input_id(conf) # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. buffer = _wizard_buffer_name(input_id) - cg.add_global( - cg.RawStatement( - f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' - ) - ) + size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1 + cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";')) if CONF_ID in conf: cg.new_Pvariable(input_id, cg.RawExpression(buffer)) - entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + key = fnv1_hash(input_id.id) + if conf[CONF_MULTIPLE]: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}")) + else: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}")) if entries: cg.extern_progmem_array( "esphome::api::API_WIZARD_INPUTS", diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c4c20e80a6d..d219296ca57 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -282,6 +282,7 @@ #define USE_API_WIZARD #define USE_API_WIZARD_INPUTS #define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_MULTIPLE_INPUTS #define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 112092482b2..f55a6974e7f 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -490,6 +490,7 @@ WIZARD_DEFINES = { "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", } @@ -550,9 +551,28 @@ WIZARD_DEFINES = { ), pytest.param( "- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]", - WIZARD_DEFINES, + WIZARD_DEFINES - {"USE_API_WIZARD_MULTIPLE_INPUTS"}, id="everything", ), + pytest.param( + "- inputs: [{id: calendars, multiple: true}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", + }, + id="multiple-input", + ), + pytest.param( + "- inputs: [{id: calendars, multiple: false}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="multiple-false", + ), ], ) def test_only_the_defines_the_wizard_needs_are_emitted( @@ -925,3 +945,99 @@ def test_an_entity_with_an_entity_id_that_is_no_input_is_valid( assert "api_wizard_input_ha_plain_sensor" not in main_cpp # Every buffer starts empty assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0] + + +@pytest.mark.parametrize( + "input_conf", + [ + "{id: cals, multiple: true}", + "{id: cals, multiple: true, max_length: 255}", + "{id: cals, multiple: true, max_length: 4095}", + "{id: cals, multiple: false}", + ], +) +def test_multiple_input_is_valid(tmp_path: Path, input_conf: str) -> None: + api = wizard_inputs(f"- {input_conf}") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + pytest.param( + "- {entity: ha_sensor, multiple: true}", + "multiple is only for standalone inputs", + id="multiple-linked", + ), + pytest.param( + "- {id: cals, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-without-multiple", + ), + pytest.param( + "- {id: cals, multiple: false, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-multiple-false", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 254}", + "value must be at least 255", + id="max-length-too-small", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 4096}", + "value must be at most 4095", + id="max-length-too-big", + ), + ], +) +def test_invalid_multiple_inputs_are_rejected( + tmp_path: Path, inputs: str, message: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs))) + + assert any(message in error for error in errors), errors + + +def test_multiple_input_document_and_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + api = wizard_inputs( + """ + - {id: cals, multiple: true, max_length: 1023} + - {id: cals_default, multiple: true} + - {id: single} + """ + ) + main_cpp = generate_main(write_config(tmp_path, ESP32_HEADER, api)) + + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) + inputs = document["pages"][0]["inputs"] + # multiple and max_length are only there when set + assert inputs == [ + {"key": fnv1_hash("cals"), "multiple": True, "max_length": 1023}, + {"key": fnv1_hash("cals_default"), "multiple": True}, + {"key": fnv1_hash("single")}, + ] + # Only a multiple entry names its buffer size + assert 'static char api_wizard_input_cals[1024] = "";' in main_cpp + assert 'static char api_wizard_input_cals_default[256] = "";' in main_cpp + assert 'static char api_wizard_input_single[256] = "";' in main_cpp + assert f"{{{fnv1_hash('cals')}u, api_wizard_input_cals, 1024}}" in main_cpp + assert ( + f"{{{fnv1_hash('cals_default')}u, api_wizard_input_cals_default, 256}}" + in main_cpp + ) + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + + +def test_inputs_without_multiple_keep_the_two_field_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config(tmp_path, ESP32_HEADER, wizard_inputs("- id: single")) + ) + + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + assert "MULTIPLE_INPUTS" not in {d.name for d in CORE.defines} diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index bd84c8d9bb4..79feb398bda 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", ): cg.add_define(define) cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + cg.add_define("API_WIZARD_INPUT_COUNT", 3) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml index 5e1936a042d..d658da1c1f2 100644 --- a/tests/components/api/common-wizard.yaml +++ b/tests/components/api/common-wizard.yaml @@ -41,6 +41,13 @@ api: entity: - domain: weather - id: unset_input + - id: calendars_input + description: Calendars + multiple: true + max_length: 1023 + target: + entity: + - domain: calendar switch: - platform: template @@ -106,3 +113,16 @@ interval: data: type: daily entity_id: !lambda 'return id(weather_input).entity_id().c_str();' + - if: + condition: + api.wizard.input_is_set: calendars_input + then: + - homeassistant.action: + action: calendar.get_events + data: + duration: "24:00:00" + entity_id: !lambda 'return id(calendars_input).entity_id().c_str();' + - lambda: |- + ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count()); + id(calendars_input).for_each_entity_id( + [](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); }); diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 033c8b627e5..54aeb4b4ed6 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -23,8 +23,15 @@ static char *wizard_input_other() { return buffer; } +static constexpr size_t CALENDARS_SIZE = 64; +static char *wizard_input_calendars() { + static char buffer[CALENDARS_SIZE] = ""; + return buffer; +} + static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; static constexpr uint32_t OTHER_KEY = 0x11223344; +static constexpr uint32_t CALENDARS_KEY = 0x55667788; // The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two // bytes as a varint. @@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { {WEATHER_KEY, wizard_input_weather()}, {OTHER_KEY, wizard_input_other()}, + {CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE}, }; using Bytes = std::vector; @@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { EXPECT_EQ(input.entity_id(), "weather.home"); } +TEST(DeviceWizard, MultipleInputStoresTheList) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + // One id is a list of one + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.c"); +} + +TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c"); +} + +TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) { + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars()); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr); + EXPECT_STREQ(wizard_input_calendars(), "calendar.keep"); +} + +TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) { + // Pad the last id so the list fills the buffer + std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x'); + ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1); + EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + + std::string too_long = fits + "x"; + EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + // Spaces do not count towards the limit + std::string spaced = fits; + spaced.replace(spaced.find(','), 1, " , "); + EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); +} + +TEST(DeviceWizard, MultipleInputHelpersWalkTheList) { + strcpy(wizard_input_calendars(), ""); + WizardInput input(wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 0u); + std::vector seen; + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_TRUE(seen.empty()); + + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 1u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a"})); + + seen.clear(); + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 3u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a", "calendar.b", "calendar.c"})); + // The raw list is what homeassistant.action data receives + EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c"); +} + } // namespace esphome::api #endif // USE_HOST From 9a35c5229b2d11acae95428e209404d2611de427 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:36:06 +0200 Subject: [PATCH 266/435] [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) --- esphome/components/uart/uart_component_esp8266.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index b81c0af85f1..0819a1bfb00 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -257,16 +257,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); gpio_tx_pin_ = tx_pin; gpio_tx_pin_->setup(); - tx_pin_ = gpio_tx_pin_->to_isr(); tx_pin_.digital_write(true); } if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); - rx_pin_ = gpio_rx_pin_->to_isr(); uint8_t *buffer = this->rx_.buffer(); if (this->rx_.buffer_size() != rx_buffer_size) { delete[] buffer; // NOLINT From cf569702c40e22a0440fc4a83fb0605fdd1209eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 15:42:45 -1000 Subject: [PATCH 267/435] [ci] Stop the toolchain tests from installing ESP-IDF (#20413) --- tests/unit_tests/conftest.py | 16 ++++++++++++++++ tests/unit_tests/test_espidf_toolchain.py | 2 ++ 2 files changed, 18 insertions(+) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index ff5564e8150..7835156b01f 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -18,6 +18,7 @@ from unittest.mock import Mock, patch import pytest from esphome.core import CORE +from esphome.espidf import toolchain here = Path(__file__).parent @@ -26,6 +27,21 @@ package_root = here.parent.parent sys.path.insert(0, package_root.as_posix()) +@pytest.fixture(autouse=True) +def _no_idf_install(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail any test that would install ESP-IDF for real. + + An unmocked ``_get_idf_env`` reaches ``check_esp_idf_install``, which + downloads the framework on a bare CI runner (minutes on Windows). The + install path's own tests call it through ``esphome.espidf.framework``. + """ + + def refuse(*args: object, **kwargs: object) -> None: + raise AssertionError("test would install ESP-IDF; mock _get_idf_env") + + monkeypatch.setattr(toolchain, "check_esp_idf_install", refuse) + + @pytest.fixture(autouse=True) def reset_core(): """Reset CORE after each test.""" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 620d08fe3b4..854c39a9ce1 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -1519,6 +1519,7 @@ def test_run_reconfigure_flip_into_skip_mode_cleans_up(setup_core: Path) -> None with ( patch.object(toolchain, "_skip_bootloader", return_value=True), patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "_get_idf_env", return_value={}), patch.object(toolchain, "_tool_env", return_value={}), patch.object(toolchain, "run_build_tool", return_value=0), patch.object(toolchain, "_idf_py") as mock_idf_py, @@ -1542,6 +1543,7 @@ def test_run_reconfigure_skip_steady_state_cleans_nothing(setup_core: Path) -> N with ( patch.object(toolchain, "_skip_bootloader", return_value=True), patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "_get_idf_env", return_value={}), patch.object(toolchain, "_tool_env", return_value={}), patch.object(toolchain, "run_build_tool", return_value=0), patch.object(toolchain, "_idf_py") as mock_idf_py, From 8e6ed71e2a7a7f1399414eb584372e9837c25cca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:26 -1000 Subject: [PATCH 268/435] [ethernet] Collapse duplicate CI test variants (#20406) --- .../ethernet/common-w5500-spi-id.yaml | 17 -------- .../ethernet/test-ip101.esp32-idf.yaml | 7 +++- .../ethernet/test-ipv6.esp32-idf.yaml | 12 ------ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ------------------- .../ethernet/test-w5500-spi-id.esp32-idf.yaml | 3 -- .../test-w5500-wifi-psram.esp32-s3-idf.yaml | 15 ------- .../ethernet/test-w5500.esp32-idf.yaml | 37 +++++++++++++++--- .../ethernet/test.esp32-s3-idf.yaml | 16 +++++++- ...aml => validate-ksz8081rna.esp32-idf.yaml} | 0 ...date-lan8720-with-expander.esp32-idf.yaml} | 0 .../validate-w5500-interface.esp32-idf.yaml | 12 ++++++ 11 files changed, 65 insertions(+), 93 deletions(-) delete mode 100644 tests/components/ethernet/common-w5500-spi-id.yaml delete mode 100644 tests/components/ethernet/test-ipv6.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml rename tests/components/ethernet/{test-ksz8081rna.esp32-idf.yaml => validate-ksz8081rna.esp32-idf.yaml} (100%) rename tests/components/ethernet/{test-lan8720-with-expander.esp32-idf.yaml => validate-lan8720-with-expander.esp32-idf.yaml} (100%) create mode 100644 tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml diff --git a/tests/components/ethernet/common-w5500-spi-id.yaml b/tests/components/ethernet/common-w5500-spi-id.yaml deleted file mode 100644 index 14c01842f0c..00000000000 --- a/tests/components/ethernet/common-w5500-spi-id.yaml +++ /dev/null @@ -1,17 +0,0 @@ -ethernet: - type: W5500 - spi_id: spi_bus - cs_pin: 5 - interrupt_pin: 36 - reset_pin: 22 - clock_speed: 10Mhz - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - domain: .local - mac_address: "02:AA:BB:CC:DD:01" - on_connect: - - logger.log: "Ethernet connected!" - on_disconnect: - - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ip101.esp32-idf.yaml b/tests/components/ethernet/test-ip101.esp32-idf.yaml index e52329d7ea2..628b63d4e97 100644 --- a/tests/components/ethernet/test-ip101.esp32-idf.yaml +++ b/tests/components/ethernet/test-ip101.esp32-idf.yaml @@ -1 +1,6 @@ -<<: !include common-ip101.yaml +packages: + ethernet: !include common-ip101.yaml + +# IPv6 on the internal EMAC: compiles the IPv6 event handler and multicast filter +network: + enable_ipv6: true diff --git a/tests/components/ethernet/test-ipv6.esp32-idf.yaml b/tests/components/ethernet/test-ipv6.esp32-idf.yaml deleted file mode 100644 index 607e82dfa94..00000000000 --- a/tests/components/ethernet/test-ipv6.esp32-idf.yaml +++ /dev/null @@ -1,12 +0,0 @@ -network: - enable_ipv6: true - -ethernet: - type: IP101 - mdc_pin: 23 - mdio_pin: 18 - clk: - pin: 0 - mode: CLK_EXT_IN - phy_addr: 1 - power_pin: 5 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml deleted file mode 100644 index 904a9167896..00000000000 --- a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml +++ /dev/null @@ -1,39 +0,0 @@ -ethernet: - id: eth - type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 - interrupt_pin: 36 - reset_pin: 22 - enable_on_boot: false - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 - -esphome: - on_boot: - priority: 200 - then: - - if: - condition: - not: - ethernet.enabled: - then: - - ethernet.enable: - -button: - - platform: template - name: "Disable Ethernet" - on_press: - - ethernet.disable: - -binary_sensor: - - platform: template - name: "Ethernet Connected" - lambda: |- - return id(eth).is_connected(); diff --git a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml deleted file mode 100644 index 16a25ced2a9..00000000000 --- a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - spi: !include ../../test_build_components/common/spi/esp32-idf.yaml - ethernet: !include common-w5500-spi-id.yaml diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml deleted file mode 100644 index d454dbadc39..00000000000 --- a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM -packages: - ethernet: !include common-w5500.yaml - -psram: - mode: quad - -wifi: - ssid: MySSID - password: password1 - -network: - priority: - - ethernet - - wifi diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index f1551fef603..c99dfaf5281 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -1,20 +1,47 @@ +# W5500 on a shared SPI bus, started from an automation instead of on boot. +# Covers the spi_id attach path and the enable/disable lifecycle in one build. +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + ethernet: + id: eth type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 + spi_id: spi_bus + cs_pin: 5 interrupt_pin: 36 reset_pin: 22 clock_speed: 10Mhz + enable_on_boot: false manual_ip: static_ip: 192.168.178.56 gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 on_connect: - logger.log: "Ethernet connected!" on_disconnect: - logger.log: "Ethernet disconnected!" + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected(); diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml index 36f1b5365f1..d454dbadc39 100644 --- a/tests/components/ethernet/test.esp32-s3-idf.yaml +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -1 +1,15 @@ -<<: !include common-w5500.yaml +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml b/tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml rename to tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml rename to tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml diff --git a/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml new file mode 100644 index 00000000000..a2c62e4a526 --- /dev/null +++ b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml @@ -0,0 +1,12 @@ +# Config only: a user supplied SPI interface on dedicated pins exercises the +# interface option in validation. +ethernet: + type: W5500 + interface: spi3 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 17 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 12 + clock_speed: 10Mhz From 0b40892775d1083b1aeae154c53946a3d056a2cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:42 -1000 Subject: [PATCH 269/435] [mcp23xxx_base] Keep the interrupt handler in IRAM on ESP8266 (#20409) --- esphome/components/mcp23xxx_base/mcp23xxx_base.cpp | 2 ++ esphome/components/mcp23xxx_base/mcp23xxx_base.h | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index b8032fcec8c..8ad2f7521e5 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -4,6 +4,8 @@ namespace esphome::mcp23xxx_base { +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); } + template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 1c45b0f4afa..7414677175e 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -8,6 +8,10 @@ namespace esphome::mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; +// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the +// ISR in flash. +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg); + template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { public: virtual void pin_mode(uint8_t pin, gpio::Flags flags); @@ -35,7 +39,8 @@ template class MCP23XXXBase : public Component, public gpio_expander: void setup_interrupt_pin_() { if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast(this), + gpio::INTERRUPT_FALLING_EDGE); this->set_invalidate_on_read_(false); } // Disable loop until an input pin is configured via pin_mode() @@ -43,7 +48,6 @@ template class MCP23XXXBase : public Component, public gpio_expander: // For polling mode, loop is re-enabled when pin_mode() registers an input pin this->disable_loop(); } - static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); } // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; From db517059a3bc730ecef59964f8941a6d9aebcd90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:32:07 -1000 Subject: [PATCH 270/435] [ota] Collapse duplicate CI test variants (#20407) --- tests/components/ota/common.yaml | 5 ----- tests/components/ota/test-api_key_offer.esp8266-ard.yaml | 2 -- .../components/ota/test-api_runtime_key.esp8266-ard.yaml | 2 -- .../components/ota/test-partition_access.esp32-idf.yaml | 5 ----- tests/components/ota/test.bk72xx-ard.yaml | 4 +++- tests/components/ota/test.esp32-ard.yaml | 4 +++- tests/components/ota/test.esp32-idf.yaml | 9 ++++++++- tests/components/ota/test.esp8266-ard.yaml | 4 +++- tests/components/ota/test.host.yaml | 6 ++---- tests/components/ota/test.rp2040-ard.yaml | 4 +++- ...ard.yaml => validate-empty_password.esp8266-ard.yaml} | 0 ...yaml => validate-encryption_inherit.esp8266-ard.yaml} | 0 tests/components/ota/wifi.yaml | 3 +++ 13 files changed, 25 insertions(+), 23 deletions(-) delete mode 100644 tests/components/ota/test-api_key_offer.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-api_runtime_key.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml rename tests/components/ota/{test-empty_password.esp8266-ard.yaml => validate-empty_password.esp8266-ard.yaml} (100%) rename tests/components/ota/{test-encryption_inherit.esp8266-ard.yaml => validate-encryption_inherit.esp8266-ard.yaml} (100%) create mode 100644 tests/components/ota/wifi.yaml diff --git a/tests/components/ota/common.yaml b/tests/components/ota/common.yaml index 1433dada1ff..af8cf66708d 100644 --- a/tests/components/ota/common.yaml +++ b/tests/components/ota/common.yaml @@ -1,11 +1,6 @@ -wifi: - ssid: MySSID - password: password1 - ota: - platform: esphome password: "superlongpasswordthatnoonewillknow" - port: 3286 on_begin: then: - logger.log: "OTA start" diff --git a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml b/tests/components/ota/test-api_key_offer.esp8266-ard.yaml deleted file mode 100644 index ecda6255219..00000000000 --- a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_key_offer.yaml diff --git a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml b/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml deleted file mode 100644 index 4709a9e45ce..00000000000 --- a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_runtime_key.yaml diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml deleted file mode 100644 index 0cbf8549520..00000000000 --- a/tests/components/ota/test-partition_access.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -ota: - - platform: esphome - allow_partition_access: true - -<<: !include common.yaml diff --git a/tests/components/ota/test.bk72xx-ard.yaml b/tests/components/ota/test.bk72xx-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.bk72xx-ard.yaml +++ b/tests/components/ota/test.bk72xx-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-ard.yaml b/tests/components/ota/test.esp32-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.esp32-ard.yaml +++ b/tests/components/ota/test.esp32-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index dade44d145b..7f25fafadbb 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1 +1,8 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml + +# Final validation merges this into the common block +ota: + - platform: esphome + allow_partition_access: true diff --git a/tests/components/ota/test.esp8266-ard.yaml b/tests/components/ota/test.esp8266-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.esp8266-ard.yaml +++ b/tests/components/ota/test.esp8266-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.host.yaml b/tests/components/ota/test.host.yaml index ae7c4d0add7..fdfbf47139a 100644 --- a/tests/components/ota/test.host.yaml +++ b/tests/components/ota/test.host.yaml @@ -1,4 +1,2 @@ -<<: !include common.yaml - -#host platform does not support wifi / network is automatically included -wifi: !remove +packages: + ota: !include common.yaml diff --git a/tests/components/ota/test.rp2040-ard.yaml b/tests/components/ota/test.rp2040-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.rp2040-ard.yaml +++ b/tests/components/ota/test.rp2040-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test-empty_password.esp8266-ard.yaml b/tests/components/ota/validate-empty_password.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-empty_password.esp8266-ard.yaml rename to tests/components/ota/validate-empty_password.esp8266-ard.yaml diff --git a/tests/components/ota/test-encryption_inherit.esp8266-ard.yaml b/tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-encryption_inherit.esp8266-ard.yaml rename to tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml diff --git a/tests/components/ota/wifi.yaml b/tests/components/ota/wifi.yaml new file mode 100644 index 00000000000..ac662302fc7 --- /dev/null +++ b/tests/components/ota/wifi.yaml @@ -0,0 +1,3 @@ +wifi: + ssid: MySSID + password: password1 From b1f5da956149c1ba1fec5d80123a8b454b831294 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:30 -1000 Subject: [PATCH 271/435] [esp8266] Keep ArduinoJson's float tables in flash (Arduino core 3.1.2-esphome.3) (#20416) --- esphome/arduino8266/framework.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 48fd72d1da7..82e00ff8976 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple): # Arduino core version -> its build in esphome-libs/arduino-esp8266 FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { Version(3, 1, 2): FrameworkRelease( - "3.1.2-esphome.2", - "9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7", - 37192128, + "3.1.2-esphome.3", + "3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db", + 37185650, ), } From 061733672faca783e8ad4bcf8304cec2077f3ce9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:12:35 -1000 Subject: [PATCH 272/435] [ci] Refit component batch estimates to a full run (#20401) --- script/split_components_for_ci.py | 172 ++++++++++++++----- tests/script/test_determine_jobs.py | 14 +- tests/script/test_split_components_for_ci.py | 121 +++++++++++-- 3 files changed, 248 insertions(+), 59 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index cc458d5d664..26907936c17 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -13,15 +13,20 @@ from __future__ import annotations import argparse from collections import defaultdict +from collections.abc import Set as AbstractSet +import contextlib from dataclasses import dataclass, field +import functools import json import math from pathlib import Path import sys +from typing import Any, NamedTuple # Add esphome to path sys.path.insert(0, str(Path(__file__).parent.parent)) +from esphome import yaml_util from script.analyze_component_buses import ( ISOLATED_COMPONENTS, ISOLATED_SIGNATURE_PREFIX, @@ -31,29 +36,52 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, ) from script.helpers import ( + _extract_components_from_yaml, get_component_test_files, parse_test_filename, split_conflicting_groups, ) -# Estimated CI seconds for one build per test platform, read from batch job -# logs. Absolute accuracy does not matter, only the ratios between platforms. -# A full run takes over an hour, so recording real timings is not practical. +# Estimated CI seconds for one build per test platform, fitted to the batch +# job logs of a full run (every component, PR #20389). Absolute accuracy does +# not matter, only the ratios between platforms. A full run takes over an +# hour, so recording real timings is not practical. PLATFORM_BUILD_SECONDS = { "host": 10, "esp8266-ard": 30, - "esp32-idf": 45, - "rtl87xx-ard": 35, - "ln882x-ard": 50, - "rp2040-ard": 50, - "rp2350-ard": 50, - "bk72xx-ard": 60, + "rtl87xx-ard": 40, + "esp32-c3-idf": 40, + "esp32-s2-idf": 40, + "rp2350-ard": 40, + "rp2040-ard": 45, + "esp32-idf": 55, + "esp32-p4-idf": 55, + "ln882x-ard": 55, + "esp32-s3-idf": 60, + "bk72xx-ard": 70, + "esp32-c6-idf": 70, } -NRF52_BUILD_SECONDS = 45 -# Other ESP32 chips and Arduino on ESP32 share less of the ccache -DEFAULT_BUILD_SECONDS = 90 +NRF52_BUILD_SECONDS = 50 +# Arduino on any ESP32 chip builds the IDF plus the Arduino core on top +ESP32_ARDUINO_BUILD_SECONDS = 90 +# The remaining ESP32 chips on ESP-IDF +DEFAULT_BUILD_SECONDS = 50 # Each extra component merged into a grouped build makes it larger -GROUPED_COMPONENT_SECONDS = 5 +GROUPED_COMPONENT_SECONDS = 3 +# Components that pull in a large library the build compiles from source +# (TensorFlow Lite Micro, FastLED, LVGL, the HTTP server), added to every +# build whose test config enables them. Fitted the same way as the platforms. +HEAVY_COMPONENT_SECONDS: dict[str, int] = { + "micro_wake_word": 200, + "fastled_clockless": 90, + "fastled_spi": 90, + "wled": 60, + "lvgl": 50, + "web_server": 50, + "online_image": 45, + "prometheus": 35, + "bluetooth_proxy": 30, +} # Estimated build seconds per CI runner; sets how many runners are used. # Approximate: the runner count charges each grouped build once, but a large # group spreads over several runners, which each pay for that build. @@ -90,9 +118,62 @@ def build_seconds(platform: str) -> int: return seconds if platform.startswith("nrf52"): return NRF52_BUILD_SECONDS + if platform.startswith("esp32") and platform.endswith("-ard"): + return ESP32_ARDUINO_BUILD_SECONDS return DEFAULT_BUILD_SECONDS +def _config_components(data: Any) -> set[str]: + """Return the components a loaded test config and its ``packages:`` use.""" + if not isinstance(data, dict): + return set() + found = _extract_components_from_yaml(data) + if isinstance(packages := data.get("packages"), dict): + for package in packages.values(): + if isinstance(package, yaml_util.IncludeFile): + found |= _file_components( + (package.parent_file.parent / package.file).resolve() + ) + else: + found |= _config_components(package) + return found + + +@functools.cache +def _file_components(path: Path) -> frozenset[str]: + """Return the components a config file uses; shared packages load once.""" + return frozenset( + _config_components(yaml_util.load_yaml(path, track_document_range=False)) + ) + + +def heavy_components(component: str, test_file: Path) -> frozenset[str]: + """Return the heavy components a test compiles. + + A test counts as heavy when it belongs to a heavy component or when its + config enables one, such as a display test that draws with lvgl. + """ + heavy = {component} & HEAVY_COMPONENT_SECONDS.keys() + # A config that does not load is left to the build to report + with contextlib.suppress(Exception): + heavy |= _file_components(test_file.resolve()) & HEAVY_COMPONENT_SECONDS.keys() + return frozenset(heavy) + + +def heavy_seconds(heavy: AbstractSet[str]) -> int: + """Return the library seconds a set of heavy components adds to a build.""" + return sum(HEAVY_COMPONENT_SECONDS[name] for name in heavy) + + +class _GroupedShare(NamedTuple): + """A component's part in one (signature, platform) shared build.""" + + # Seconds of its files on that platform when no other member shares it + solo: int + # Heavy components its base test brings into the shared build + heavy: frozenset[str] + + @dataclass class _BatchItem: """A component and the builds it adds to the batch it lands in.""" @@ -101,40 +182,52 @@ class _BatchItem: # Builds that always run on their own (isolated tests, variants on a # platform without a base test) own_seconds: int - # Per (signature, platform) shared build: the seconds of this component's - # files on that platform when no other batch member shares the build - grouped_builds: dict[tuple[str, str], int] = field(default_factory=dict) + grouped_builds: dict[tuple[str, str], _GroupedShare] = field(default_factory=dict) def standalone_seconds(self) -> int: - return self.own_seconds + sum(self.grouped_builds.values()) + return self.own_seconds + sum( + share.solo for share in self.grouped_builds.values() + ) -def _grouped_build_seconds(platform: str, solo_seconds: list[int]) -> int: +def _grouped_build_seconds(platform: str, members: list[_GroupedShare]) -> int: """Return the seconds of one (signature, platform) build in a batch. + A library two members both enable compiles once, so the union is charged. test_build_components only groups when two or more members share the build; a grouped member then skips its variants on that platform. """ - if len(solo_seconds) <= 1: - return sum(solo_seconds) - return build_seconds(platform) + GROUPED_COMPONENT_SECONDS * (len(solo_seconds) - 1) + if len(members) <= 1: + return sum(share.solo for share in members) + return ( + build_seconds(platform) + + GROUPED_COMPONENT_SECONDS * (len(members) - 1) + + heavy_seconds(set().union(*(share.heavy for share in members))) + ) def _make_item( tests_dir: Path, component: str, signature: str, is_isolated: bool ) -> _BatchItem: - files_by_platform: dict[str, list[str]] = defaultdict(list) + # Per platform: the seconds of its test files, and the base test's heavy + # components; a platform without a base test cannot join a shared build + seconds_by_platform: dict[str, int] = defaultdict(int) + base_heavy: dict[str, frozenset[str]] = {} for test_file in (tests_dir / component).glob("test[.-]*.yaml"): test_name, platform = parse_test_filename(test_file) - files_by_platform[platform].append(test_name) + heavy = heavy_components(component, test_file) + seconds_by_platform[platform] += build_seconds(platform) + heavy_seconds(heavy) + if test_name == "test": + base_heavy[platform] = heavy own_seconds = 0 - grouped: dict[tuple[str, str], int] = {} - for platform, test_names in files_by_platform.items(): - seconds = build_seconds(platform) * len(test_names) - if is_isolated or "test" not in test_names: + grouped: dict[tuple[str, str], _GroupedShare] = {} + for platform, seconds in seconds_by_platform.items(): + if is_isolated or platform not in base_heavy: own_seconds += seconds else: - grouped[(signature, platform)] = seconds + grouped[(signature, platform)] = _GroupedShare( + seconds, base_heavy[platform] + ) return _BatchItem(component, own_seconds, grouped) @@ -144,22 +237,24 @@ class _Batch: def __init__(self) -> None: self.components: list[str] = [] self.seconds = 0 - self.grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + self.grouped_builds: dict[tuple[str, str], list[_GroupedShare]] = defaultdict( + list + ) def added_seconds(self, item: _BatchItem) -> int: """Return the seconds item would add; joining an existing build is cheap.""" added = item.own_seconds - for (signature, platform), solo in item.grouped_builds.items(): + for (signature, platform), share in item.grouped_builds.items(): members = self.grouped_builds.get((signature, platform), []) added += _grouped_build_seconds( - platform, [*members, solo] + platform, [*members, share] ) - _grouped_build_seconds(platform, members) return added def add(self, item: _BatchItem) -> None: self.seconds += self.added_seconds(item) - for build, solo in item.grouped_builds.items(): - self.grouped_builds[build].append(solo) + for build, share in item.grouped_builds.items(): + self.grouped_builds[build].append(share) self.components.append(item.component) @@ -172,14 +267,11 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """ if not items: return [] - grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + # One batch holding every item counts each grouped build once + whole = _Batch() for item in items: - for build, solo in item.grouped_builds.items(): - grouped_builds[build].append(solo) - total = sum(item.own_seconds for item in items) + sum( - _grouped_build_seconds(platform, solos) - for (_, platform), solos in grouped_builds.items() - ) + whole.add(item) + total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7e9b09b2b93..e63e0849deb 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2613,8 +2613,8 @@ def test_component_batching_beta_branch_groups_evenly( For beta/release branches, all components should be groupable (not isolated), so they share one grouped build per runner and spread evenly. """ - # Create 120 test components with test files - component_names = [f"comp_{i:03d}" for i in range(120)] + # Create 200 test components with test files + component_names = [f"comp_{i:03d}" for i in range(200)] tests_dir = tmp_path / "tests" / "components" for comp in component_names: @@ -2675,15 +2675,15 @@ def test_component_batching_beta_branch_groups_evenly( assert "component_test_batches" in output batches = output["component_test_batches"] - # One grouped esp32-idf build plus 5 s per extra component is 640 s, - # so two runners of 60 components each - assert [len(batch["components"].split()) for batch in batches] == [60, 60] + # One grouped esp32-idf build plus 3 s per extra component is 652 s, + # so two runners of 100 components each + assert [len(batch["components"].split()) for batch in batches] == [100, 100] - # Verify all 120 components are in batches + # Verify all 200 components are in batches all_components = [] for batch in batches: all_components.extend(batch["components"].split()) - assert len(all_components) == 120 + assert len(all_components) == 200 assert set(all_components) == set(component_names) diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 88a1a17b333..4da644d6798 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -35,13 +35,46 @@ def test_build_seconds() -> None: """Known platforms use the table; nrf52 boards share one estimate.""" build_seconds = split_components_for_ci.build_seconds assert build_seconds("host") == 10 - assert build_seconds("esp32-idf") == 45 + assert build_seconds("esp32-idf") == 55 assert ( build_seconds("nrf52-xiao-ble") == split_components_for_ci.NRF52_BUILD_SECONDS ) assert ( - build_seconds("esp32-s3-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS + build_seconds("esp32-c5-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS ) + assert ( + build_seconds("esp32-c3-ard") + == split_components_for_ci.ESP32_ARDUINO_BUILD_SECONDS + ) + + +def test_heavy_components(tests_dir: Path) -> None: + """A heavy component, or a test that enables one, costs its library time.""" + heavy_components = split_components_for_ci.heavy_components + heavy_seconds = split_components_for_ci.heavy_seconds + table = split_components_for_ci.HEAVY_COMPONENT_SECONDS + _add_component(tests_dir, "dht", ["test.esp32-idf.yaml"]) + _add_component(tests_dir, "micro_wake_word", ["test.esp32-idf.yaml"]) + assert heavy_components("dht", tests_dir / "dht/test.esp32-idf.yaml") == set() + assert heavy_components( + "micro_wake_word", tests_dir / "micro_wake_word/test.esp32-idf.yaml" + ) == {"micro_wake_word"} + assert heavy_seconds(frozenset({"micro_wake_word"})) == table["micro_wake_word"] + + display = tests_dir / "mipi_spi" + display.mkdir() + (display / "common.yaml").write_text("web_server:\n port: 80\n") + (display / "test-lvgl.esp32-s3-idf.yaml").write_text( + "packages:\n" + " mipi_spi: !include common.yaml\n" + "lvgl:\n pages: []\n" + "light:\n - platform: fastled_spi\n num_leds: 1\n" + ) + assert heavy_components("mipi_spi", display / "test-lvgl.esp32-s3-idf.yaml") == { + "lvgl", + "web_server", + "fastled_spi", + } def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: @@ -61,29 +94,93 @@ def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: isolated = split_components_for_ci._make_item( tests_dir, "comp", "isolated_comp", True ) - assert isolated.own_seconds == 45 + 10 + 45 + 50 + assert isolated.own_seconds == 55 + 10 + 55 + 45 assert isolated.grouped_builds == {} # The rp2040 variant has no base test to group with, so it always runs grouped = split_components_for_ci._make_item(tests_dir, "comp", "i2c", False) - assert grouped.own_seconds == 50 - assert grouped.grouped_builds == {("i2c", "esp32-idf"): 90, ("i2c", "host"): 10} + assert grouped.own_seconds == 45 + assert grouped.grouped_builds == { + ("i2c", "esp32-idf"): (110, frozenset()), + ("i2c", "host"): (10, frozenset()), + } + + +def test_make_item_heavy_component(tests_dir: Path) -> None: + """A heavy component's library cost follows it into every build.""" + _add_component(tests_dir, "lvgl", ["test.esp32-idf.yaml", "test-a.host.yaml"]) + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + + item = split_components_for_ci._make_item(tests_dir, "lvgl", "spi", False) + assert item.own_seconds == 10 + extra + assert item.grouped_builds == { + ("spi", "esp32-idf"): (55 + extra, frozenset({"lvgl"})) + } + + +def test_heavy_cost_is_per_platform(tests_dir: Path) -> None: + """A base test that enables a heavy component only charges its own platform.""" + comp = tests_dir / "web_server_idf" + comp.mkdir() + (comp / "test.esp32-idf.yaml").write_text("web_server:\n port: 80\n") + (comp / "test.host.yaml").write_text("logger:\n") + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["web_server"] + + item = split_components_for_ci._make_item( + tests_dir, "web_server_idf", "none", False + ) + assert item.grouped_builds == { + ("none", "esp32-idf"): (55 + extra, frozenset({"web_server"})), + ("none", "host"): (10, frozenset()), + } def test_grouped_component_joins_existing_build() -> None: """A second member turns a lone build into a group that skips variants.""" item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare batch = split_components_for_ci._Batch() - batch.add(item("a", 0, {("i2c", "esp32-idf"): 90})) - assert batch.seconds == 90 + batch.add(item("a", 0, {("i2c", "esp32-idf"): share(110, frozenset())})) + assert batch.seconds == 110 - other = item("b", 0, {("i2c", "esp32-idf"): 45, ("i2c", "host"): 10}) - grouped = 45 + split_components_for_ci.GROUPED_COMPONENT_SECONDS - assert batch.added_seconds(other) == grouped - 90 + 10 + other = item( + "b", + 0, + { + ("i2c", "esp32-idf"): share(55, frozenset()), + ("i2c", "host"): share(10, frozenset()), + }, + ) + grouped = 55 + split_components_for_ci.GROUPED_COMPONENT_SECONDS + assert batch.added_seconds(other) == grouped - 110 + 10 batch.add(other) assert batch.seconds == grouped + 10 +def test_heavy_component_charges_the_shared_build() -> None: + """A heavy member makes the whole grouped build slower, once per library.""" + item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare + batch = split_components_for_ci._Batch() + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + per_member = split_components_for_ci.GROUPED_COMPONENT_SECONDS + lvgl = frozenset({"lvgl"}) + batch.add(item("a", 0, {("spi", "esp32-idf"): share(55, frozenset())})) + + heavy = item("lvgl", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + grouped = 55 + per_member + extra + assert batch.added_seconds(heavy) == grouped - 55 + batch.add(heavy) + assert batch.seconds == grouped + + light = item("b", 0, {("spi", "esp32-idf"): share(55, frozenset())}) + assert batch.added_seconds(light) == per_member + + # A second member that also draws with lvgl compiles the library once + also_heavy = item("c", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + assert batch.added_seconds(also_heavy) == per_member + + def test_isolated_components_balance_across_runners(tests_dir: Path) -> None: """Heavy isolated components spread out and light ones fill the gaps.""" platforms = ["esp32-idf", "esp8266-ard", "rp2040-ard", "bk72xx-ard"] @@ -138,7 +235,7 @@ def test_groupable_components_split_evenly(tests_dir: Path) -> None: components=names, tests_dir=tests_dir, target_seconds=100 ) - assert sorted(len(batch) for batch in batches) == [2, 2, 2, 3, 3] + assert sorted(len(batch) for batch in batches) == [3, 3, 3, 3] assert sorted(c for batch in batches for c in batch) == names @@ -149,7 +246,7 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: _add_component(other, "comp", ["test.esp32-idf.yaml"]) item = split_components_for_ci._make_item(other, "comp", "isolated_comp", True) - assert item.own_seconds == 45 + assert item.own_seconds == 55 def test_balance_batches_empty() -> None: From 22e594bc629c17833fba59f4c53cc571cc32ec85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:16:17 -1000 Subject: [PATCH 273/435] [esp32_ble_client] Track conn_id per attempt and stop logging an error on a failed open (#20417) --- .../esp32_ble_client/ble_client_base.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 88454f7bdbf..cfd77e84f4a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -150,6 +150,8 @@ void BLEClientBase::connect() { // A registration whose event never arrived must not block this connection's release. this->services_released_ = false; this->pending_notify_regs_ = 0; + // A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own. + this->conn_id_ = UNSET_CONN_ID; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_gattc_lifecycle_event_("OPEN"); - // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an @@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } - if (this->state() != espbt::ClientState::CONNECTING) { - // This should not happen but lets log it in case it does - // because it means we have a bad assumption about how the - // ESP BT stack works. + const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN; + // Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT, + // so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption + // about how the ESP BT stack works, so log it. + const bool expected_failure = + open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID; + if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) { ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state()), param->open.status); } - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + if (open_failed) { this->log_gattc_warning_("Connection open", param->open.status); // Connection was never established so CLOSE_EVT may not follow this->set_idle_(); break; } + // An open on a link that is already up sends no CONNECT_EVT, so take the id here. + this->conn_id_ = param->open.conn_id; if (this->want_disconnect_) { // Disconnect was requested after connecting started, // but before the connection was established. Now that we have From e61d348f0c245bad74acca633e420423618f2914 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:20:59 -1000 Subject: [PATCH 274/435] [wifi] Use numeric scheduler ids instead of string names (#20357) --- esphome/components/wifi/automation.h | 11 +++++++---- esphome/components/wifi/wifi_component.cpp | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index c14341330f5..eb3d7471dc1 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -76,13 +76,13 @@ template class WiFiConfigureAction final : public Action, // Enable WiFi global_wifi_component->enable(); // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { + this->set_timeout(CONNECT_TIMEOUT_ID, this->connection_timeout_.value(x...), [this, x...]() { // If the timeout is reached, stop connecting and revert to the old AP global_wifi_component->disable(); global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); global_wifi_component->enable(); // Start a timeout for the fallback if the connection to the old AP fails - this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->set_timeout(FALLBACK_TIMEOUT_ID, this->connection_timeout_.value(x...), [this]() { this->connecting_ = false; this->error_trigger_.trigger(); }); @@ -97,8 +97,8 @@ template class WiFiConfigureAction final : public Action, return; if (global_wifi_component->is_connected()) { // The WiFi is connected, stop the timeout and reset the connecting flag - this->cancel_timeout("wifi-connect-timeout"); - this->cancel_timeout("wifi-fallback-timeout"); + this->cancel_timeout(CONNECT_TIMEOUT_ID); + this->cancel_timeout(FALLBACK_TIMEOUT_ID); this->connecting_ = false; char ssid_buf[SSID_BUFFER_SIZE]; if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), this->new_sta_.get_ssid().c_str()) == 0) { @@ -112,6 +112,9 @@ template class WiFiConfigureAction final : public Action, } protected: + static constexpr uint32_t CONNECT_TIMEOUT_ID = 0; + static constexpr uint32_t FALLBACK_TIMEOUT_ID = 1; + bool connecting_{false}; WiFiAP new_sta_; WiFiAP old_sta_; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fd982c0d312..594e59827aa 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -398,6 +398,7 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; /// disconnect event cannot keep the networks from being tried for good. static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; #endif /// Cooldown duration when fallback AP is active and captive portal may be running @@ -1222,7 +1223,7 @@ void WiFiComponent::connect_soon_() { // New credentials from the portal: the AP pauses for them once its answer // is out, and the STA state machine picks them up from there. if (this->ap_setup_) { - this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { if (this->ap_setup_) this->pause_exclusive_ap_(); }); From 3b0c4857016f9a447b175adc51fa98c37045db41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:29 -1000 Subject: [PATCH 275/435] [mqtt] Use a numeric scheduler id for the deferred state publish (#20359) --- esphome/components/mqtt/mqtt_component.h | 2 ++ esphome/components/mqtt/mqtt_fan.cpp | 2 +- esphome/components/mqtt/mqtt_light.cpp | 2 +- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index b4ae6244048..141b5f12b38 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -273,6 +273,8 @@ class MQTTComponent : public Component { void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); protected: + static constexpr uint32_t SEND_DEFER_ID = 0; + /// Helper method to get the discovery topic for this component into a buffer. StringRef get_discovery_topic_to_(std::span buf, const MQTTDiscoveryInfo &discovery_info) const; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 3a8658a55fe..6560f200a10 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -121,7 +121,7 @@ void MQTTFanComponent::setup() { }); } - this->state_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->state_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTFanComponent::dump_config() { diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index a8b52a3839f..31327aa0439 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -28,7 +28,7 @@ void MQTTJSONLightComponent::setup() { } void MQTTJSONLightComponent::on_light_remote_values_update() { - this->defer("send", [this]() { this->publish_state_(); }); + this->defer(SEND_DEFER_ID, [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 369c1c6668b..7dd7317949e 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -29,7 +29,7 @@ void MQTTLockComponent::setup() { } }); this->lock_->add_on_state_callback( - [this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); }); + [this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTLockComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index 70cd03a4eb5..c7610b30067 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -34,7 +34,7 @@ void MQTTSwitchComponent::setup() { } }); this->switch_->add_on_state_callback( - [this](bool enabled) { this->defer("send", [this, enabled]() { this->publish_state(enabled); }); }); + [this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); }); } void MQTTSwitchComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index c01fb9e52e0..c594a6fe269 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -24,7 +24,7 @@ void MQTTUpdateComponent::setup() { } }); - this->update_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->update_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } bool MQTTUpdateComponent::publish_state() { From 01686a5f677e6573ae6e107aa5fb7de00b40b387 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:53 -1000 Subject: [PATCH 276/435] [improv_serial] Use a numeric scheduler id for the Wi-Fi connect timeout (#20360) --- .../components/improv_serial/improv_serial_component.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 3827fb6ed45..cccb78d3bf0 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -18,6 +18,8 @@ namespace esphome::improv_serial { static const char *const TAG = "improv_serial"; +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; + void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_IMPROV_SERIAL_UART @@ -73,7 +75,7 @@ void ImprovSerialComponent::loop() { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); this->set_state_(improv::STATE_PROVISIONED); this->send_settings_response_(improv::WIFI_SETTINGS); @@ -302,7 +304,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, [this]() { this->on_wifi_connect_timeout_(); }); #else // No Wi-Fi support compiled in; there is nothing to provision. From 3284d93a33fbe1a3b86c07fd73fa547876056287 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:36:42 -1000 Subject: [PATCH 277/435] [nrf52] Fake a larger flash in testing mode (#20439) --- esphome/components/nrf52/__init__.py | 38 ++++++++++++++++++- .../components/zephyr_mcumgr/ota/__init__.py | 8 ++-- .../nrf52/test_testing_mode_flash.py | 30 +++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/components/nrf52/test_testing_mode_flash.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 86d485777ca..631959530eb 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -12,6 +12,7 @@ from esphome.build_helpers import pch from esphome.build_helpers.ccache import resolve_ccache_path import esphome.codegen as cg from esphome.components.zephyr import ( + Section, add_extra_script, copy_files as zephyr_copy_files, zephyr_add_overlay, @@ -125,11 +126,37 @@ def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: - zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]) + sections = BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]] + if CORE.testing_mode: + sections = _testing_mode_sections(sections) + zephyr_add_pm_static(sections) return config +# In testing mode, fake a larger flash to allow linking grouped component +# tests. The nRF52840 has 1 MB and a mcumgr OTA config halves the app slot, +# which an openthread config alone fills; CI images are never flashed. +NRF52840_FLASH_SIZE = 0x100000 +TESTING_FLASH_SIZE = 0x400000 + + +def _testing_mode_sections(sections: list[Section]) -> list[Section]: + """Move a bootloader pinned to the end of the real flash to the end of the + faked one, so the partition manager still sees a single gap for the app.""" + return [ + Section( + section.name, + section.address + TESTING_FLASH_SIZE - NRF52840_FLASH_SIZE, + section.size, + section.region, + ) + if section.end_address == NRF52840_FLASH_SIZE + else section + for section in sections + ] + + _TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF) _resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF) @@ -380,6 +407,15 @@ async def to_code(config: ConfigType) -> None: zephyr_setup_preferences() zephyr_to_code(config) + if CORE.testing_mode: + zephyr_add_overlay( + f""" + &flash0 {{ + reg = <0x0 {TESTING_FLASH_SIZE:#x}>; + }}; + """ + ) + if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index f5ab04809e4..d4f84e30f24 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -13,6 +13,7 @@ from esphome.components.zephyr import ( from esphome.components.zephyr.const import ( BOOTLOADER_MCUBOOT, KEY_BOOTLOADER, + KEY_PM_STATIC, KEY_SYSBUILD, ) from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu @@ -207,10 +208,11 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - sections = BOOTLOADER_CONFIG[bootloader] - # Derive partition addresses from the SoftDevice and bootloader sections so - # that the DTS flash map matches what the Partition Manager produces: + # Derive partition addresses from the SoftDevice and bootloader sections + # registered with the Partition Manager (moved in testing mode) so that + # the DTS flash map matches what it produces: # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) bl_start = next(s.address for s in sections if "Adafruit" in s.name) diff --git a/tests/unit_tests/components/nrf52/test_testing_mode_flash.py b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py new file mode 100644 index 00000000000..7c66cd3361b --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py @@ -0,0 +1,30 @@ +"""Testing mode fakes a larger flash so grouped component tests link.""" + +from esphome.components import nrf52 +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr import Section + + +def test_testing_mode_sections_move_only_the_bootloader() -> None: + """The bootloader pinned to the end of flash follows the faked end; the + SoftDevice at the start stays.""" + sections = nrf52._testing_mode_sections( + BOOTLOADER_CONFIG[BOOTLOADER_ADAFRUIT_NRF52_SD140_V7] + ) + + assert [(s.name, s.address, s.size) for s in sections] == [ + ("SoftDevice", 0x0, 0x27000), + ( + "Adafruit_nRF52_Bootloader", + nrf52.TESTING_FLASH_SIZE - 0xC000, + 0xC000, + ), + ] + assert sections[1].end_address == nrf52.TESTING_FLASH_SIZE + + +def test_testing_mode_sections_leave_unpinned_sections() -> None: + section = Section("other", 0x50000, 0x1000, "flash_primary") + + assert nrf52._testing_mode_sections([section]) == [section] From 54f86537aded462e25a936080c902d70e593963e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:02:22 -1000 Subject: [PATCH 278/435] [esp32][libretiny] Skip registering static destructors that never run (#20440) --- esphome/components/esp32/__init__.py | 5 +++++ esphome/components/esp32/atexit_stubs.cpp | 20 +++++++++++++++++++ esphome/components/libretiny/__init__.py | 3 +++ esphome/components/libretiny/atexit_stubs.cpp | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 esphome/components/esp32/atexit_stubs.cpp create mode 100644 esphome/components/libretiny/atexit_stubs.cpp diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c825deaefd9..73078dc19fd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2925,6 +2925,11 @@ async def to_code(config): # produce reproducible outputs and downstream tooling can reuse artifacts. add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + # --undefined: libsrc.a is scanned before the IDF libraries that also register them. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit") + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") diff --git a/esphome/components/esp32/atexit_stubs.cpp b/esphome/components/esp32/atexit_stubs.cpp new file mode 100644 index 00000000000..1230a566814 --- /dev/null +++ b/esphome/components/esp32/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables (about 400 bytes of RAM on ESP32). + */ + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP32 diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 5d630fec6cc..433c8aee740 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -491,6 +491,9 @@ async def component_to_code(config): # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + # LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c). if config[CONF_COMPONENT_ID] == COMPONENT_LN882X: cg.add_build_flag("-Wl,--wrap=pvPortMalloc") diff --git a/esphome/components/libretiny/atexit_stubs.cpp b/esphome/components/libretiny/atexit_stubs.cpp new file mode 100644 index 00000000000..1d237e7f059 --- /dev/null +++ b/esphome/components/libretiny/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables. + */ + +#include "esphome/core/defines.h" + +#ifdef USE_LIBRETINY + +namespace esphome::libretiny {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_LIBRETINY From 28b012389d0f22bd947aab2a57a5d5ad02fc8d02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:10:18 -1000 Subject: [PATCH 279/435] [ci] Keep bus group partners on the same runner (#20431) --- script/split_components_for_ci.py | 22 ++++++++++++++++---- tests/script/test_split_components_for_ci.py | 18 ++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 26907936c17..663f2c6d5d7 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -262,8 +262,9 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """Spread items over enough runners to stay near target_seconds each. The runner count comes from the total estimate with every grouped build - counted once. Heaviest items go first, each to the runner that ends up - lightest, so grouped components follow the builds they can join. + counted once. Heaviest items go first, each to the runner it adds the + fewest seconds to among those ending near the lightest, so grouped + components follow the builds they can join. """ if not items: return [] @@ -274,10 +275,23 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] + # Among the runners ending within slack of the lightest, the one the item + # adds the fewest seconds to wins, so a component lands with the bus + # group partners it shares builds with; placed away from them it builds + # alone on every platform they only share there. + slack = target_seconds // 4 for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): - min(batches, key=lambda b: (b.seconds + b.added_seconds(item), b.seconds)).add( - item + costs = [(batch, batch.added_seconds(item)) for batch in batches] + ceiling = min(batch.seconds + added for batch, added in costs) + slack + batch, _ = min( + ( + (batch, added) + for batch, added in costs + if batch.seconds + added <= ceiling + ), + key=lambda cost: (cost[1], cost[0].seconds + cost[1]), ) + batch.add(item) return [batch.components for batch in batches if batch.components] diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 4da644d6798..718d754f016 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -249,6 +249,24 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: assert item.own_seconds == 55 +def test_balance_batches_keeps_partners_together() -> None: + """Two components sharing a build land on one runner even when the + lightest runner is the other one.""" + share = { + ("i2c", "esp32-idf"): split_components_for_ci._GroupedShare(55, frozenset()), + ("i2c", "esp8266-ard"): split_components_for_ci._GroupedShare(30, frozenset()), + } + items = [ + split_components_for_ci._BatchItem("partner_a", 0, dict(share)), + split_components_for_ci._BatchItem("partner_b", 0, dict(share)), + split_components_for_ci._BatchItem("alone", 80), + ] + + batches = split_components_for_ci.balance_batches(items, target_seconds=120) + + assert sorted(batches) == [["alone"], ["partner_a", "partner_b"]] + + def test_balance_batches_empty() -> None: """No components means no runners.""" assert split_components_for_ci.balance_batches([], 600) == [] From 58815575efa79d324d83736187316ea24f0a4e66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:24:32 -1000 Subject: [PATCH 280/435] [core] Pass a single condition to actions directly instead of wrapping it in a group (#19223) --- esphome/automation.py | 57 ++----------------- tests/component_tests/automation/__init__.py | 0 .../automation/config/condition_groups.yaml | 44 ++++++++++++++ .../automation/test_condition_groups.py | 25 ++++++++ 4 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 tests/component_tests/automation/__init__.py create mode 100644 tests/component_tests/automation/config/condition_groups.yaml create mode 100644 tests/component_tests/automation/test_condition_groups.py diff --git a/esphome/automation.py b/esphome/automation.py index b69edd04a13..b1a35bf4c02 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -672,52 +672,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition) @register_condition("and", AndCondition, validate_condition_list) -async def and_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - -@register_condition("or", OrCondition, validate_condition_list) -async def or_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("all", AndCondition, validate_condition_list) -async def all_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - +@register_condition("or", OrCondition, validate_condition_list) @register_condition("any", OrCondition, validate_condition_list) -async def any_condition_to_code( +@register_condition("xor", XorCondition, validate_condition_list) +async def condition_group_to_code( config: ConfigType, condition_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) + # A group of one is that condition for and, or and xor; skip the wrapper. + if len(conditions) == 1: + return conditions[0] return cg.new_Pvariable( condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions ) @@ -734,19 +702,6 @@ async def not_condition_to_code( return cg.new_Pvariable(condition_id, template_arg, condition) -@register_condition("xor", XorCondition, validate_condition_list) -async def xor_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("lambda", LambdaCondition, cv.returning_lambda) async def lambda_condition_to_code( config: ConfigType, diff --git a/tests/component_tests/automation/__init__.py b/tests/component_tests/automation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/automation/config/condition_groups.yaml b/tests/component_tests/automation/config/condition_groups.yaml new file mode 100644 index 00000000000..9624531862f --- /dev/null +++ b/tests/component_tests/automation/config/condition_groups.yaml @@ -0,0 +1,44 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +logger: + +binary_sensor: + - platform: template + id: single_and + on_press: + - if: + condition: + - lambda: return true; + then: + - logger.log: single and + - platform: template + id: single_or + on_press: + - if: + any: + - lambda: return true; + then: + - logger.log: single or + - platform: template + id: double_and + on_press: + - if: + condition: + - lambda: return true; + - lambda: return false; + then: + - logger.log: double and + - platform: template + id: single_xor + on_press: + - if: + condition: + xor: + - lambda: return true; + then: + - logger.log: single xor diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py new file mode 100644 index 00000000000..effe7b5b0bd --- /dev/null +++ b/tests/component_tests/automation/test_condition_groups.py @@ -0,0 +1,25 @@ +"""Tests for and/or condition group codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_single_condition_groups_are_unwrapped( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A group of one condition is passed to the action directly.""" + main_cpp = generate_main(component_config_path("condition_groups.yaml")) + + assert "IfAction(lambdacondition_id);" in main_cpp + assert "IfAction(lambdacondition_id_2);" in main_cpp + group = re.search( + r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + main_cpp, + ) + assert group is not None + assert f"IfAction({group.group(1)});" in main_cpp + # xor of one condition equals that condition, so it is unwrapped too. + assert "IfAction(lambdacondition_id_5);" in main_cpp + assert "XorCondition<" not in main_cpp From 34eca948c459732aa3153e1e687fe934830242fe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:06:10 -1000 Subject: [PATCH 281/435] Bump bundled esphome-device-builder to 1.23.0 (#20446) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0282011f504..9edbd6e4f9b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.22.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 RUN \ platformio settings set enable_telemetry No \ From 3d248496ab5b13f72c7ab7383b9b27d639af93da Mon Sep 17 00:00:00 2001 From: Ali Alaei <49282631+aalaei@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:50:50 +0200 Subject: [PATCH 282/435] [zephyr_mcumgr] Enable MCUMGR_GRP_OS for UART transport, not just BLE (#18319) Co-authored-by: tomaszduda23 --- esphome/components/zephyr_mcumgr/ota/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index d4f84e30f24..ed7069a5403 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -147,14 +147,15 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_MGMT_NOTIFICATION_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_STATUS_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK", True) + # OS management group (reset, MCUMGR_PARAMETERS query, etc.) isn't transport-specific -- + # every mcumgr transport needs it, not just BLE. + zephyr_add_prj_conf("MCUMGR_GRP_OS", True) + zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) transport = config[CONF_TRANSPORT] if transport[CONF_BLE]: zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT", True) zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT_REASSEMBLY", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) - zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: From f2395f9001134385ba37027b77e235572652cbbc Mon Sep 17 00:00:00 2001 From: Zhaoqi Xu Date: Sat, 10 Oct 2026 09:53:38 +0800 Subject: [PATCH 283/435] [heatpumpir] Fix fan speeds for mitsubishi_msc protocol (#20460) --- esphome/components/heatpumpir/heatpumpir.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 502f83cd5d4..838c8ba54a8 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -187,15 +187,18 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd = HDIR_SWING; } + // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, + // so low/medium/high have to start at FAN_1 there instead of FAN_2. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC; switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: - fan_speed_cmd = FAN_2; + fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; break; case climate::CLIMATE_FAN_MEDIUM: - fan_speed_cmd = FAN_3; + fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3; break; case climate::CLIMATE_FAN_HIGH: - fan_speed_cmd = FAN_4; + fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4; break; case climate::CLIMATE_FAN_AUTO: default: From 3ae52e21cfa9bf5237dc4c1d91ba57bd21b91b4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:38:05 -1000 Subject: [PATCH 284/435] [usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373) --- esphome/components/usb_host/__init__.py | 53 ++++++++++---- tests/unit_tests/components/test_usb_host.py | 75 +++++++++++++++++++- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 94532958ed1..317cec8236b 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,4 +1,5 @@ -from itertools import combinations +import itertools +import logging import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER @@ -22,6 +23,8 @@ import esphome.final_validate as fv from esphome.helpers import cpp_u16string_escape from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["bytebuffer"] CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["esp32"] @@ -71,15 +74,19 @@ def usb_device_schema( ) -def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: +def _clients_overlap(first: ConfigType, second: ConfigType) -> bool: # Two entries overlap when no field they both constrain tells them apart - for first, second in combinations(configs, 2): - for key, wildcard in _FILTER_WILDCARDS.items(): - a = first.get(key) - b = second.get(key) - if wildcard not in (a, b) and a != b: - break - else: + for key, wildcard in _FILTER_WILDCARDS.items(): + a = first.get(key) + b = second.get(key) + if wildcard not in (a, b) and a != b: + return False + return True + + +def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: + for first, second in itertools.combinations(configs, 2): + if _clients_overlap(first, second): raise cv.Invalid( f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}" ) @@ -87,11 +94,29 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: def _final_validate(config: ConfigType) -> ConfigType: - # Every USB client on the bus, whichever component configured it: any two could - # otherwise open the same device - clients = list(config.get(CONF_DEVICES) or ()) - clients.extend(fv.full_config.get().get("usb_uart") or ()) - validate_usb_clients(clients) + # Any two overlapping clients could otherwise open the same device + devices = config.get(CONF_DEVICES) or [] + uarts = fv.full_config.get().get("usb_uart") or [] + validate_usb_clients(devices) + validate_usb_clients(uarts) + # Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call + for device, uart in itertools.product(devices, uarts): + if not _clients_overlap(device, uart): + continue + # The device matches nothing the usb_uart entry does not already claim + redundant = all( + uart.get(key) in (wildcard, device.get(key)) + for key, wildcard in _FILTER_WILDCARDS.items() + ) + _LOGGER.warning( + "usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. " + "This will be an error in 2027.10.0", + device[CONF_ID], + uart[CONF_ID], + "remove it from usb_host devices" + if redundant + else "narrow its filter so it no longer matches that device", + ) return config diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py index 1a1df68f4de..8d572dba968 100644 --- a/tests/unit_tests/components/test_usb_host.py +++ b/tests/unit_tests/components/test_usb_host.py @@ -1,9 +1,12 @@ """Tests for usb_host device matching validation.""" +import logging + import pytest -from esphome.components.usb_host import validate_usb_clients +from esphome.components.usb_host import _final_validate, validate_usb_clients import esphome.config_validation as cv +import esphome.final_validate as fv from esphome.types import ConfigType @@ -109,3 +112,73 @@ def test_every_pair_is_compared_not_just_neighbours() -> None: ] with pytest.raises(cv.Invalid, match="a, c"): validate_usb_clients(configs) + + +def _run_final_validate( + devices: list[ConfigType], uarts: list[ConfigType] +) -> ConfigType: + config = {"id": "usb_host", "devices": devices} + token = fv.full_config.set({"usb_host": config, "usb_uart": uarts}) + try: + return _final_validate(config) + finally: + fv.full_config.reset(token) + + +def test_device_duplicating_usb_uart_warns(caplog: pytest.LogCaptureFixture) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "'device_0'" in caplog.text + assert "'uart_0'" in caplog.text + assert "remove it from usb_host devices" in caplog.text + + +def test_wildcard_device_covering_usb_uart_suggests_narrowing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "narrow its filter" in caplog.text + assert "remove it" not in caplog.text + + +def test_disjoint_device_and_usb_uart_do_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4002}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert caplog.text == "" + + +_DUPLICATE_CLIENTS = [ + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4001}, +] + + +@pytest.mark.parametrize( + ("devices", "uarts"), + [([*_DUPLICATE_CLIENTS], []), ([], [*_DUPLICATE_CLIENTS])], + ids=["devices", "uarts"], +) +def test_overlap_within_one_component_is_rejected( + devices: list[ConfigType], uarts: list[ConfigType] +) -> None: + with pytest.raises(cv.Invalid, match="a, b"): + _run_final_validate(devices, uarts) + + +def test_device_inside_broader_usb_uart_suggests_removing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "remove it from usb_host devices" in caplog.text From 21804a75debf9b96d45890011e4a3f5b708ba217 Mon Sep 17 00:00:00 2001 From: Mathieu Rene Date: Sun, 11 Oct 2026 18:41:14 -0400 Subject: [PATCH 285/435] [openthread] add mesh_local_eid openthread_info sensor (#13924) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../openthread_info_text_sensor.cpp | 1 + .../openthread_info_text_sensor.h | 18 ++++++++++++++++++ .../components/openthread_info/text_sensor.py | 8 ++++++++ .../openthread_info/test.esp32-c6-idf.yaml | 2 ++ 4 files changed, 29 insertions(+) diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index 44eb5468502..68ee263e7c5 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -16,6 +16,7 @@ void Eui64OpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Eui64", this); } void NetworkNameOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Name", this); } void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Key", this); } void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "PAN ID", this); } +void MeshLocalEidOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Mesh Local EID", this); } void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Extended PAN ID", this); } } // namespace esphome::openthread_info diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index ef7c5cc8e9f..1f97c577805 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -134,6 +134,24 @@ class ChannelOpenThreadInfo final : public OpenThreadInstancePollingComponent, p uint8_t last_channel_; }; +class MeshLocalEidOpenThreadInfo final : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance(otInstance *instance) override { + const otIp6Address *addr = otThreadGetMeshLocalEid(instance); + if (otIp6IsAddressEqual(addr, &this->last_addr_)) { + return; + } + this->last_addr_ = *addr; + char buf[OT_IP6_ADDRESS_STRING_SIZE]; + otIp6AddressToString(addr, buf, sizeof(buf)); + this->publish_state(buf); + } + void dump_config() override; + + protected: + otIp6Address last_addr_{}; +}; + class DatasetOpenThreadInfo : public OpenThreadInstancePollingComponent { public: void update_instance(otInstance *instance) override { diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index 4d3e8cd6c37..c7e8a602987 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -14,6 +14,7 @@ from esphome.types import ConfigType CONF_RLOC16 = "rloc16" CONF_EUI64 = "eui64" CONF_EXT_ADDR = "ext_addr" +CONF_MESH_LOCAL_EID = "mesh_local_eid" DEPENDENCIES = ["openthread"] @@ -46,6 +47,9 @@ NetworkKeyOpenThreadInfo = openthread_info_ns.class_( PanIdOpenThreadInfo = openthread_info_ns.class_( "PanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) +MeshLocalEidOpenThreadInfo = openthread_info_ns.class_( + "MeshLocalEidOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) ExtPanIdOpenThreadInfo = openthread_info_ns.class_( "ExtPanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) @@ -80,6 +84,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_PAN_ID): text_sensor.text_sensor_schema( # noqa: F821 PanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_MESH_LOCAL_EID): text_sensor.text_sensor_schema( + MeshLocalEidOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), cv.Optional(CONF_EXT_PAN_ID): text_sensor.text_sensor_schema( ExtPanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), @@ -103,4 +110,5 @@ async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_NETWORK_NAME) await setup_conf(config, CONF_NETWORK_KEY) await setup_conf(config, CONF_PAN_ID) + await setup_conf(config, CONF_MESH_LOCAL_EID) await setup_conf(config, CONF_EXT_PAN_ID) diff --git a/tests/components/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml index 8c55546ce60..a1a91e9c675 100644 --- a/tests/components/openthread_info/test.esp32-c6-idf.yaml +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -26,6 +26,8 @@ text_sensor: name: "Network Key" pan_id: name: "PAN ID" + mesh_local_eid: + name: "Mesh Local EID" ext_pan_id: name: "Extended PAN ID" From c9e49b8e8fbbdf63a7ccbb2a053eebde6e462e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:31:06 +0200 Subject: [PATCH 286/435] [teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/teleinfo/__init__.py | 3 +- .../components/teleinfo/sensor/__init__.py | 144 ++++++++++++++++- tests/component_tests/teleinfo/__init__.py | 0 tests/component_tests/teleinfo/test_sensor.py | 149 ++++++++++++++++++ tests/components/teleinfo/common.yaml | 86 +++++++++- 6 files changed, 373 insertions(+), 11 deletions(-) create mode 100644 tests/component_tests/teleinfo/__init__.py create mode 100644 tests/component_tests/teleinfo/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index b5138c06120..3fe111b6282 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -568,7 +568,7 @@ esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet esphome/components/tcp_uart/* @Bascht74 esphome/components/tee501/* @Stock-M -esphome/components/teleinfo/* @0hax +esphome/components/teleinfo/* @0hax @FredM67 esphome/components/tem3200/* @bakerkj esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/template/datetime/* @rfdarter diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 67aad11d0f1..5a6a01f6695 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -4,7 +4,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -CODEOWNERS = ["@0hax"] +CODEOWNERS = ["@0hax", "@FredM67"] +DOMAIN = "teleinfo" MULTI_CONF = True teleinfo_ns = cg.esphome_ns.namespace("teleinfo") diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b51d4cb7953..6a23836c8ab 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,10 +1,32 @@ import esphome.codegen as cg from esphome.components import sensor +import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, ICON_FLASH, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_MILLIVOLT, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = sensor.sensor_schema( - TeleInfoSensor, - unit_of_measurement=UNIT_WATT_HOURS, - icon=ICON_FLASH, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, -).extend(TELEINFO_LISTENER_SCHEMA) + +def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]: + return { + CONF_UNIT_OF_MEASUREMENT: unit, + CONF_DEVICE_CLASS: device_class, + CONF_STATE_CLASS: state_class, + } + + +_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING) +_REACTIVE_ENERGY = _preset( + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT) +_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT) +_APPARENT_POWER_VA = _preset( + UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_APPARENT_POWER_KVA = _preset( + UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT) + +# Legacy defaults, kept for tags without a known preset +_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH} + +# Units that are valid for each device class, used to decide whether a preset's +# device class can be kept when the user overrides the unit (and vice versa) +_COMPATIBLE_UNITS = { + DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS}, + DEVICE_CLASS_REACTIVE_ENERGY: { + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + }, + DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP}, + DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT}, + DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS}, + DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT}, +} + +# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant. +TIC_TAG_CONFIGS = { + # Standard mode + "EA": _ENERGY, + "ER": _REACTIVE_ENERGY, + "IRMS": _CURRENT, + "URMS": _VOLTAGE, + "UMOY": _VOLTAGE, + "SINST": _APPARENT_POWER_VA, + "SMAX": _APPARENT_POWER_VA, + "CC": _POWER, + "PREF": _APPARENT_POWER_KVA, + "PCOUP": _APPARENT_POWER_KVA, + # Historical mode + "BASE": _ENERGY, + "HCH": _ENERGY, + "EJP": _ENERGY, + "BBRH": _ENERGY, + "IINST": _CURRENT, + "IMAX": _CURRENT, + "ISOUSC": _CURRENT, + "ADPS": _CURRENT, + "ADIR": _CURRENT, + "PAPP": _APPARENT_POWER_VA, + "PMAX": _POWER, +} + + +def apply_tag_config(config: ConfigType) -> ConfigType: + """Apply preset configurations based on the tag name. + + Only keys not set by the user are filled in. For known tags, a preset unit or + device class is dropped if it is incompatible with the one set by the user. + Unknown tags get the legacy defaults, merged key by key as before. + """ + if CONF_TAG_NAME not in config: + return config + + # Runs before cv.string, so the raw value may not be a string yet + tag_name = str(config[CONF_TAG_NAME]) + preset = next( + (p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)), + None, + ) + + config = dict(config) + if preset is None: + for key, value in _LEGACY_DEFAULT.items(): + config.setdefault(key, value) + return config + + unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT]) + device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS]) + compatible = unit in _COMPATIBLE_UNITS.get(device_class, ()) + + for key, value in preset.items(): + if key in config: + continue + if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible: + continue + config[key] = value + return config + + +CONFIG_SCHEMA = cv.All( + apply_tag_config, + sensor.sensor_schema( + TeleInfoSensor, + accuracy_decimals=0, + ).extend(TELEINFO_LISTENER_SCHEMA), +) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) + teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID]) cg.add(teleinfo.register_teleinfo_listener(var)) diff --git a/tests/component_tests/teleinfo/__init__.py b/tests/component_tests/teleinfo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 00000000000..a8f9420deac --- /dev/null +++ b/tests/component_tests/teleinfo/test_sensor.py @@ -0,0 +1,149 @@ +"""Tests for teleinfo sensor tag presets.""" + +import pytest + +from esphome.components.teleinfo import CONF_TAG_NAME +from esphome.components.teleinfo.sensor import apply_tag_config +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, + UNIT_WATT_HOURS, +) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class"), + [ + ("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ( + "ERQ1", + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ), + ("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT), + ( + "SINSTS", + UNIT_VOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ( + "PREF", + UNIT_KILOVOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT), + ("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ], +) +def test_known_tag_preset( + tag: str, unit: str, device_class: str, state_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == state_class + assert CONF_ICON not in config + + +def test_unknown_tag_keeps_legacy_defaults() -> None: + config = apply_tag_config({CONF_TAG_NAME: "MYTAG"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_starting_with_u_is_not_voltage() -> None: + config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"}) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +def test_numeric_tag_name_does_not_crash() -> None: + config = apply_tag_config({CONF_TAG_NAME: 1234}) + assert config[CONF_TAG_NAME] == 1234 + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class"), + [ + ("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER), + ("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT), + ], +) +def test_user_compatible_unit_keeps_preset_device_class( + tag: str, unit: str, device_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + + +def test_user_incompatible_unit_skips_preset_device_class() -> None: + config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + +def test_user_incompatible_device_class_skips_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER} + ) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert CONF_UNIT_OF_MEASUREMENT not in config + + +def test_user_same_device_class_keeps_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS + + +def test_user_state_class_is_kept() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT} + ) + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index dcb2cb14bdf..7f73bca62c2 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -22,11 +22,95 @@ teleinfo: update_interval: 60s sensor: + # Historical mode tags - auto-configured via apply_tag_config - platform: teleinfo name: hchc tag_name: HCHC teleinfo_id: test_teleinfo - unit_of_measurement: Wh + - platform: teleinfo + name: base + tag_name: BASE + teleinfo_id: test_teleinfo + - platform: teleinfo + name: iinst1 + tag_name: IINST1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: papp + tag_name: PAPP + teleinfo_id: test_teleinfo + - platform: teleinfo + name: bbrhcjb + tag_name: BBRHCJB + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ejphn + tag_name: EJPHN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pmax + tag_name: PMAX + teleinfo_id: test_teleinfo + - platform: teleinfo + name: imax1 + tag_name: IMAX1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: isousc + tag_name: ISOUSC + teleinfo_id: test_teleinfo + - platform: teleinfo + name: adps + tag_name: ADPS + teleinfo_id: test_teleinfo + # Standard mode tags - auto-configured via apply_tag_config + - platform: teleinfo + name: east + tag_name: EAST + teleinfo_id: test_teleinfo + - platform: teleinfo + name: erqt + tag_name: ERQT + teleinfo_id: test_teleinfo + - platform: teleinfo + name: irms1 + tag_name: IRMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: urms1 + tag_name: URMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: sinsts + tag_name: SINSTS + teleinfo_id: test_teleinfo + - platform: teleinfo + name: smaxsn + tag_name: SMAXSN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ccasn + tag_name: CCASN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pref + tag_name: PREF + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pcoup + tag_name: PCOUP + teleinfo_id: test_teleinfo + # Explicit override - user-specified unit should not be overwritten + - platform: teleinfo + name: hchp_override + tag_name: HCHP + teleinfo_id: test_teleinfo + unit_of_measurement: kWh + # Unknown tag - no auto-config match, should still compile + - platform: teleinfo + name: custom_tag + tag_name: MYTAG + teleinfo_id: test_teleinfo text_sensor: - platform: teleinfo From 97154f9e5f656285a246dc53d242b0c96570bce4 Mon Sep 17 00:00:00 2001 From: jabla Date: Sat, 10 Oct 2026 19:28:40 +0200 Subject: [PATCH 287/435] [espidf] Clean the build when the IDF target changes (#20473) --- esphome/espidf/toolchain.py | 50 ++++++ tests/unit_tests/test_espidf_toolchain.py | 199 ++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index effb817f353..c09cb73f8cf 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -1,5 +1,6 @@ """ESP-IDF direct build API for ESPHome.""" +import contextlib from dataclasses import dataclass, field import fnmatch import hashlib @@ -125,6 +126,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] = os.environ.copy() # Do not leak PYTHONPATH into child env env_cache[version].pop("PYTHONPATH", None) + # Do not let an inherited IDF_TARGET override the variant + env_cache[version].pop("IDF_TARGET", None) # Use provided IDF framework if available if _esphome_manages_idf(): @@ -228,6 +231,8 @@ FILTER_IDF_LINES: list[str] = [ # click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE. _CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) _CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$") +_SDKCONFIG_TARGET_LINE = re.compile(r'^CONFIG_IDF_TARGET="([^"]*)"$', re.MULTILINE) +_TOOLCHAIN_FILE_TARGET = re.compile(r"toolchain-(?:clang-)?(\w+)\.cmake$") @dataclass(frozen=True, kw_only=True) @@ -311,6 +316,50 @@ def tree_skips_bootloader(build_dir: Path) -> bool: return cache.get(SKIP_BOOTLOADER_DEFINE) == "1" +def _foreign_idf_target(target: str) -> str | None: + """Return another IDF target recorded in the CMake caches or sdkconfig.""" + build_dir = _build_dir() + sdkconfig = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + found = [] + for cache_path in ( + build_dir / "CMakeCache.txt", + build_dir / "bootloader" / "CMakeCache.txt", + ): + with contextlib.suppress(OSError, ValueError): + cache = _parse_cmakecache(cache_path) + found.append(cache.get("IDF_TARGET")) + if m := _TOOLCHAIN_FILE_TARGET.search( + cache.get("CMAKE_TOOLCHAIN_FILE", "") + ): + found.append(m.group(1)) + with contextlib.suppress(OSError, ValueError): + if m := _SDKCONFIG_TARGET_LINE.search(sdkconfig.read_text(encoding="utf-8")): + found.append(m.group(1)) + return next((t for t in found if t and t != target), None) + + +def _clean_foreign_target_tree() -> None: + """Clean a build configured for another IDF target, like idf.py set-target.""" + variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT) + if variant is None: + return + target = variant_to_idf_target(variant) + if (old := _foreign_idf_target(target)) is None: + return + from esphome.writer import clean_build + + _LOGGER.info( + "IDF target changed from %s to %s, cleaning build files...", old, target + ) + clean_build(clear_pio_cache=False) + if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): + return + # Restart sdkconfig from ESPHome's options + internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") + if internal.is_file(): + shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) + + def _skip_bootloader() -> bool: """Whether this tree should not build a bootloader at all; per-run memo.""" cache = _cache() @@ -809,6 +858,7 @@ def run_compile(config, verbose: bool) -> int: 3. Run full build """ jobs = _build_jobs(config) + _clean_foreign_target_tree() if need_reconfigure(): if (rc := _configure_project(verbose)) != 0: return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 854c39a9ce1..202ddd970ce 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import json import os from pathlib import Path +import shutil import subprocess from unittest.mock import MagicMock, call, patch @@ -262,6 +263,14 @@ def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: assert "PYTHONPATH" not in env +def test_get_idf_env_pops_inherited_idf_target(setup_core: Path) -> None: + """An inherited IDF_TARGET does not reach cmake.""" + toolchain._cache().env.clear() + with patch.dict(os.environ, {"IDF_PATH": str(setup_core), "IDF_TARGET": "esp32"}): + env = toolchain._get_idf_env(version="5.5.4") + assert "IDF_TARGET" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. @@ -1573,3 +1582,193 @@ def test_missing_image_hint_names_the_flag(setup_core: Path) -> None: f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n" ) assert "--skip-bootloader" in toolchain.missing_image_hint() + + +def _write_target_tree( + main: str | None = None, + bootloader: str | None = None, + bootloader_toolchain: str | None = None, + sdkconfig: str | None = None, +) -> None: + """Write the target into the CMake caches and sdkconfig.""" + tools = "/idf/tools/cmake" + for rel, target, toolchain_target in ( + ("build", main, main), + ("build/bootloader", bootloader, bootloader_toolchain or bootloader), + ): + if target is None: + continue + cache = CORE.relative_build_path(rel, "CMakeCache.txt") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text( + f"CMAKE_TOOLCHAIN_FILE:FILEPATH={tools}/toolchain-{toolchain_target}.cmake\n" + f"IDF_TARGET:STRING={target}\n" + ) + if sdkconfig is not None: + path = CORE.relative_build_path("sdkconfig.test") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'CONFIG_FOO=y\nCONFIG_IDF_TARGET="{sdkconfig}"\n') + + +@pytest.mark.parametrize( + ("tree", "expected"), + [ + ({}, None), + ({"main": "esp32c6", "bootloader": "esp32c6", "sdkconfig": "esp32c6"}, None), + ({"main": "esp32h2"}, "esp32h2"), + ({"bootloader": "esp32h2"}, "esp32h2"), + # Failed bootloader configure: new IDF_TARGET, old toolchain file + ({"bootloader": "esp32c6", "bootloader_toolchain": "esp32h2"}, "esp32h2"), + ({"main": "esp32c6", "sdkconfig": "esp32h2"}, "esp32h2"), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32c6", + }, + None, + ), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32h2", + }, + "esp32h2", + ), + ], +) +def test_foreign_idf_target( + setup_core: Path, tree: dict[str, str], expected: str | None +) -> None: + """Another target in any cache or sdkconfig counts.""" + _setup_build(setup_core) + _write_target_tree(**tree) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +@pytest.mark.parametrize( + ("cache", "sdkconfig", "expected"), + [ + ("IDF_TARGET:STRING=esp32h2\n", None, "esp32h2"), + (None, "CONFIG_FOO=y\n", None), + ], +) +def test_foreign_idf_target_partial_entries( + setup_core: Path, cache: str | None, sdkconfig: str | None, expected: str | None +) -> None: + """Missing entries are skipped.""" + _setup_build(setup_core) + for rel, content in ( + ("build/CMakeCache.txt", cache), + ("sdkconfig.test", sdkconfig), + ): + if content is not None: + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +def test_foreign_idf_target_ignores_unreadable_files(setup_core: Path) -> None: + """Unreadable files are ignored.""" + _setup_build(setup_core) + for rel in ("build/CMakeCache.txt", "sdkconfig.test"): + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xff\xfe IDF_TARGET:STRING=esp32h2\n") + assert toolchain._foreign_idf_target("esp32c6") is None + + +@pytest.mark.parametrize("snapshot", [True, False]) +def test_clean_foreign_target_tree_cleans_and_resets_sdkconfig( + setup_core: Path, snapshot: bool +) -> None: + """A foreign tree is cleaned and sdkconfig reset from the snapshot.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(bootloader="esp32c6", bootloader_toolchain="esp32h2") + _write_target_tree(sdkconfig="esp32h2") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + if snapshot: + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + + with patch( + "esphome.writer.clean_build", + side_effect=lambda **_: shutil.rmtree(CORE.relative_build_path("build")), + ) as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_called_once_with(clear_pio_cache=False) + sdkconfig = CORE.relative_build_path("sdkconfig.test").read_text() + if snapshot: + assert sdkconfig == "CONFIG_IDF_TARGET_ESP32C6=y\n" + else: + assert 'CONFIG_IDF_TARGET="esp32h2"' in sdkconfig + + +@pytest.mark.parametrize("variant", ["ESP32C6", None]) +def test_clean_foreign_target_tree_keeps_matching_tree( + setup_core: Path, variant: str | None +) -> None: + """A matching tree or unknown variant is left alone.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + _write_target_tree(main="esp32c6", bootloader="esp32c6", sdkconfig="esp32c6") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + before = sdkconfig.read_text() + + with patch("esphome.writer.clean_build") as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_not_called() + assert sdkconfig.read_text() == before + + +@pytest.mark.parametrize("main", ["esp32h2", None]) +def test_clean_foreign_target_tree_skipped_clean_keeps_sdkconfig( + setup_core: Path, monkeypatch: pytest.MonkeyPatch, main: str | None +) -> None: + """ESPHOME_SKIP_CLEAN_BUILD keeps the tree and sdkconfig.""" + _setup_build(setup_core) + monkeypatch.setenv("ESPHOME_SKIP_CLEAN_BUILD", "1") + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main=main, sdkconfig="esp32h2") + CORE.relative_build_path("sdkconfig.test.esphomeinternal").write_text( + "CONFIG_IDF_TARGET_ESP32C6=y\n" + ) + + toolchain._clean_foreign_target_tree() + + assert CORE.relative_build_path("build/CMakeCache.txt").is_file() == bool(main) + assert 'CONFIG_IDF_TARGET="esp32h2"' in ( + CORE.relative_build_path("sdkconfig.test").read_text() + ) + + +def test_run_compile_cleans_foreign_target_tree_first(setup_core: Path) -> None: + """run_compile cleans a foreign tree before the reconfigure check.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main="esp32h2") + calls: list[str] = [] + + with ( + patch( + "esphome.writer.clean_build", + side_effect=lambda **_: calls.append("clean"), + ), + patch.object( + toolchain, + "need_reconfigure", + side_effect=lambda: calls.append("check") or False, + ), + patch.object(toolchain, "_cache_entries_changed", return_value=False), + patch.object(toolchain, "_run_ninja", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + + assert calls == ["clean", "check"] From 654afb536ec12dbb5d75857202b31ae4fff3cafa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 07:39:49 -1000 Subject: [PATCH 288/435] [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_gen/host.py | 2 +- esphome/build_helpers/ninja_gen.py | 21 ++++++++++++------- .../unit_tests/build_gen/test_arduino8266.py | 3 ++- tests/unit_tests/build_gen/test_host.py | 5 ++++- .../build_helpers/test_ninja_gen.py | 18 ++++++++++++++++ 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 44369136a01..b0ba7663927 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), *pch_rule_lines(), "rule link", diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index f1e53e1ccb2..3a8587891b2 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: lines = [ *tool_lines(compilers.cc, compilers.cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index a2ee244a29f..c74262fdeea 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -74,33 +74,40 @@ def tool_lines( # path gets f"python = {_q(strip_win_long_path_prefix(sys.executable))}", f"buildtool = {_q(_BUILD_TOOL)}", - f"ccache = {_q(ccache) if ccache else ''}", + *([f"ccache = {_q(ccache)}"] if ccache else []), "", ] -def compile_rule_lines() -> list[str]: - """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" +def compile_rule_lines(ccache: str | None) -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it. + + ``ccache`` is the launcher ``tool_lines`` was given; the same test here + keeps the variable and the rules in step. It is spliced in rather than + left as an empty variable: a leading space on the command line makes + CreateProcess fail on Windows. + """ + launcher = "$ccache " if ccache else "" return [ "rule c", - " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CC $out", "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CXX $out", "rule aspp", - " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = AS $out", # Plain assembler, as SCons's ASCOM: no preprocessor, so no # depfile and no $flags (defines/includes) either "rule asm", - " command = $ccache $cc -x assembler $asflags -c $in -o $out", + f" command = {launcher}$cc -x assembler $asflags -c $in -o $out", " description = AS $out", ] diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 2ea4ffabe6e..4c60b4e4764 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -888,8 +888,9 @@ def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None _set_flags() content = _write_ninja(paths) assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $cc -x assembler $asflags -c $in -o $out" in content assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( - content + _write_ninja(paths, ccache="/cc/ccache") ) diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py index 9e40698c91c..c3936ee65a8 100644 --- a/tests/unit_tests/build_gen/test_host.py +++ b/tests/unit_tests/build_gen/test_host.py @@ -308,7 +308,10 @@ def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render() - assert "ccache = \n" in ninja + # No launcher variable, and no leading space on the commands either: + # CreateProcess on Windows rejects a command line that starts with one + assert "$ccache" not in ninja + assert " command = $cxx -MMD -MF $out.d" in ninja def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py index e7f9b96af2e..d51110efad8 100644 --- a/tests/unit_tests/build_helpers/test_ninja_gen.py +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -55,3 +55,21 @@ def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: tmp_path / "b.cpp", tmp_path / "sub" / "c.S", ] + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_compile_rule_lines_splice_the_launcher(ccache: str | None) -> None: + """Without ccache no command keeps a leading space, which CreateProcess + on Windows rejects; with it every compile rule starts with the launcher.""" + commands = [ + line.removeprefix(" command = ") + for line in ninja_gen.compile_rule_lines(ccache) + if line.startswith(" command = ") + ] + assert len(commands) == 4 + for command in commands: + if ccache: + assert command.startswith("$ccache $c") + else: + assert command.startswith("$c") + assert "$ccache" not in command From 93c8796ae872dffe71220cf4f3a53e73d2d92cc7 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 10 Oct 2026 19:49:35 +0200 Subject: [PATCH 289/435] [nrf52] fix build error on 3.4.0 with pch ON (#20474) --- esphome/components/nrf52/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 631959530eb..bc535ebfdb2 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -834,7 +834,7 @@ _PCH_CMAKE_LINES = [ "set(esphome_kept_options)", "set(esphome_pch_headers)", "foreach(option IN LISTS esphome_options)", - ' if(option MATCHES "imacros> (.+)$")', + ' if(option MATCHES "imacros> ([^>]+)")', ' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")', " list(APPEND esphome_kept_options", ' "$<$,$,app>>>:${option}>")', From 897b2f16a98856170bf6ebfe13a0db2e59c5d05c Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:15:30 +0200 Subject: [PATCH 290/435] [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215) Co-authored-by: J. Nick Koston --- tests/integration/test_socket_tcp_client_link.py | 6 ++++++ tests/integration/test_tcp_uart_bridge.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/integration/test_socket_tcp_client_link.py b/tests/integration/test_socket_tcp_client_link.py index 16b5f544f79..89a6ff42347 100644 --- a/tests/integration/test_socket_tcp_client_link.py +++ b/tests/integration/test_socket_tcp_client_link.py @@ -35,6 +35,7 @@ async def test_socket_tcp_client_link( link_down = asyncio.Event() second_link_up = asyncio.Event() link_up_count = 0 + writers: list[asyncio.StreamWriter] = [] def on_log_line(line: str) -> None: nonlocal link_up_count @@ -48,6 +49,7 @@ async def test_socket_tcp_client_link( async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: + writers.append(writer) if not echo_done.is_set(): writer.write(PAYLOAD) await writer.drain() @@ -98,5 +100,9 @@ async def test_socket_tcp_client_link( "Second session echo wrong; stale bytes from the first session?" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() diff --git a/tests/integration/test_tcp_uart_bridge.py b/tests/integration/test_tcp_uart_bridge.py index fb22a26b558..70befc90498 100644 --- a/tests/integration/test_tcp_uart_bridge.py +++ b/tests/integration/test_tcp_uart_bridge.py @@ -41,12 +41,14 @@ async def test_tcp_uart_bridge( service_byte_ok = asyncio.Event() reconnect_echo_ok = asyncio.Event() sessions = 0 + writers: list[asyncio.StreamWriter] = [] async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: nonlocal sessions sessions += 1 + writers.append(writer) if sessions == 1: writer.write(GREETING) await writer.drain() @@ -98,5 +100,9 @@ async def test_tcp_uart_bridge( reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() From 4bae89bd4a47fa9d489a8feda2cf6ef32d5c8e83 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Sat, 10 Oct 2026 21:17:01 +0000 Subject: [PATCH 291/435] [nrf52] Fix BLE OTA/log device discovery & transport selection on macOS (#17101) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/nrf52/__init__.py | 8 +- esphome/components/nrf52/ble_logger.py | 23 ++++- esphome/components/nrf52/ota.py | 21 ++--- tests/unit_tests/test_nrf52_ota.py | 121 +++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_ota.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index bc535ebfdb2..f8649f968ed 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -731,13 +731,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool: return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths - from .ble_logger import is_mac_address + from .ble_logger import is_ble_address from .ota import smpmgr_scan, smpmgr_upload if host == "BLE": mcumgr_device = asyncio.run(smpmgr_scan(CORE.name)) - if is_mac_address(host): + if is_ble_address(host): mcumgr_device = host if mcumgr_device: @@ -752,7 +752,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: def show_logs(config: ConfigType, args, devices: list[str]) -> bool: address = devices[0] - from .ble_logger import is_mac_address, logger_connect, logger_scan + from .ble_logger import is_ble_address, logger_connect, logger_scan if devices[0] == "BLE": ble_device = asyncio.run(logger_scan(CORE.name)) @@ -761,7 +761,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: else: return True - if is_mac_address(address): + if is_ble_address(address): asyncio.run(logger_connect(address)) return True return False diff --git a/esphome/components/nrf52/ble_logger.py b/esphome/components/nrf52/ble_logger.py index f74a49ea897..5edcddfd326 100644 --- a/esphome/components/nrf52/ble_logger.py +++ b/esphome/components/nrf52/ble_logger.py @@ -4,6 +4,7 @@ import re from typing import Final from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.backends.scanner import AdvertisementData from bleak.exc import ( BleakCharacteristicNotFoundError, BleakDBusError, @@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" MAC_ADDRESS_PATTERN: Final = re.compile( r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE ) +# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address +UUID_PATTERN: Final = re.compile( + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + flags=re.IGNORECASE, +) -def is_mac_address(value: str) -> bool: - return MAC_ADDRESS_PATTERN.match(value) +def is_ble_address(value: str) -> bool: + return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value)) + + +def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool: + # device.name can be a stale cached name on macOS; adv.local_name is what is on the air now + return name in (device.name, adv.local_name) + + +async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None: + return await BleakScanner.find_device_by_filter( + lambda device, adv: _name_matches(device, adv, name), timeout=timeout + ) async def logger_scan(name: str) -> BLEDevice | None: _LOGGER.info("Scanning bluetooth for %s...", name) - device = await BleakScanner.find_device_by_name(name) + device = await find_device_by_name(name) if not device: _LOGGER.error("%s Bluetooth LE device was not found!", name) return device diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index cafeda64785..9115e218747 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -4,7 +4,6 @@ import json import logging from pathlib import Path -from bleak import BleakScanner from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient @@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport from esphome.core import EsphomeError from esphome.espota2 import ProgressBar -from .ble_logger import is_mac_address +from .ble_logger import find_device_by_name, is_ble_address -SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84" BLE_SCAN_TIMEOUT = 10.0 # seconds RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute @@ -45,12 +43,12 @@ def _json_state(o: object) -> object: async def smpmgr_scan(name: str) -> str: _LOGGER.info("Scanning bluetooth for %s...", name) - for device in await BleakScanner.discover( - timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID] - ): - if device.name == name: - return device.address - raise EsphomeError(f"BLE device {name} with OTA service not found") + # No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on. + # The SMP service is checked on connect. + device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT) + if device is None: + raise EsphomeError(f"BLE device {name} not found") + return device.address async def smpmgr_upload(device: str, firmware: Path) -> None: @@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes: async def _smpmgr_upload(device: str, firmware: Path) -> None: image_tlv_sha256 = _get_image_tlv_sha256(firmware) - if is_mac_address(device): + if is_ble_address(device): smp_client = SMPClient(SMPBLETransport(), device) else: smp_client = SMPClient(SMPSerialTransport(), device) @@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: ) from exc raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: - raise EsphomeError(f"Connection error with {device}") from exc + # Reached when a device with the right name has no SMP service, among other causes + raise EsphomeError(f"Connection error with {device}: {exc}") from exc _LOGGER.info("Connected %s...", device) try: diff --git a/tests/unit_tests/test_nrf52_ota.py b/tests/unit_tests/test_nrf52_ota.py new file mode 100644 index 00000000000..c0b5e202f70 --- /dev/null +++ b/tests/unit_tests/test_nrf52_ota.py @@ -0,0 +1,121 @@ +"""Tests for nRF52 BLE device discovery and OTA transport selection.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from esphome.components.nrf52 import ble_logger, ota, show_logs +from esphome.core import EsphomeError + +MAC = "AA:BB:CC:DD:EE:FF" +# macOS/CoreBluetooth identifies peripherals by UUID instead of MAC address +UUID = "0FA1B2C3-D4E5-F607-1829-3A4B5C6D7E8F" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (MAC, True), + (MAC.lower(), True), + (UUID, True), + ("/dev/ttyACM0", False), + ("COM3", False), + ], +) +def test_is_ble_address(value: str, expected: bool) -> None: + assert ble_logger.is_ble_address(value) is expected + + +def _scan_result( + monkeypatch, devices: list[tuple[str, str | None, str | None]] +) -> AsyncMock: + """Stub the bleak scan so the filter is applied to the given (address, name, local_name) devices.""" + + async def find_device_by_filter(filterfunc, timeout): + for address, name, local_name in devices: + device = SimpleNamespace(address=address, name=name) + if filterfunc(device, SimpleNamespace(local_name=local_name)): + return device + return None + + mock = AsyncMock(side_effect=find_device_by_filter) + monkeypatch.setattr(ble_logger.BleakScanner, "find_device_by_filter", mock) + return mock + + +def test_scan_matches_advertised_local_name(monkeypatch) -> None: + """On macOS device.name is cached from an earlier connection; the live name is adv.local_name.""" + _scan_result(monkeypatch, [(UUID, "stale-old-name", "my-device")]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == UUID + + +def test_scan_matches_device_name(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "my-device", None)]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == MAC + + +def test_scan_raises_when_no_device_found(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "someone-else", "someone-else")]) + with pytest.raises(EsphomeError, match="not found"): + asyncio.run(ota.smpmgr_scan("my-device")) + + +@pytest.mark.parametrize( + ("device", "transport"), + [("/dev/ttyACM0", "serial"), ("COM3", "serial"), (MAC, "ble"), (UUID, "ble")], +) +def test_upload_transport(monkeypatch, device: str, transport: str) -> None: + """Serial ports go to the serial transport; MAC addresses and macOS UUIDs to BLE.""" + captured: dict = {} + + class FakeClient: + def __init__(self, transport, address): + captured["transport"] = transport + captured["address"] = address + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass + + monkeypatch.setattr(ota, "_get_image_tlv_sha256", lambda firmware: b"") + monkeypatch.setattr(ota, "_smpmgr_upload_connected", AsyncMock()) + monkeypatch.setattr(ota, "SMPSerialTransport", lambda: "serial") + monkeypatch.setattr(ota, "SMPBLETransport", lambda: "ble") + monkeypatch.setattr(ota, "SMPClient", FakeClient) + + asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"))) # pylint: disable=protected-access + assert captured == {"transport": transport, "address": device} + + +@pytest.mark.parametrize( + ("device", "connected_to"), + [("BLE", UUID), (MAC, MAC), (UUID, UUID), ("my-device.local", None)], +) +def test_show_logs_routing(monkeypatch, device: str, connected_to: str | None) -> None: + """A scanned device, a MAC or a UUID is handed to the BLE logger; anything else falls through.""" + scan = AsyncMock(return_value=SimpleNamespace(address=UUID)) + connect = AsyncMock(return_value=0) + monkeypatch.setattr(ble_logger, "logger_scan", scan) + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + handled = show_logs(config={}, args=None, devices=[device]) + + assert handled is (connected_to is not None) + if connected_to is None: + connect.assert_not_called() + else: + connect.assert_awaited_once_with(connected_to) + + +def test_show_logs_returns_when_the_scan_finds_nothing(monkeypatch) -> None: + monkeypatch.setattr(ble_logger, "logger_scan", AsyncMock(return_value=None)) + connect = AsyncMock() + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + assert show_logs(config={}, args=None, devices=["BLE"]) is True + connect.assert_not_called() From 375fbd829e432d687bdff94dfbf533c47907196b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:00:39 -1000 Subject: [PATCH 292/435] Bump bundled esphome-device-builder to 1.23.1 (#20497) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9edbd6e4f9b..c9aa3dba62c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 RUN \ platformio settings set enable_telemetry No \ From ec58e864f55da38908fcaf3b06d2ec0802a8342a Mon Sep 17 00:00:00 2001 From: fakuivan Date: Sat, 10 Oct 2026 22:07:36 -0300 Subject: [PATCH 293/435] [canbus] Fix high frame rates causing crashes due to unbounded iteration (#14481) 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/canbus/__init__.py | 20 ++++++++++++++++---- esphome/components/canbus/canbus.cpp | 22 +++++++++++++++------- esphome/components/canbus/canbus.h | 6 ++++-- tests/components/canbus/common.yaml | 1 + 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index b7de235dd18..00435bf18e0 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -19,6 +19,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request" CONF_CANBUS_ID = "canbus_id" CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" +CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" +DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h +DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h def validate_id(config: ConfigType) -> ConfigType: @@ -86,8 +89,13 @@ CANBUS_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(CanbusComponent), cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True), + cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum( + CAN_SPEEDS, upper=True + ), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, + cv.Optional( + CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP + ): cv.positive_not_null_int, cv.Optional(CONF_ON_FRAME): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger), @@ -108,9 +116,13 @@ CANBUS_SCHEMA.add_extra(validate_id) async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - cg.add(var.set_can_id([config[CONF_CAN_ID]])) - cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) - cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]])) + cg.add(var.set_can_id(config[CONF_CAN_ID])) + if config[CONF_USE_EXTENDED_ID]: + cg.add(var.set_use_extended_id(True)) + if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE: + cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate])) + if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP: + cg.add(var.set_max_frames_per_loop(max_frames)) for conf in config.get(CONF_ON_FRAME, []): can_id = conf[CONF_CAN_ID] diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index c5ba59a645a..114230500da 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -67,16 +67,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { void Canbus::loop() { struct CanFrame can_message; - // read all messages until queue is empty - int message_counter = 0; - while (this->read_message(&can_message) == canbus::ERROR_OK) { - message_counter++; + uint32_t message_counter = 0; + for (; message_counter < this->max_frames_per_loop_; message_counter++) { + if (this->read_message(&can_message) != canbus::ERROR_OK) { + break; + } + if (can_message.use_extended_id) { - ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter, + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1, can_message.can_id, can_message.can_data_length_code); } else { - ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id, - can_message.can_data_length_code); + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1, + can_message.can_id, can_message.can_data_length_code); } std::vector data; @@ -100,6 +102,12 @@ void Canbus::loop() { } } } + + if (message_counter == this->max_frames_per_loop_) { + ESP_LOGW(TAG, + "Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped", + this->max_frames_per_loop_); + } } } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 1bc4d6e3455..b5ccd9911a6 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -78,6 +78,7 @@ class Canbus : public Component { void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } + void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } void add_trigger(CanbusTrigger *trigger); /** @@ -96,8 +97,9 @@ class Canbus : public Component { template friend class CanbusSendAction; std::vector triggers_{}; uint32_t can_id_; - bool use_extended_id_; - CanSpeed bit_rate_; + uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py + CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py + bool use_extended_id_{false}; CallbackManager &data)> callback_manager_{}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 3ba3564608a..b1a2442d827 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -5,6 +5,7 @@ canbus: tx_pin: 5 can_id: 4 bit_rate: 50kbps + max_frames_per_loop: 100 on_frame: - can_id: 500 then: From 41a9fca8810e219624de1ce6028189bd9a8501fb Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:04:57 +1100 Subject: [PATCH 294/435] [ci] Reset sdkconfig after ESP-IDF component discovery (#20511) Co-authored-by: Claude Opus 5.5 --- esphome/espidf/toolchain.py | 9 ++++++- tests/unit_tests/test_espidf_toolchain.py | 29 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index c09cb73f8cf..5b32646e04c 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -354,7 +354,11 @@ def _clean_foreign_target_tree() -> None: clean_build(clear_pio_cache=False) if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): return - # Restart sdkconfig from ESPHome's options + _reset_sdkconfig() + + +def _reset_sdkconfig() -> None: + """Restart the project sdkconfig from ESPHome's own options.""" internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") if internal.is_file(): shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) @@ -659,6 +663,9 @@ def _configure_project(verbose: bool = False) -> int: if not discovered: _LOGGER.error("Component discovery found no built-in ESP-IDF components") return 1 + # The discovery configure builds every IDF component, and their Kconfig + # selects (e.g. esp-tls turning TLS on) would otherwise stay in sdkconfig + _reset_sdkconfig() if (rc := _write_project_and_reconfigure(discovered, verbose)) != 0: _LOGGER.error("Reconfigure with discovered components failed") return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 202ddd970ce..9f921066761 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -608,6 +608,35 @@ def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None: ] +def test_run_compile_discovery_resets_sdkconfig(setup_core: Path) -> None: + """Options the discovery configure forced on (e.g. esp-tls selecting TLS) + are dropped before the configure with the real component list.""" + _setup_build(setup_core) + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.parent.mkdir(parents=True) + internal.write_text("CONFIG_MBEDTLS_TLS_ENABLED=n\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + seen: list[str] = [] + + def record_write(minimal: bool = False, builtin_components=None) -> None: + if minimal: + sdkconfig.write_text("CONFIG_MBEDTLS_TLS_ENABLED=y\n") + else: + seen.append(sdkconfig.read_text()) + + with ( + patch.object(toolchain, "load_cached_builtin_components", return_value=None), + patch.object(toolchain, "save_cached_builtin_components"), + patch( + "esphome.build_gen.espidf.get_available_components", return_value=["lwip"] + ), + patch("esphome.build_gen.espidf.write_project", side_effect=record_write), + patch.object(toolchain, "run_reconfigure", return_value=0), + ): + assert toolchain._configure_project() == 0 + assert seen == ["CONFIG_MBEDTLS_TLS_ENABLED=n\n"] + + def test_run_compile_discovery_failure_stops_before_full_write( setup_core: Path, ) -> None: From 58e03a875f054dd3b73ede0b9faaa847abe4a3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:31:14 +0200 Subject: [PATCH 295/435] [teleinfo] Fix out-of-bounds read when splitting a frame into groups (#20513) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 17d3d6c0999..97405c45408 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -116,8 +116,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_index_ - 1))) && - ((buf_finger - buf_) < buf_index_)) { // NOLINT(clang-diagnostic-sign-compare) + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -168,8 +167,8 @@ void TeleInfo::loop() { continue; } - /* Advance buf_finger to end of group */ - buf_finger += field_len + 1 + 1 + 1; + /* Resume after the group's 0xd; grp_end < buf_end, so this stays in bounds. */ + buf_finger = grp_end + 1; publish_value_(std::string(tag_), std::string(val_)); } From 3c7ce71d6f7477b905d8abf58c5144583fab1859 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:47:12 -1000 Subject: [PATCH 296/435] [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) --- tests/integration/test_uart_tcp_bridge.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_uart_tcp_bridge.py b/tests/integration/test_uart_tcp_bridge.py index 33d32f4c07e..d2e1603bd15 100644 --- a/tests/integration/test_uart_tcp_bridge.py +++ b/tests/integration/test_uart_tcp_bridge.py @@ -84,13 +84,16 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 1) + # The accept log precedes the stale discard in the same loop pass, so + # prove the device is past it with a client to UART round trip before + # writing the bytes the client must see. + writer.write(b"down1") + await writer.drain() + assert await read_uart(5) == b"down1" os.write(controller_fd, b"live!") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live!", ( "First bytes to the client were not the live payload" ) - writer.write(b"down1") - await writer.drain() - assert await read_uart(5) == b"down1" # Drop the client; bytes while no client is connected are discarded # when the next one is accepted. @@ -101,13 +104,13 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 2) + writer.write(b"down2") + await writer.drain() + assert await read_uart(5) == b"down2" os.write(controller_fd, b"live2") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live2", ( "Second client received stale bytes from the gap" ) - writer.write(b"down2") - await writer.drain() - assert await read_uart(5) == b"down2" writer.close() # A peer outside the allow list is rejected and closed. From bd2e8d184d4580ceca8fad3e40440dfda7e4ee62 Mon Sep 17 00:00:00 2001 From: sh1der Date: Sun, 11 Oct 2026 22:24:50 +0200 Subject: [PATCH 297/435] [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516) 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/gree/gree.cpp | 2 -- esphome/components/gree/gree.h | 8 ++++++- esphome/components/gree/switch/__init__.py | 25 ++++++++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index e59f186521b..da843a008a1 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -37,7 +37,6 @@ void GreeClimate::transmit_state() { remote_state[1] = this->temperature_(); if (model == GREE_YAN) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } @@ -61,7 +60,6 @@ void GreeClimate::transmit_state() { } if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index c2616295735..a85878e764d 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00; static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9. +static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10; +static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20; +static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40; +static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80; + // Model codes enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; - uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] + uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise }; } // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 9bec3751d6b..b48d32b9001 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -17,12 +17,22 @@ CONF_HEALTH = "health" CONF_XFAN = "xfan" CONF_GREE_ID = "gree_id" -# Switch configurations: (config_key, display_name, bit_mask, icon) +# Switch configurations: (config_key, display_name, mode_bit, icon) SWITCH_CONFIGS = ( - (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), - (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), - (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), - (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), + ( + CONF_TURBO, + "Gree Turbo Switch", + gree_ns.GREE_MODE_BIT_TURBO, + "mdi:car-turbocharger", + ), + (CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"), + ( + CONF_XFAN, + "Gree X-FAN Switch", + gree_ns.GREE_MODE_BIT_XFAN, + "mdi:wall-sconce-flat", + ), ) SUPPORTED_MODELS = { @@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(key): switch.switch_schema( GreeModeBitSwitch, icon=icon, - default_restore_mode="RESTORE_DEFAULT_OFF", + # The display light was on before the switches existed, so it stays on by default + default_restore_mode="RESTORE_DEFAULT_ON" + if key == CONF_LIGHT + else "RESTORE_DEFAULT_OFF", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, ) From 5d75828cebe1205eb245a87bb33ffd369383f57a Mon Sep 17 00:00:00 2001 From: heikkikii <11836264+heikkikii@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:50 +1100 Subject: [PATCH 298/435] [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860) Co-authored-by: J. Nick Koston --- .../components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 15 ++++++++++++--- .../components/seeed_mr24hpc1/seeed_mr24hpc1.h | 1 + .../components/seeed_mr24hpc1/test.esp32-idf.yaml | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 tests/components/seeed_mr24hpc1/test.esp32-idf.yaml diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index a0dc7ae4cb1..0d095b53842 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -9,6 +9,8 @@ namespace esphome::seeed_mr24hpc1 { static const char *const TAG = "seeed_mr24hpc1"; +static constexpr uint32_t STARTUP_DELAY_MS = 1000; + // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. void MR24HPC1Component::dump_config() { @@ -76,8 +78,7 @@ void MR24HPC1Component::setup() { this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); } #endif - this->set_custom_end_mode(); - this->poll_time_base_func_check_ = true; + this->poll_time_base_func_check_ = false; this->check_dev_inf_sign_ = true; this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE; this->sg_data_len_ = 0; @@ -92,7 +93,13 @@ void MR24HPC1Component::setup() { memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE); memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE); - this->set_interval(8000, [this]() { this->update_(); }); + // The module locks up when queried during its own boot, so hold all TX for a second + this->set_timeout(STARTUP_DELAY_MS, [this]() { + this->module_ready_ = true; + this->set_custom_end_mode(); + this->poll_time_base_func_check_ = true; + this->set_interval(8000, [this]() { this->update_(); }); + }); } // Timed polling of radar data @@ -686,6 +693,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { // Sending data frames void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) { + if (!this->module_ready_) + return; this->write_array(query, string_length); } diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b231bab33e5..23cecfc9863 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component, int sg_start_query_data_; bool check_dev_inf_sign_; bool poll_time_base_func_check_; + bool module_ready_{false}; void update_(); void r24_split_data_frame_(uint8_t value); diff --git a/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..3853bf3e8dc --- /dev/null +++ b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr24hpc1: !include common.yaml From 7c0f63ea200f9c7249c0561a2f35c0d9dd6dfd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:22:52 +0200 Subject: [PATCH 299/435] [core] Copy trigger arguments in wait_until and script.wait (#20536) Co-authored-by: Claude Opus 5.5 --- esphome/components/script/script.h | 8 +- esphome/core/automation.h | 8 ++ esphome/core/base_automation.h | 21 ++-- .../core/test_wait_until_action.cpp | 109 ++++++++++++++++++ .../script/test_script_wait_action.cpp | 96 +++++++++++++++ 5 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 tests/components/core/test_wait_until_action.cpp create mode 100644 tests/components/script/test_script_wait_action.cpp diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 97b27af498d..f00ad7f4afa 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -306,7 +306,7 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, std::make_index_sequence{}); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -323,12 +323,8 @@ template class ScriptWaitAction final : public Action void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->play_next_(std::get(tuple)...); - } - C *script_; - std::list> param_queue_; + std::list...>> param_queue_; }; } // namespace esphome::script diff --git a/esphome/core/automation.h b/esphome/core/automation.h index f5921168e02..8820d2a63af 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -8,6 +8,7 @@ #include "esphome/core/string_ref.h" #include #include +#include #include #include @@ -490,6 +491,13 @@ template class TemplatableValue { }; }; +/// How deferring actions store a trigger argument: a copy, since a referenced object may be gone once the trigger +/// returns, unless the type cannot be copied (e.g. an entity, which outlives the wait). A copyable `Base &` would be +/// stored sliced, so entity-like argument types must not be copyable. +template +using stored_trigger_arg_t = + std::conditional_t>, std::remove_cvref_t, T>; + /** Base class for all automation conditions. * * @tparam Ts The template parameters to pass when executing. diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 8762cbe7110..69def222da2 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -500,7 +501,7 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, x...); // Do immediate check with fresh timestamp - don't call loop() synchronously! // Let the event loop call it to avoid reentrancy issues @@ -532,7 +533,13 @@ template class WaitUntilAction : public Action, public Co } protected: - using QueueItem = std::tuple, std::tuple>; + struct QueueItem { + QueueItem(uint32_t start, optional timeout, const Ts &...x) + : start(start), timeout(timeout), args(x...) {} + uint32_t start; + optional timeout; + std::tuple...> args; + }; // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items @@ -546,14 +553,14 @@ template class WaitUntilAction : public Action, public Co std::list pending; while (!queue.empty()) { auto it = queue.begin(); - auto start = std::get(*it); - auto timeout = std::get>(*it); + auto start = it->start; + auto timeout = it->timeout; // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) { pending.splice(pending.end(), queue, it); continue; } @@ -564,7 +571,7 @@ template class WaitUntilAction : public Action, public Co std::list completed; completed.splice(completed.begin(), queue, it); uint8_t generation = this->stop_generation_; - this->play_next_tuple_(std::get>(completed.front())); + std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args); if (generation != this->stop_generation_) { // stop() ran inside the chain - the items still held locally were cancelled pending.clear(); @@ -581,7 +588,7 @@ template class WaitUntilAction : public Action, public Co Condition *condition_; std::list var_queue_{}; - // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_() uint8_t stop_generation_{0}; }; diff --git a/tests/components/core/test_wait_until_action.cpp b/tests/components/core/test_wait_until_action.cpp new file mode 100644 index 00000000000..56cfa10d513 --- /dev/null +++ b/tests/components/core/test_wait_until_action.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include + +#include "esphome/core/base_automation.h" + +namespace esphome::testing { + +class FlagCondition : public Condition { + public: + bool check(const std::string & /*value*/) override { return this->ready; } + bool ready{false}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(WaitUntilActionTest, KeepsCopyOfReferenceArguments) { + FlagCondition condition; + WaitUntilAction wait(&condition); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + condition.ready = true; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class CounterCondition : public Condition { + public: + bool check(Counter & /*counter*/) override { return this->ready; } + bool ready{false}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +// A non-const reference argument used to fail to compile +TEST(WaitUntilActionTest, AcceptsMutableReferenceArguments) { + CounterCondition condition; + WaitUntilAction wait(&condition); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + condition.ready = true; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityCondition : public Condition { + public: + bool check(Entity & /*entity*/) override { return this->ready; } + bool ready{false}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(WaitUntilActionTest, KeepsReferenceToNonCopyableArguments) { + EntityCondition condition; + WaitUntilAction wait(&condition); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + condition.ready = true; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::testing diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp new file mode 100644 index 00000000000..17f553996ea --- /dev/null +++ b/tests/components/script/test_script_wait_action.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class FakeScript { + public: + bool is_running() const { return this->running; } + bool running{true}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(ScriptWaitActionTest, KeepsCopyOfReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +TEST(ScriptWaitActionTest, KeepsCopyOfMutableReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + script.running = false; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + script.running = false; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::script::testing From fb1ef29d54ba7220f8084b304e8b94444ded864d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 11:56:21 -1000 Subject: [PATCH 300/435] [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) --- .../uart/software_serial_rx_decoder.h | 179 +++++++++++ .../uart/uart_component_esp8266.cpp | 77 +++-- .../components/uart/uart_component_esp8266.h | 21 +- .../uart/test_software_serial_rx_decoder.cpp | 285 ++++++++++++++++++ 4 files changed, 534 insertions(+), 28 deletions(-) create mode 100644 esphome/components/uart/software_serial_rx_decoder.h create mode 100644 tests/components/uart/test_software_serial_rx_decoder.cpp diff --git a/esphome/components/uart/software_serial_rx_decoder.h b/esphome/components/uart/software_serial_rx_decoder.h new file mode 100644 index 00000000000..d80dbf1a854 --- /dev/null +++ b/esphome/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0f6d7f6577c..b81c0af85f1 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -15,7 +15,13 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); +// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes. +static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200; +// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a +// CpuFrequencyBoost; the cycle counter does not. +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) uint32_t ESP8266UartComponent::get_config() { @@ -158,6 +164,11 @@ void ESP8266UartComponent::dump_config() { "\n Wake on data RX: ENABLED" #endif ); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX decoder: %s", + this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge") + : LOG_STR_LITERAL("start bit sampler")); + } } this->check_logger_conflict(); } @@ -239,8 +250,10 @@ UARTFlushResult ESP8266UartComponent::flush() { void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size) { + // load_settings() re-enters here + if (this->gpio_rx_pin_ != nullptr) + this->gpio_rx_pin_->detach_interrupt(); this->bit_time_ = F_CPU / baud_rate; - this->rx_buffer_size_ = rx_buffer_size; this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; @@ -254,8 +267,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); rx_pin_ = gpio_rx_pin_->to_isr(); - rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT - gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + uint8_t *buffer = this->rx_.buffer(); + if (this->rx_.buffer_size() != rx_buffer_size) { + delete[] buffer; // NOLINT + buffer = new uint8_t[rx_buffer_size]; // NOLINT + } + this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE, + stop_bits, buffer, rx_buffer_size); + this->rx_.reset(rx_now(), this->rx_pin_.digital_read()); + if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE); + } else { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + } } } // A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles @@ -288,8 +312,7 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { if (arg->stop_bits_ == 2) arg->wait_(&wait, start, bit_time); - arg->rx_buffer_[arg->rx_in_pos_] = rec; - arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; + arg->rx_.push_byte(rec); // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -299,6 +322,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { wake_loop_isrsafe(); #endif } +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) { + const uint32_t now = rx_now(); + const bool level = arg->rx_pin_.digital_read(); + [[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level); +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (wake) + wake_loop_isrsafe(); +#endif +} +void ESP8266SoftwareSerial::rx_finalize_pending_() { + if (!this->rx_.finalize_due(rx_now())) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Tail not elapsed; wake again only once drained + if (this->rx_.available() == 0) + wake_loop_threadsafe(); +#endif + return; + } + InterruptLock lock; + this->rx_.finalize(rx_now()); +} void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { ESP_LOGE(TAG, "UART doesn't have TX pins set!"); @@ -350,28 +394,19 @@ void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - uint8_t data = this->rx_buffer_[this->rx_out_pos_]; - this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_; - return data; + this->rx_sync_(); + return this->rx_.read_byte(); } uint8_t ESP8266SoftwareSerial::peek_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - return this->rx_buffer_[this->rx_out_pos_]; + this->rx_sync_(); + return this->rx_.peek_byte(); } void ESP8266SoftwareSerial::flush() { // Flush is a NO-OP with software serial, all bytes are written immediately. } size_t ESP8266SoftwareSerial::available() { - // Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR. - // When in >= out, data is contiguous: [out..in). - // When in < out, data wraps: [out..buf_size) + [0..in). - size_t in = this->rx_in_pos_; - if (in >= this->rx_out_pos_) - return in - this->rx_out_pos_; - return this->rx_buffer_size_ - this->rx_out_pos_ + in; + this->rx_sync_(); + return this->rx_.available(); } } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 0098e752b2d..3d4a2ba85bc 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -7,6 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" #include "uart_component.h" namespace esphome::uart { @@ -26,24 +27,30 @@ class ESP8266SoftwareSerial { size_t available(); protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(ESP8266SoftwareSerial *arg); void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding uint32_t bit_time_{0}; - uint8_t *rx_buffer_{nullptr}; - size_t rx_buffer_size_; - volatile size_t rx_in_pos_{0}; - size_t rx_out_pos_{0}; - uint8_t stop_bits_; - uint8_t data_bits_; - UARTParityOptions parity_; InternalGPIOPin *gpio_tx_pin_{nullptr}; ISRInternalGPIOPin tx_pin_; InternalGPIOPin *gpio_rx_pin_{nullptr}; ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; }; class ESP8266UartComponent final : public UARTComponent, public Component { diff --git a/tests/components/uart/test_software_serial_rx_decoder.cpp b/tests/components/uart/test_software_serial_rx_decoder.cpp new file mode 100644 index 00000000000..b65e8a659fd --- /dev/null +++ b/tests/components/uart/test_software_serial_rx_decoder.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/uart/software_serial_rx_decoder.h" + +namespace esphome::uart::testing { + +namespace { + +// Firmware time base: micros() << 4 +constexpr uint32_t TICKS_PER_SECOND = 16000000; +constexpr uint32_t TICKS_PER_US = 16; + +// Simulated line: frames become edges at exact baud timing plus jitter, truncated +// to whole microseconds like micros(); poll() emulates the main loop. + +class LineSim { + public: + LineSim(uint32_t baud, uint8_t data_bits, bool parity, bool odd, uint8_t stop_bits, size_t buffer_size = 64) + : data_bits_(data_bits), parity_(parity), odd_(odd), stop_bits_(stop_bits), buffer_(buffer_size) { + this->bit_ = (TICKS_PER_SECOND + baud / 2) / baud; + this->exact_bit_ = static_cast(TICKS_PER_SECOND) / baud; + this->dec_.setup(this->bit_, data_bits, parity, stop_bits, this->buffer_.data(), buffer_size); + this->dec_.reset(this->now_, true); + } + + uint32_t bit_ticks() const { return this->bit_; } + SoftwareSerialRxDecoder &decoder() { return this->dec_; } + const std::vector &received() const { return this->received_; } + + void edge(uint32_t at, bool level) { + this->now_ = at; + this->line_ = level; + this->dec_.on_edge(at, level); + } + + // Main loop pass at cycle `at`. + void poll(uint32_t at) { + this->now_ = at; + if (this->dec_.pending() && this->dec_.finalize_due(at)) + this->dec_.finalize(at); + while (this->dec_.available() > 0) + this->received_.push_back(this->dec_.read_byte()); + } + + // Emit one frame at `start` with per edge `jitter` in ticks; returns the end tick. + uint32_t send( + uint8_t value, uint32_t start, const std::function &jitter = [] { return 0; }) { + std::vector bits; + bits.push_back(false); + int ones = 0; + for (int i = 0; i < this->data_bits_; i++) { + bool b = (value >> i) & 1; + bits.push_back(b); + ones += b; + } + if (this->parity_) + bits.push_back(this->odd_ ? !(ones & 1) : (ones & 1)); + for (int i = 0; i < this->stop_bits_; i++) + bits.push_back(true); + double t = start; + for (bool b : bits) { + if (b != this->line_) { + const auto at = static_cast(static_cast(t) + jitter()); + this->edge(at / TICKS_PER_US * TICKS_PER_US, b); + } + t += this->exact_bit_; + } + return static_cast(t); + } + + protected: + uint8_t data_bits_; + bool parity_; + bool odd_; + uint8_t stop_bits_; + uint32_t bit_{0}; + double exact_bit_{0}; + uint32_t now_{1000}; + bool line_{true}; + std::vector buffer_; + std::vector received_; + SoftwareSerialRxDecoder dec_; +}; + +struct FrameFormat { + uint32_t baud; + uint8_t data_bits; + bool parity; + bool odd; + uint8_t stop_bits; +}; + +// Random bytes, back to back or with idle gaps, with bounded edge jitter. +void run_stream(const FrameFormat &f, double jitter_bits, uint32_t gap_bits, int count, unsigned seed) { + LineSim sim(f.baud, f.data_bits, f.parity, f.odd, f.stop_bits); + std::mt19937 rng(seed); + std::uniform_real_distribution jit(-jitter_bits, jitter_bits); + const uint8_t mask = static_cast((1U << f.data_bits) - 1); + std::vector sent; + uint32_t t = 5000; + for (int n = 0; n < count; n++) { + uint8_t b = rng() & mask; + sent.push_back(b); + t = sim.send(b, t, [&] { return static_cast(jit(rng) * sim.bit_ticks()); }); + if (gap_bits != 0 && n % 3 == 2) { + t += gap_bits * sim.bit_ticks(); + sim.poll(t); + } else if (rng() % 4 == 0) { + sim.poll(t); + } + } + sim.poll(t + 20 * sim.bit_ticks()); + EXPECT_EQ(sim.received(), sent) << "baud " << f.baud << " jitter " << jitter_bits; +} + +} // namespace + +TEST(SoftwareSerialRxDecoder, DecodesBackToBackFramesAtCommonBaudRates) { + for (uint32_t baud : {2400U, 4800U, 9600U, 19200U, 38400U}) { + run_stream({baud, 8, false, false, 1}, 0.0, 0, 300, baud); + } +} + +TEST(SoftwareSerialRxDecoder, ToleratesEdgeJitterUpToAQuarterBit) { + // A quarter bit per edge keeps each run within the half bit rounding budget. + run_stream({9600, 8, false, false, 1}, 0.24, 0, 2000, 1); + run_stream({9600, 8, false, false, 1}, 0.24, 7, 2000, 2); + run_stream({19200, 8, false, false, 1}, 0.24, 4, 2000, 3); // edge decoder limit +} + +TEST(SoftwareSerialRxDecoder, HandlesParityDataBitsAndStopBits) { + run_stream({9600, 8, true, false, 1}, 0.2, 3, 1000, 4); // 8E1 + run_stream({4800, 8, true, true, 2}, 0.2, 2, 1000, 5); // 8O2 + run_stream({19200, 7, false, false, 2}, 0.2, 5, 1000, 6); // 7N2 + run_stream({2400, 5, false, false, 1}, 0.2, 1, 1000, 7); // 5N1 +} + +TEST(SoftwareSerialRxDecoder, AllOnesByteCompletesOnlyByFinalize) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + // 0xFF: start bit, then the line stays high with no closing edge. + sim.edge(t, false); + sim.edge(t + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.poll(t + bit + 8 * bit); + EXPECT_TRUE(sim.received().empty()); + EXPECT_FALSE(sim.decoder().finalize_due(t + bit + 8 * bit)); + sim.poll(t + bit + 10 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xFF); + EXPECT_FALSE(sim.decoder().pending()); +} + +TEST(SoftwareSerialRxDecoder, LastByteOfBurstIsFinalizedThenNextFrameDecodes) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = sim.send(0xA5, 5000); + t = sim.send(0xF0, t); // ends high, needs finalize + EXPECT_TRUE(sim.received().empty()); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 2u); + EXPECT_EQ(sim.received()[0], 0xA5); + EXPECT_EQ(sim.received()[1], 0xF0); + // The stale last edge must not confuse the next start bit. + t = sim.send(0x3C, t + 50 * bit); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 3u); + EXPECT_EQ(sim.received()[2], 0x3C); +} + +TEST(SoftwareSerialRxDecoder, BreakConditionIsDroppedAndResyncs) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + sim.edge(t, false); + t += 25 * bit; // line held low for far longer than a frame + sim.edge(t, true); + t += 3 * bit; + sim.poll(t); + EXPECT_TRUE(sim.received().empty()); + t = sim.send(0xA5, t); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xA5); +} + +TEST(SoftwareSerialRxDecoder, CollapsedEdgeIsIgnoredAndStreamRealignsAtIdle) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + // 0x31: lose the rising edge of bit 4, so the next edge repeats the last level. + uint32_t t = 5000; + sim.edge(t, false); // start + sim.edge(t + 1 * bit, true); // bit0 = 1 + sim.edge(t + 2 * bit, false); // bits1..3 = 0 + sim.decoder().on_edge(t + 7 * bit, false); // should have been bit6 falling edge; level still low + sim.edge(t + 9 * bit, true); // stop + t += 10 * bit; + // Next frame decodes correctly once the line has idled. + t = sim.send(0x5A, t + 12 * bit); + sim.poll(t + 2 * bit); + ASSERT_FALSE(sim.received().empty()); + EXPECT_EQ(sim.received().back(), 0x5A); +} + +TEST(SoftwareSerialRxDecoder, DropsBytesWhenBufferIsFullAndKeepsOldest) { + LineSim sim(9600, 8, false, false, 1, 8); + uint32_t t = 5000; + for (int n = 0; n < 20; n++) + t = sim.send(static_cast(n), t); + sim.poll(t + 20 * sim.bit_ticks()); + ASSERT_EQ(sim.received().size(), 7u); // capacity is size - 1 + for (int n = 0; n < 7; n++) + EXPECT_EQ(sim.received()[n], n); +} + +TEST(SoftwareSerialRxDecoder, SetupAgainDropsStaleStateAndUsesNewBufferAndFraming) { + // Mirrors load_settings(): buffered 8N1 bytes and an open frame, then setup() as 5E2. + LineSim sim(9600, 8, false, false, 1); + uint32_t t = 5000; + for (int n = 0; n < 10; n++) + t = sim.send(static_cast(0x40 + n), t); + sim.edge(t + 8 * sim.bit_ticks(), false); // open a frame, never closed + SoftwareSerialRxDecoder &dec = sim.decoder(); + ASSERT_GE(dec.available(), 9u); + + std::vector small(4, 0xEE); + const uint32_t bit = TICKS_PER_SECOND / 2400; + dec.setup(bit, 5, true, 2, small.data(), small.size()); + EXPECT_EQ(dec.available(), 0u); + EXPECT_FALSE(dec.pending()); + EXPECT_EQ(dec.read_byte(), 0); + + // Capacity of the 4 byte buffer is 3; the spare slot must stay untouched. + auto send_5e2 = [&](uint8_t value, uint32_t start) { + bool line = true; + uint32_t at = start; + auto put = [&](bool b) { + if (b != line) { + dec.on_edge(at, b); + line = b; + } + at += bit; + }; + put(false); + int ones = 0; + for (int i = 0; i < 5; i++) { + bool b = (value >> i) & 1; + ones += b; + put(b); + } + put(ones & 1); + put(true); + put(true); + return at; + }; + uint32_t t2 = 5000; + for (int n = 1; n <= 6; n++) + t2 = send_5e2(static_cast(n), t2); + dec.finalize(t2 + 20 * bit); + ASSERT_EQ(dec.available(), 3u); + EXPECT_EQ(dec.read_byte(), 1); + EXPECT_EQ(dec.read_byte(), 2); + EXPECT_EQ(dec.read_byte(), 3); + EXPECT_EQ(small[3], 0xEE); // capacity slot is never written +} + +TEST(SoftwareSerialRxDecoder, ResetDiscardsPartialFrame) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + sim.edge(5000, false); + sim.edge(5000 + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.decoder().reset(5000 + 2 * bit, true); + EXPECT_FALSE(sim.decoder().pending()); + sim.poll(5000 + 30 * bit); + EXPECT_TRUE(sim.received().empty()); +} + +} // namespace esphome::uart::testing From 2727d356552a77df95019c4db308dfadd7c719a6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:58:31 -0400 Subject: [PATCH 301/435] [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) --- esphome/components/uart/uart.cpp | 4 +- esphome/components/uart/uart_component.h | 3 ++ .../components/uart/uart_component_host.cpp | 2 + .../uart/uart_component_libretiny.cpp | 2 + esphome/components/usb_uart/__init__.py | 8 ++- esphome/components/usb_uart/ch34x.cpp | 13 ++--- esphome/components/usb_uart/cp210x.cpp | 4 +- esphome/components/usb_uart/ft23xx.cpp | 30 +---------- esphome/components/usb_uart/pl2303.cpp | 29 +--------- esphome/components/usb_uart/usb_uart.cpp | 50 +++++++++++++---- esphome/components/usb_uart/usb_uart.h | 53 +++++++++---------- 11 files changed, 91 insertions(+), 107 deletions(-) diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 337fa352c14..44bc99ad726 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -10,8 +10,8 @@ namespace esphome::uart { static const char *const TAG = "uart"; -// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD -PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN"); +// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE +PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN"); void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, uint8_t data_bits) { diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 8f143b79777..2dfbf9c163b 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -18,6 +18,9 @@ enum UARTParityOptions { UART_CONFIG_PARITY_NONE, UART_CONFIG_PARITY_EVEN, UART_CONFIG_PARITY_ODD, + // Only USB serial adapters (usb_uart) support these + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, }; #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 63b5631564d..bf9ab2dfd7b 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -158,6 +158,8 @@ void HostUartComponent::setup() { options.c_cflag |= PARENB; options.c_cflag |= PARODD; break; + default: + break; }; // Set stop bits if (this->stop_bits_ == 2) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 293cb9e786f..e1c1a7143be 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -38,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() { case UART_CONFIG_PARITY_ODD: config |= SERIAL_PARITY_ODD; break; + default: + break; } config |= (this->data_bits_ - 4) << 8; diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a632b4e38d0..d5cb65dfbb3 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent +from esphome.components.uart import ( + CONF_DEBUG_PREFIX, + CONF_FLUSH_TIMEOUT, + UARTComponent, + UARTParityOptions, +) from esphome.components.usb_host import ( get_max_packet_size, register_usb_client, @@ -38,7 +43,6 @@ def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: ) -UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 00c5e0b0694..ed5a034b82f 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo factor = 256 - factor; uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2) value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; - } + // Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space + const LineParity parity = channel->line_parity_(); + if (parity != LineParity::LINE_PARITY_NONE) + value |= 8 | ((static_cast(parity) - 1) << 4); value |= channel->data_bits_ - 5; value <<= 8; value |= 0x8C; diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1d..e920da6c267 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); return true; case 1: { - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; + uint16_t line_control = static_cast(channel->line_stop_bits_()); + line_control |= static_cast(channel->line_parity_()) << 4; line_control |= channel->data_bits_ << 8; ESP_LOGD(TAG, "Line control value 0x%X", line_control); this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd95..e10da78aaf0 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b } case 2: { // set line properties (data bits / parity / stop bits) uint16_t value = channel->data_bits_; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - switch (channel->stop_bits_) { - default: // 1 bit - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } + value |= static_cast(channel->line_parity_()) << 8; + value |= static_cast(channel->line_stop_bits_()) << 11; value |= (0x00 << 14); this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, channel->cdc_dev_.bulk_interface_number + 1); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308d..36107c093c4 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b encode_baud_divisor(line_coding, baud); } - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } + line_coding[4] = static_cast(channel->line_stop_bits_()); - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } + line_coding[5] = static_cast(channel->line_parity_()); // Data bits line_coding[6] = channel->get_data_bits(); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 86dfa6d729f..f61642566cb 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -270,9 +270,10 @@ void USBUartComponent::dump_config() { " Flush Timeout: %" PRIu32 " ms\n" " Debug: %s\n" " Dummy receiver: %s", - channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_], - STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_), - YESNO(channel->dummy_receiver_)); + channel->index_, channel->baud_rate_, channel->data_bits_, + PARITY_NAMES[static_cast(channel->line_parity_())], + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], channel->flush_timeout_ms_, + YESNO(channel->debug_), YESNO(channel->dummy_receiver_)); } } void USBUartComponent::start_input(USBUartChannelBase *channel) { @@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // sends RSTACK. uint32_t baud = channel->baud_rate_; std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits + static_cast(baud & 0xFF), + static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), + static_cast((baud >> 24) & 0xFF), + static_cast(channel->line_stop_bits_()), // bCharFormat + static_cast(channel->line_parity_()), // bParityType + static_cast(channel->data_bits_), // bDataBits }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud, + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], + PARITY_NAMES[static_cast(channel->line_parity_())], channel->data_bits_); this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, line_coding); return true; @@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() { return true; } +LineStopBits USBUartChannelBase::line_stop_bits_() const { + switch (this->stop_bits_) { + case UART_CONFIG_STOP_BITS_1_5: + return LineStopBits::LINE_STOP_BITS_1_5; + case UART_CONFIG_STOP_BITS_2: + return LineStopBits::LINE_STOP_BITS_2; + default: + return LineStopBits::LINE_STOP_BITS_1; + } +} + +LineParity USBUartChannelBase::line_parity_() const { + switch (this->parity_) { + case uart::UART_CONFIG_PARITY_ODD: + return LineParity::LINE_PARITY_ODD; + case uart::UART_CONFIG_PARITY_EVEN: + return LineParity::LINE_PARITY_EVEN; + case uart::UART_CONFIG_PARITY_MARK: + return LineParity::LINE_PARITY_MARK; + case uart::UART_CONFIG_PARITY_SPACE: + return LineParity::LINE_PARITY_SPACE; + default: + return LineParity::LINE_PARITY_NONE; + } +} + void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c6ae0b4eef5..4e52fb60e20 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -71,21 +71,33 @@ enum CH34xChipType : uint8_t { CHIP_UNKNOWN = 0xFF, }; -enum UARTParityOptions { - UART_CONFIG_PARITY_NONE = 0, - UART_CONFIG_PARITY_ODD, - UART_CONFIG_PARITY_EVEN, - UART_CONFIG_PARITY_MARK, - UART_CONFIG_PARITY_SPACE, -}; - +// 1 and 2 are the stop bit count the uart component uses, so a count set through the +// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count. enum UARTStopBitsOptions { - UART_CONFIG_STOP_BITS_1 = 0, - UART_CONFIG_STOP_BITS_1_5, - UART_CONFIG_STOP_BITS_2, + UART_CONFIG_STOP_BITS_1 = 1, + UART_CONFIG_STOP_BITS_2 = 2, + UART_CONFIG_STOP_BITS_1_5 = 3, }; +// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the +// CP210x, FTDI and PL2303 requests, all use these values +enum class LineStopBits : uint8_t { + LINE_STOP_BITS_1 = 0, + LINE_STOP_BITS_1_5 = 1, + LINE_STOP_BITS_2 = 2, +}; + +enum class LineParity : uint8_t { + LINE_PARITY_NONE = 0, + LINE_PARITY_ODD = 1, + LINE_PARITY_EVEN = 2, + LINE_PARITY_MARK = 3, + LINE_PARITY_SPACE = 4, +}; + +// Indexed by LineParity static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +// Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; class RingBuffer { @@ -156,22 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; - // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. - // MARK/SPACE have no uart:: equivalent and report as NONE. - switch (parity) { - case UART_CONFIG_PARITY_EVEN: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); - break; - case UART_CONFIG_PARITY_ODD: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); - break; - default: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); - break; - } - } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } @@ -194,6 +190,8 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented output_queue_; @@ -205,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_started_{true}; From 60402614cdaa5d03cf1b014a90d102141b5b6c0d Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 12 Oct 2026 00:15:23 +0200 Subject: [PATCH 302/435] [nrf52] fix zigbee time component (#20527) Co-authored-by: Claude --- esphome/components/time/__init__.py | 8 +++++++ esphome/components/zigbee/time/__init__.py | 2 ++ .../zigbee/time/zigbee_time_zephyr.cpp | 22 ++++++++++++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index cedb9b3c9b5..47526d99bda 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -26,6 +26,8 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -462,6 +464,12 @@ async def register_time(time_var, config): async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("POSIX_CLOCK", True) + # From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(), + # which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time() + # always returns -1. + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0): + zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True) + zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True) cg.add_define("USE_TIME") cg.add_global(time_ns.using) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 74f81df2e96..0572e591cfc 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -14,6 +14,7 @@ from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, get_slot_index, + zigbee_assign, zigbee_new_attr_list, zigbee_new_cluster_list, zigbee_new_variable, @@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None: # Create the binary attributes structure time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t") + zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE")) attr_list = zigbee_new_attr_list( attr_list_name, "ZB_ZCL_DECLARE_TIME_ATTR_LIST", diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 3f14d0a62d2..4eb20d3da26 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -2,6 +2,7 @@ #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" #include "esphome/core/application.h" +#include namespace esphome::zigbee { @@ -10,12 +11,14 @@ static const char *const TAG = "zigbee.time"; // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; +// Largest ZCL time that still fits the 32-bit epoch. Also rejects ZB_ZCL_TIME_TIME_INVALID_VALUE. +constexpr uint32_t MAX_ZCL_TIME = std::numeric_limits::max() - EPOCH_2000; ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t short_addr, zb_uint8_t endpoint, zb_uint32_t nw_time) { - if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT) { + if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT && nw_time <= MAX_ZCL_TIME) { global_time->set_epoch_time(nw_time + EPOCH_2000); } else if (status != RET_TIMEOUT || !global_time->has_time_) { ESP_LOGE(TAG, "Status: %d, auth_level: %u, short_addr: %d, endpoint: %d, nw_time: %u", status, auth_level, @@ -26,7 +29,6 @@ void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t void ZigbeeTime::setup() { global_time = this; this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); - synchronize_epoch_(EPOCH_2000); this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); } @@ -39,8 +41,12 @@ void ZigbeeTime::dump_config() { } void ZigbeeTime::update() { - time_t time = timestamp_now(); - this->cluster_attributes_->time = time - EPOCH_2000; + const time_t now = this->timestamp_now(); + if (now < EPOCH_2000) { + this->cluster_attributes_->time = ZB_ZCL_TIME_TIME_INVALID_VALUE; + } else { + this->cluster_attributes_->time = static_cast(now - EPOCH_2000); + } } void ZigbeeTime::set_epoch_time(uint32_t epoch) { @@ -63,8 +69,12 @@ void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { if (cluster_id == ZB_ZCL_CLUSTER_ID_TIME) { if (attr_id == ZB_ZCL_ATTR_TIME_TIME_ID) { zb_uint32_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data32; - ESP_LOGI(TAG, "Synchronize time to %u", value); - this->defer([this, value]() { synchronize_epoch_(value + EPOCH_2000); }); + if (value <= MAX_ZCL_TIME) { + ESP_LOGI(TAG, "Synchronize time to %u", value); + this->defer([this, value]() { this->synchronize_epoch_(value + EPOCH_2000); }); + } else { + ESP_LOGW(TAG, "Ignoring invalid time %u", value); + } } else if (attr_id == ZB_ZCL_ATTR_TIME_TIME_STATUS_ID) { zb_uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8; ESP_LOGI(TAG, "Time status %hd", value); From 87187c539118088be5ff588191fcf51ade05d553 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:22:29 +1300 Subject: [PATCH 303/435] [api] Let a wizard input pick multiple entities (#20537) --- esphome/components/api/api.proto | 21 +++- esphome/components/api/api_wizard.cpp | 56 +++++++++-- esphome/components/api/api_wizard.h | 35 ++++++- esphome/components/api/wizard.py | 55 +++++++++-- esphome/core/defines.h | 1 + tests/component_tests/api/test_wizard.py | 118 ++++++++++++++++++++++- tests/components/api/__init__.py | 3 +- tests/components/api/common-wizard.yaml | 20 ++++ tests/components/api/test_wizard.cpp | 73 ++++++++++++++ 9 files changed, 360 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 76b82810a4b..77b5afe818e 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -444,7 +444,7 @@ message DeviceWizardRequest { // {"version": 1, // "pages": [{"title": "...", "description": "...", // "entities": [{"key": 123, "device_id": 456, "description": "..."}], -// "inputs": [{"key": 789, "description": "...", +// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, // "entity_filters": [{"integration": "...", "domain": ["..."], // "device_class": ["..."], "supported_features": ["..."]}]}]}]} // @@ -468,6 +468,14 @@ message DeviceWizardRequest { // an entity matches a filter when it satisfies every field that is set, and an entity // that matches any one of the filters is accepted. // +// An input with "multiple": true lets the user pick several entities. The client sends +// them in one WizardInputSetRequest as a comma separated list with no spaces +// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the +// list without them. "max_length" (only on a multiple input, 255 when left out) is the +// longest list in bytes the device accepts. The version stays 1: a client that does not +// know "multiple" ignores it, shows a single picker and sends one entity id, which a +// multiple input accepts. +// // The device keeps the entity id of an input in RAM only and does not store it across // restarts. The client therefore stores the choice itself and, after every connection // and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest @@ -483,8 +491,13 @@ message DeviceWizardResponse { // Sets the entity id of a wizard input (see WizardInputField.key). Clients send // this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no -// reply. The device ignores a request whose key matches no input or whose entity_id -// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// reply. The device ignores a request whose key matches no input. A single input is +// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple +// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no +// spaces, the device trims spaces around each id and stores the list without them. The +// whole request is ignored when any id is empty or has no '.', or when the list without +// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id +// or list in RAM, // not across restarts, and when the input belongs to a homeassistant platform entity // it subscribes to that entity's state. Clients send this for every input right // after connecting, before SubscribeHomeAssistantStatesRequest. A request that @@ -496,7 +509,7 @@ message WizardInputSetRequest { option (ifdef) = "USE_API_WIZARD_INPUTS"; fixed32 key = 1; - string entity_id = 2 [(max_data_length) = 255]; + string entity_id = 2 [(max_data_length) = 4095]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index 986017a138d..0dd2869caa0 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -14,26 +14,68 @@ namespace esphome::api { static const char *const TAG = "api.wizard"; #ifdef USE_API_WIZARD_INPUTS +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined +// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr. +static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) { + size_t joined = 0; + const char *end = value + length; + while (true) { + const char *comma = static_cast(memchr(value, ',', end - value)); + const char *id_end = comma != nullptr ? comma : end; + while (value < id_end && *value == ' ') + value++; + while (id_end > value && id_end[-1] == ' ') + id_end--; + size_t id_length = id_end - value; + if (id_length == 0 || memchr(value, '.', id_length) == nullptr) + return 0; + if (joined != 0) { + if (out != nullptr) + out[joined] = ','; + joined++; + } + if (out != nullptr) + memcpy(out + joined, value, id_length); + joined += id_length; + if (comma == nullptr) + return joined; + value = comma + 1; + } +} +#endif // USE_API_WIZARD_MULTIPLE_INPUTS + static bool wizard_entity_id_valid(const char *entity_id, size_t length) { return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; } const char *wizard_set_input(const WizardInputSetRequest &msg) { - if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { - ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); - return nullptr; - } + const char *value = msg.entity_id.c_str(); + const size_t length = msg.entity_id.size(); for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { // The table is in flash, which ESP8266 can only read through progmem_memcpy WizardInputEntry entry; progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); if (entry.key != msg.key) continue; - memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); - entry.entity_id[msg.entity_id.size()] = '\0'; +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + if (entry.size != 0) { + // Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged + const size_t joined = wizard_join_entity_ids(value, length, nullptr); + if (joined == 0 || joined >= entry.size) + break; + wizard_join_entity_ids(value, length, entry.entity_id); + entry.entity_id[joined] = '\0'; + return entry.entity_id; + } +#endif + if (!wizard_entity_id_valid(value, length)) + break; + memcpy(entry.entity_id, value, length); + entry.entity_id[length] = '\0'; return entry.entity_id; } - ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid"); return nullptr; } #endif // USE_API_WIZARD_INPUTS diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 7d5a566b550..6bdc1654bcb 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -6,6 +6,9 @@ #include #include +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +#include +#endif #include "esphome/core/hal.h" #include "proto.h" @@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM; /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { uint32_t key; // FNV-1 hash of the ESPHome id of the input - char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity + char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + uint16_t size; // Buffer size of a multiple input, 0 for a single input +#endif }; /// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. @@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg); class WizardInput { public: explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} - /// The Home Assistant entity ID, empty until the wizard sets one. + /// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole + /// comma separated list, which homeassistant.action data can use as it is. StringRef entity_id() const { return StringRef(this->entity_id_); } bool has_entity_id() const { return this->entity_id_[0] != '\0'; } +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + /// The number of entity IDs in the list, 0 until the wizard sets one. + size_t entity_id_count() const { + if (!this->has_entity_id()) + return 0; + size_t count = 1; + for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++) + count++; + return count; + } + /// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is + /// not null terminated, so print it with "%.*s". + template void for_each_entity_id(F &&callback) const { + // The device stores the list without spaces or empty entries + const char *start = this->entity_id_; + while (*start != '\0') { + const char *end = strchr(start, ','); + if (end == nullptr) + end = start + strlen(start); + callback(StringRef(start, end - start)); + start = *end == ',' ? end + 1 : end; + } + } +#endif protected: const char *entity_id_; diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 314b907e4d0..e3f3faabf6f 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -20,6 +20,8 @@ from esphome.const import ( CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL, + CONF_MAX_LENGTH, + CONF_MULTIPLE, CONF_NAME, CONF_PAGES, CONF_PLATFORM, @@ -46,6 +48,10 @@ WizardInput = _API.class_("WizardInput") WIZARD_ENTITY_ID_BUFFER_SIZE = ( 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes ) +WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1 +# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266; +# api.proto max_data_length documents the same value +WIZARD_MULTIPLE_MAX_LENGTH = 4095 # One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, # Noise) and footer (16 bytes, Noise MAC) WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 @@ -101,6 +107,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema( ) +def _validate_multiple(conf: ConfigType) -> ConfigType: + if conf[CONF_MULTIPLE] and CONF_ENTITY in conf: + raise cv.Invalid( + "A linked input follows the state of exactly one Home Assistant entity, so multiple " + "is only for standalone inputs (id)", + path=[CONF_MULTIPLE], + ) + if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]: + raise cv.Invalid( + "max_length only applies to an input with multiple: true", + path=[CONF_MAX_LENGTH], + ) + return conf + + # An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no # entity_id of its own: Home Assistant sets it WIZARD_INPUT_SCHEMA = cv.All( @@ -109,6 +130,10 @@ WIZARD_INPUT_SCHEMA = cv.All( cv.Optional(CONF_ID): cv.declare_id(WizardInput), cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_MULTIPLE, default=False): cv.boolean, + cv.Optional(CONF_MAX_LENGTH): cv.int_range( + min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH + ), cv.Optional(CONF_TARGET): cv.Schema( { cv.Required(CONF_ENTITY): cv.All( @@ -119,6 +144,7 @@ WIZARD_INPUT_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), + _validate_multiple, ) WIZARD_PAGE_SCHEMA = cv.All( @@ -243,6 +269,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]: defines.add("USE_API_WIZARD_LINKED_INPUTS") if any(CONF_ID in conf for conf in inputs): defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + if any(conf.get(CONF_MULTIPLE) for conf in inputs): + defines.add("USE_API_WIZARD_MULTIPLE_INPUTS") return defines @@ -374,6 +402,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} if description := conf.get(CONF_DESCRIPTION): document[CONF_DESCRIPTION] = description + if conf.get(CONF_MULTIPLE): + document["multiple"] = True + if (max_length := conf.get(CONF_MAX_LENGTH)) is not None: + document["max_length"] = max_length if filters := _wizard_input_filters(conf, config): document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] return document @@ -387,7 +419,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi {"version": 1, "pages": [{"title": "...", "description": "...", "entities": [{"key": 123, "device_id": 456, "description": "..."}], - "inputs": [{"key": 789, "description": "...", + "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, "entity_filters": [{"integration": "...", "domain": ["..."], "device_class": ["..."], "supported_features": ["..."]}]}]}]} @@ -399,6 +431,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi the device it belongs to (esphome/core/config.py), and is left out for the main device. - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - multiple means the user may pick several entities. The client sends them in one + WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the + device trims spaces around each id and stores the list without them. The whole request is + ignored when any id is empty or has no '.', or when the list without spaces does not fit + max_length (only on a multiple input, 255 when left out). A single input is ignored when the id + is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know + multiple ignores it, shows a single picker and sends one entity id, which a multiple input + accepts. - entity_filters are the filters of the input, or the default filters of a linked switch, number, text, select or button. """ @@ -444,14 +484,15 @@ async def to_code(wizard: ConfigType) -> None: input_id = _wizard_input_id(conf) # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. buffer = _wizard_buffer_name(input_id) - cg.add_global( - cg.RawStatement( - f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' - ) - ) + size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1 + cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";')) if CONF_ID in conf: cg.new_Pvariable(input_id, cg.RawExpression(buffer)) - entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + key = fnv1_hash(input_id.id) + if conf[CONF_MULTIPLE]: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}")) + else: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}")) if entries: cg.extern_progmem_array( "esphome::api::API_WIZARD_INPUTS", diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 12f61467d65..f3e54660f73 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -275,6 +275,7 @@ #define USE_API_WIZARD #define USE_API_WIZARD_INPUTS #define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_MULTIPLE_INPUTS #define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 276f42d624e..8de3a2018ce 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -490,6 +490,7 @@ WIZARD_DEFINES = { "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", } @@ -550,9 +551,28 @@ WIZARD_DEFINES = { ), pytest.param( "- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]", - WIZARD_DEFINES, + WIZARD_DEFINES - {"USE_API_WIZARD_MULTIPLE_INPUTS"}, id="everything", ), + pytest.param( + "- inputs: [{id: calendars, multiple: true}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", + }, + id="multiple-input", + ), + pytest.param( + "- inputs: [{id: calendars, multiple: false}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="multiple-false", + ), ], ) def test_only_the_defines_the_wizard_needs_are_emitted( @@ -940,3 +960,99 @@ def test_an_entity_with_an_entity_id_that_is_no_input_is_valid( assert "api_wizard_input_ha_plain_sensor" not in main_cpp # Every buffer starts empty assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0] + + +@pytest.mark.parametrize( + "input_conf", + [ + "{id: cals, multiple: true}", + "{id: cals, multiple: true, max_length: 255}", + "{id: cals, multiple: true, max_length: 4095}", + "{id: cals, multiple: false}", + ], +) +def test_multiple_input_is_valid(tmp_path: Path, input_conf: str) -> None: + api = wizard_inputs(f"- {input_conf}") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + pytest.param( + "- {entity: ha_sensor, multiple: true}", + "multiple is only for standalone inputs", + id="multiple-linked", + ), + pytest.param( + "- {id: cals, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-without-multiple", + ), + pytest.param( + "- {id: cals, multiple: false, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-multiple-false", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 254}", + "value must be at least 255", + id="max-length-too-small", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 4096}", + "value must be at most 4095", + id="max-length-too-big", + ), + ], +) +def test_invalid_multiple_inputs_are_rejected( + tmp_path: Path, inputs: str, message: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs))) + + assert any(message in error for error in errors), errors + + +def test_multiple_input_document_and_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + api = wizard_inputs( + """ + - {id: cals, multiple: true, max_length: 1023} + - {id: cals_default, multiple: true} + - {id: single} + """ + ) + main_cpp = generate_main(write_config(tmp_path, ESP32_HEADER, api)) + + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) + inputs = document["pages"][0]["inputs"] + # multiple and max_length are only there when set + assert inputs == [ + {"key": fnv1_hash("cals"), "multiple": True, "max_length": 1023}, + {"key": fnv1_hash("cals_default"), "multiple": True}, + {"key": fnv1_hash("single")}, + ] + # Only a multiple entry names its buffer size + assert 'static char api_wizard_input_cals[1024] = "";' in main_cpp + assert 'static char api_wizard_input_cals_default[256] = "";' in main_cpp + assert 'static char api_wizard_input_single[256] = "";' in main_cpp + assert f"{{{fnv1_hash('cals')}u, api_wizard_input_cals, 1024}}" in main_cpp + assert ( + f"{{{fnv1_hash('cals_default')}u, api_wizard_input_cals_default, 256}}" + in main_cpp + ) + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + + +def test_inputs_without_multiple_keep_the_two_field_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config(tmp_path, ESP32_HEADER, wizard_inputs("- id: single")) + ) + + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + assert "MULTIPLE_INPUTS" not in {d.name for d in CORE.defines} diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index bd84c8d9bb4..79feb398bda 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", ): cg.add_define(define) cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + cg.add_define("API_WIZARD_INPUT_COUNT", 3) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml index 5e1936a042d..d658da1c1f2 100644 --- a/tests/components/api/common-wizard.yaml +++ b/tests/components/api/common-wizard.yaml @@ -41,6 +41,13 @@ api: entity: - domain: weather - id: unset_input + - id: calendars_input + description: Calendars + multiple: true + max_length: 1023 + target: + entity: + - domain: calendar switch: - platform: template @@ -106,3 +113,16 @@ interval: data: type: daily entity_id: !lambda 'return id(weather_input).entity_id().c_str();' + - if: + condition: + api.wizard.input_is_set: calendars_input + then: + - homeassistant.action: + action: calendar.get_events + data: + duration: "24:00:00" + entity_id: !lambda 'return id(calendars_input).entity_id().c_str();' + - lambda: |- + ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count()); + id(calendars_input).for_each_entity_id( + [](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); }); diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 033c8b627e5..54aeb4b4ed6 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -23,8 +23,15 @@ static char *wizard_input_other() { return buffer; } +static constexpr size_t CALENDARS_SIZE = 64; +static char *wizard_input_calendars() { + static char buffer[CALENDARS_SIZE] = ""; + return buffer; +} + static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; static constexpr uint32_t OTHER_KEY = 0x11223344; +static constexpr uint32_t CALENDARS_KEY = 0x55667788; // The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two // bytes as a varint. @@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { {WEATHER_KEY, wizard_input_weather()}, {OTHER_KEY, wizard_input_other()}, + {CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE}, }; using Bytes = std::vector; @@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { EXPECT_EQ(input.entity_id(), "weather.home"); } +TEST(DeviceWizard, MultipleInputStoresTheList) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + // One id is a list of one + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.c"); +} + +TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c"); +} + +TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) { + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars()); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr); + EXPECT_STREQ(wizard_input_calendars(), "calendar.keep"); +} + +TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) { + // Pad the last id so the list fills the buffer + std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x'); + ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1); + EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + + std::string too_long = fits + "x"; + EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + // Spaces do not count towards the limit + std::string spaced = fits; + spaced.replace(spaced.find(','), 1, " , "); + EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); +} + +TEST(DeviceWizard, MultipleInputHelpersWalkTheList) { + strcpy(wizard_input_calendars(), ""); + WizardInput input(wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 0u); + std::vector seen; + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_TRUE(seen.empty()); + + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 1u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a"})); + + seen.clear(); + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 3u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a", "calendar.b", "calendar.c"})); + // The raw list is what homeassistant.action data receives + EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c"); +} + } // namespace esphome::api #endif // USE_HOST From 9e580db434d665e0bf13328f411dd58d64e9c4ef Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:36:06 +0200 Subject: [PATCH 304/435] [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) --- esphome/components/uart/uart_component_esp8266.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index b81c0af85f1..0819a1bfb00 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -257,16 +257,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); gpio_tx_pin_ = tx_pin; gpio_tx_pin_->setup(); - tx_pin_ = gpio_tx_pin_->to_isr(); tx_pin_.digital_write(true); } if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); - rx_pin_ = gpio_rx_pin_->to_isr(); uint8_t *buffer = this->rx_.buffer(); if (this->rx_.buffer_size() != rx_buffer_size) { delete[] buffer; // NOLINT From bd4b32873a6b94c9657df836988ea6c5cb297f54 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:52:33 +1300 Subject: [PATCH 305/435] Bump version to 2026.10.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 455a83a0218..a72069f8449 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.0b2 +PROJECT_NUMBER = 2026.10.0b3 # 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 52712669472..15dcce03ff9 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.0b2" +__version__ = "2026.10.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0df7b0b3662fda1a0f89c124b561512d86f28089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 05:08:38 -1000 Subject: [PATCH 306/435] [core] Move zstd_module from the api wizard to esphome.helpers (#20342) --- esphome/components/api/wizard.py | 16 ++++++---------- esphome/helpers.py | 10 ++++++++++ requirements.txt | 2 +- tests/component_tests/api/test_wizard.py | 23 ++++------------------- tests/unit_tests/test_helpers.py | 17 +++++++++++++++++ 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index e3f3faabf6f..e4656e088ae 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -6,7 +6,6 @@ Home Assistant shows the wizard when the device is added. See api_wizard.h for t from collections.abc import Callable import importlib import json -from types import ModuleType from typing import Any from esphome import automation @@ -29,7 +28,12 @@ from esphome.const import ( ) from esphome.core import CORE, ID import esphome.final_validate as fv -from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash +from esphome.helpers import ( + fnv1_hash, + fnv1_hash_object_id, + fnv1a_32bit_hash, + zstd_module, +) from esphome.types import ConfigType API_DOMAIN = "api" @@ -379,14 +383,6 @@ automation.register_apply_condition( ) -def zstd_module() -> ModuleType: - """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" - try: - return importlib.import_module("compression.zstd") - except ImportError: - return importlib.import_module("backports.zstd") - - def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: """An entity of the device that the page shows, keyed as ListEntitiesResponse keys it.""" _, declaration = _wizard_input_declaration(config, conf[CONF_ID]) diff --git a/esphome/helpers.py b/esphome/helpers.py index 5ec46be67e4..1f9b1407971 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable, MutableMapping from contextlib import suppress +import importlib import ipaddress import logging import os @@ -10,6 +11,7 @@ import platform import re import stat import sys +from types import ModuleType from typing import TYPE_CHECKING, TextIO from esphome.const import __version__ as ESPHOME_VERSION @@ -827,3 +829,11 @@ def docs_url(path: str) -> str: path = path.removeprefix("/") return docs_format.format(path=path) + + +def zstd_module() -> ModuleType: + """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" + try: + return importlib.import_module("compression.zstd") + except ImportError: + return importlib.import_module("backports.zstd") diff --git a/requirements.txt b/requirements.txt index f429d84d524..b7df6ca4fc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # api wizard; compression.zstd is in the standard library from 3.14 +backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 8de3a2018ce..f55a6974e7f 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -14,7 +14,7 @@ from esphome.components.api import wizard from esphome.components.homeassistant.switch import SUPPORTED_DOMAINS as SWITCH_DOMAINS from esphome.config import load_config from esphome.core import CORE -from esphome.helpers import fnv1_hash, fnv1a_32bit_hash +from esphome.helpers import fnv1_hash, fnv1a_32bit_hash, zstd_module from tests.component_tests.helpers import get_define_value ESP32_HEADER = """ @@ -633,7 +633,7 @@ def test_text_select_and_button_default_to_their_domains( tmp_path: Path, generate_main: Callable[[str | Path], str] ) -> None: main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) - document = json.loads(wizard.zstd_module().decompress(blob_in(main_cpp))) + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) filters = { entry["key"]: entry.get("entity_filters") for page in document["pages"] @@ -753,7 +753,7 @@ def test_the_blob_is_the_exact_json_document( ) blob = blob_in(main_cpp) - text = wizard.zstd_module().decompress(blob).decode("utf-8") + text = zstd_module().decompress(blob).decode("utf-8") expected = { "version": 1, "pages": [ @@ -808,7 +808,7 @@ def test_the_blob_is_deterministic_and_the_same_on_every_platform( ) # Compressing the same document again gives the same bytes document = wizard.wizard_document(CORE.config["api"]["wizard"], CORE.config) - again = wizard.zstd_module().compress( + again = zstd_module().compress( json.dumps( document, separators=(",", ":"), sort_keys=True, ensure_ascii=False ).encode("utf-8"), @@ -879,21 +879,6 @@ def test_an_entity_without_a_name_cannot_be_in_the_wizard(tmp_path: Path) -> Non assert any("has no name of its own" in error for error in errors), errors -def test_zstd_falls_back_to_the_backport(monkeypatch: pytest.MonkeyPatch) -> None: - """Before Python 3.14 the standard library has no zstd, so the backport is used.""" - backport = object() - - def import_module(name: str) -> object: - if name == "compression.zstd": - raise ImportError(name) - assert name == "backports.zstd" - return backport - - monkeypatch.setattr(wizard.importlib, "import_module", import_module) - - assert wizard.zstd_module() is backport - - def test_a_wizard_too_big_for_one_message_is_rejected(tmp_path: Path) -> None: # Random text does not compress, so this needs more than the limit even compressed rng = random.Random(1) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 8dae1e87b20..764f160cbc0 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1285,3 +1285,20 @@ def test_get_usable_cpu_count_sources() -> None: mock_os_unknown = types.SimpleNamespace(cpu_count=lambda: None) with patch("esphome.helpers.os", mock_os_unknown): assert helpers.get_usable_cpu_count() == 1 + + +def test_zstd_module_falls_back_to_the_backport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Before Python 3.14 the standard library has no zstd, so the backport is used.""" + backport = object() + + def import_module(name: str) -> object: + if name == "compression.zstd": + raise ImportError(name) + assert name == "backports.zstd" + return backport + + monkeypatch.setattr(helpers.importlib, "import_module", import_module) + + assert helpers.zstd_module() is backport From 427e0c90641f2f919c27d372c31fbcb4403619ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 13:50:50 -1000 Subject: [PATCH 307/435] [const] Move the colour channel config keys to the shared constants (#20544) --- esphome/components/const/__init__.py | 4 ++++ esphome/components/tcs34725/sensor.py | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 51a5e012102..c44a323f65d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -14,9 +14,11 @@ CONF_ALLOWED_IPS = "allowed_ips" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" +CONF_BLUE_CHANNEL = "blue_channel" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" +CONF_CLEAR_CHANNEL = "clear_channel" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -29,6 +31,7 @@ CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" +CONF_GREEN_CHANNEL = "green_channel" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_HOLD_STATE = "hold_state" @@ -50,6 +53,7 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" +CONF_RED_CHANNEL = "red_channel" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index 898aa945438..e19d76a64ae 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, +) import esphome.config_validation as cv from esphome.const import ( CONF_COLOR_TEMPERATURE, @@ -20,11 +26,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_RED_CHANNEL = "red_channel" -CONF_GREEN_CHANNEL = "green_channel" -CONF_BLUE_CHANNEL = "blue_channel" -CONF_CLEAR_CHANNEL = "clear_channel" - tcs34725_ns = cg.esphome_ns.namespace("tcs34725") TCS34725Component = tcs34725_ns.class_( "TCS34725Component", cg.PollingComponent, i2c.I2CDevice From ce19978cfd6665b05a2eeb11b032696f1279ecc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:17:07 -1000 Subject: [PATCH 308/435] [ci] Tell too-big PR authors that review waits until the PR is under the cap (#20543) --- .github/scripts/auto-label-pr/reviews.js | 36 +++++++++++++----- .../auto-label-pr/tests/reviews.test.js | 38 +++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/auto-label-pr/tests/reviews.test.js diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index e9e848da6f2..8b1db61f463 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; message += - `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + - `this PR is on the large side `; + `Hey @${prAuthor}, thanks for the contribution. This PR is on the ` + + `large side `; if (tooManyLabels && tooManyChanges) { message += @@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, } message += `, which makes it harder for maintainers to review.\n\n`; + let condition; + if (tooManyLabels && tooManyChanges) { + condition = + `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` + + `at most ${MAX_LABELS} components/areas`; + } else if (tooManyLabels) { + condition = `touches at most ${MAX_LABELS} components/areas`; + } else { + condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`; + } message += - `Smaller, focused PRs tend to be reviewed much faster since they ` + - `fit into the short gaps between other maintainer work; large ones ` + - `often have to wait for a rare long uninterrupted block of time. ` + - `If you can break this up into smaller pieces that can be reviewed ` + - `independently, it will almost certainly land faster overall.\n\n`; + `**This PR will not be reviewed until it ${condition}.** We know ` + + `that is not what you want to hear after this much work, and we want ` + + `to be upfront so you do not wait for a review that is not coming: ` + + `please split the change. The label is removed automatically once ` + + `the PR ${condition}.\n\n`; message += - `Before putting more time in, it's also worth popping into ` + - `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + - `you scope things and flag anything already in flight.\n\n`; + `There is no exception you can request. Rarely, a maintainer agrees in ` + + `\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` + + `genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` + + `but that is rare enough that you should plan on splitting rather than ` + + `wait for it. Smaller, focused PRs are picked up much faster because ` + + `they fit into the short gaps between other maintainer work. For a new ` + + `component, the first PR brings the hub and a single platform (for ` + + `example \`sensor\`); each further platform is its own PR on top of it.\n\n`; message += `For more details (including how to split the work up), see: ` + `https://developers.esphome.io/contributing/submitting-your-work/` + @@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess) } module.exports = { + generateReviewMessages, handleReviews, handleMaintainerAccessComment }; diff --git a/.github/scripts/auto-label-pr/tests/reviews.test.js b/.github/scripts/auto-label-pr/tests/reviews.test.js new file mode 100644 index 00000000000..2390d2a0735 --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/reviews.test.js @@ -0,0 +1,38 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { generateReviewMessages } = require('../reviews'); + +const MAX_LABELS = 15; +const TOO_BIG_THRESHOLD = 1000; + +function tooBigMessage(labelCount, additions) { + const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }]; + const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD); + assert.equal(messages.length, 1); + return messages[0]; +} + +describe('too-big review message', () => { + it('names the line cap when the PR is too long', () => { + const message = tooBigMessage(3, 1500); + assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./); + assert.match(message, /removed automatically once the PR is at most 1000 lines/); + assert.doesNotMatch(message, /components\/areas\./); + }); + + it('names the area cap when the PR touches too many areas but is short', () => { + const message = tooBigMessage(20, 200); + assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./); + assert.doesNotMatch(message, /until it is at most 1000 lines/); + }); + + it('names both caps when both are exceeded', () => { + const message = tooBigMessage(20, 1500); + assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./); + }); + + it('tells new-component authors to bring one platform first', () => { + assert.match(tooBigMessage(3, 1500), /hub and a single platform/); + assert.match(tooBigMessage(3, 1500), /mega-pr/); + }); +}); From 826a7a252a8eecf22f14f54b05703c197262caf8 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:33:06 -0400 Subject: [PATCH 309/435] [tmp102] Add comparator alert binary sensor (#20540) --- esphome/components/tmp102/__init__.py | 19 +++++ esphome/components/tmp102/binary_sensor.py | 28 +++++++ esphome/components/tmp102/tmp102.cpp | 52 +++++++++--- esphome/components/tmp102/tmp102.h | 16 ++++ esphome/core/defines.h | 1 + .../component_tests/tmp102/config/alert.yaml | 19 +++++ .../tmp102/config/duplicate_alert.yaml | 23 ++++++ tests/component_tests/tmp102/test_config.py | 26 ++++++ tests/components/tmp102/__init__.py | 6 +- tests/components/tmp102/common.yaml | 5 ++ tests/components/tmp102/test_tmp102.cpp | 81 +++++++++++++++++++ 11 files changed, 265 insertions(+), 11 deletions(-) create mode 100644 esphome/components/tmp102/binary_sensor.py create mode 100644 tests/component_tests/tmp102/config/alert.yaml create mode 100644 tests/component_tests/tmp102/config/duplicate_alert.yaml diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 6fd5f481e72..077dd03ccc4 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -4,8 +4,12 @@ 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 +import esphome.final_validate as fv from esphome.types import ConfigType +CONF_TMP102_ID = "tmp102_id" + DOMAIN = "tmp102" tmp102_ns = cg.esphome_ns.namespace("tmp102") @@ -83,3 +87,18 @@ def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: [CONF_TEMPERATURE_LOW], ) return config + + +def validate_unique_child(config: ConfigType, platform: str) -> ConfigType: + entries = fv.full_config.get().get(platform, []) + matches = [ + entry + for entry in entries + if entry.get(CONF_PLATFORM) == DOMAIN + and entry.get(CONF_TMP102_ID) == config[CONF_TMP102_ID] + ] + if len(matches) > 1: + raise cv.Invalid( + f"Only one TMP102 {platform} may reference each temperature sensor" + ) + return config diff --git a/esphome/components/tmp102/binary_sensor.py b/esphome/components/tmp102/binary_sensor.py new file mode 100644 index 00000000000..eb23206b981 --- /dev/null +++ b/esphome/components/tmp102/binary_sensor.py @@ -0,0 +1,28 @@ +"""TMP102 comparator alert entity.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "binary_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_BINARY_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(parent.set_alert_binary_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index a1066b97619..09b29dd7ef4 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -10,8 +10,12 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; -#ifdef USE_TMP102_CONFIGURE +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +#endif +#ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; @@ -20,7 +24,6 @@ static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; -static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; @@ -44,6 +47,9 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -83,13 +89,13 @@ void TMP102Component::update() { } #endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } raw_temperature = i2c::i2ctohs(raw_temperature); @@ -98,6 +104,10 @@ void TMP102Component::update() { ESP_LOGD(TAG, "Got Temperature=%.1f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); }); } @@ -108,7 +118,7 @@ void TMP102Component::update_configured_() { return; if (this->config_ & TMP102_SHUTDOWN_BIT) { if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); @@ -134,7 +144,7 @@ bool TMP102Component::write_configuration_() { bool TMP102Component::check_configuration_() { uint16_t config; if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); @@ -144,7 +154,7 @@ bool TMP102Component::check_configuration_() { uint16_t high, low; if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } matches = high == this->high_limit_ && low == this->low_limit_; @@ -153,7 +163,7 @@ bool TMP102Component::check_configuration_() { return true; ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); } @@ -163,14 +173,38 @@ bool TMP102Component::check_configuration_() { void TMP102Component::read_temperature_() { uint16_t raw; if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } const float temperature = decode_temperature(raw, raw & 0x0001); ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); } #endif +#ifdef USE_TMP102_BINARY_SENSOR +void TMP102Component::invalidate_alert_() { + if (this->alert_binary_sensor_ != nullptr) + this->alert_binary_sensor_->invalidate_state(); +} + +bool TMP102Component::read_alert_state_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + ESP_LOGW(TAG, "Failed to read alert state"); + return false; + } + // AL equals POL while the comparator status is active. + const bool alert = bool(config & TMP102_ALERT_BIT) == bool(config & TMP102_ALERT_POLARITY_BIT); + this->alert_binary_sensor_->publish_state(alert); + return true; +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index b14b6c4cc1f..caf75e19be7 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -2,6 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#ifdef USE_TMP102_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" @@ -11,6 +14,9 @@ namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_BINARY_SENSOR + SUB_BINARY_SENSOR(alert) +#endif #ifdef USE_TMP102_CONFIGURE void setup() override; void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { @@ -24,6 +30,16 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: + void set_warning_and_invalidate_alert_() { + this->status_set_warning(); +#ifdef USE_TMP102_BINARY_SENSOR + this->invalidate_alert_(); +#endif + } +#ifdef USE_TMP102_BINARY_SENSOR + bool read_alert_state_(); + void invalidate_alert_(); +#endif #ifdef USE_TMP102_CONFIGURE bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d219296ca57..28abdb2218c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -244,6 +244,7 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER diff --git a/tests/component_tests/tmp102/config/alert.yaml b/tests/component_tests/tmp102/config/alert.yaml new file mode 100644 index 00000000000..3e1e42197d3 --- /dev/null +++ b/tests/component_tests/tmp102/config/alert.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + name: Alert diff --git a/tests/component_tests/tmp102/config/duplicate_alert.yaml b/tests/component_tests/tmp102/config/duplicate_alert.yaml new file mode 100644 index 00000000000..c56d0d96fa4 --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_alert.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + tmp102_id: chip + name: Alert One + - platform: tmp102 + tmp102_id: chip + name: Alert Two diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 020303d455d..49f2629baf8 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -7,6 +7,7 @@ import pytest from esphome.components import tmp102 as component from esphome.components.tmp102.sensor import CONFIG_SCHEMA +from esphome.config import read_config import esphome.config_validation as cv from esphome.core import CORE @@ -32,6 +33,31 @@ def test_advanced_options_enable_configuration_once( assert "USE_TMP102_CONFIGURE" in defines +def test_alert_entity_is_read_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("alert.yaml")) + assert "set_alert_binary_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_BINARY_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_alert_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_alert.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "Only one TMP102 binary_sensor may reference each temperature sensor" + in captured.out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 9702134e8c6..f86177c0f92 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,8 +6,10 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - CORE.testing_ensure_platform_registered("sensor") + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") + cg.add_define("USE_TMP102_BINARY_SENSOR") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] + manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index c764eae42b2..1e5f1f456ce 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -15,3 +15,8 @@ sensor: fault_queue: 6 temperature_high: 140°C temperature_low: 120°C + +binary_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Alert diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index b148b07716c..cd06d8a83d7 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -47,6 +47,7 @@ class TMP102Test : public ::testing::Test { protected: RegisterBus bus_; TMP102Component chip_; + binary_sensor::BinarySensor alert_; unsigned publications_{0}; void SetUp() override { @@ -112,6 +113,83 @@ TEST_F(TMP102Test, WritableConfigurationFields) { EXPECT_EQ(this->bus_.registers[2], 0x2580); } +TEST_F(TMP102Test, AlertEntityPreservesHardwareAndUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + for (uint16_t config : {0x60A0, 0x6080, 0x64A0, 0x6480}) { + this->bus_.registers[1] = config; + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x20) == bool(config & 0x400)); + EXPECT_EQ(this->bus_.registers[1], config); + } + EXPECT_EQ(this->bus_.writes, 0); +} + +TEST_F(TMP102Test, FailedTemperatureOrAlertReadInvalidatesAlert) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + for (int reg : {0, 1}) { + this->bus_.fail_read = reg; + this->chip_.update(); + run_timers(55); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); + this->bus_.fail_read = -1; + this->chip_.update(); + run_timers(55); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_TRUE(this->alert_.has_state()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + for (uint16_t config : {0x0080, 0x00A0, 0x0480, 0x04A0}) { + this->configure(config & ~0x0020); + this->chip_.setup(); + this->bus_.registers[1] = config; + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x0020) == bool(config & 0x0400)); + EXPECT_FALSE(this->chip_.status_has_warning()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertInvalidatesDuringRecoveryAndRepublishes) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + EXPECT_TRUE(this->chip_.status_has_warning()); + + this->chip_.update(); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + run_timers(45); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_TRUE(this->alert_.state); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -170,13 +248,16 @@ TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { } TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->chip_.set_alert_binary_sensor(&this->alert_); this->configure(0x0180); this->chip_.setup(); + this->alert_.publish_state(false); this->bus_.fail_write = 1; this->chip_.update(); run_timers(45); EXPECT_EQ(this->publications_, 0); EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); } TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) { From 22c17fe7e1552f3fe17e7e89d582a9685294d908 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:36:27 +0200 Subject: [PATCH 310/435] [remote_base] Add optional suffix bits to KeeLoq after the HCS301 word (#19891) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/remote_base/__init__.py | 39 +++- .../remote_base/keeloq_protocol.cpp | 63 ++++++- .../components/remote_base/keeloq_protocol.h | 9 +- .../config/keeloq_suffix.yaml | 33 ++++ .../remote_transmitter/test_keeloq_action.py | 18 ++ tests/components/remote_base/__init__.py | 2 +- tests/components/remote_base/keeloq_test.cpp | 177 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 16 ++ .../components/remote_base/__init__.py | 1 + .../components/remote_base/test_keeloq.py | 45 +++++ 11 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml create mode 100644 tests/component_tests/remote_transmitter/test_keeloq_action.py create mode 100644 tests/components/remote_base/keeloq_test.cpp create mode 100644 tests/unit_tests/components/remote_base/__init__.py create mode 100644 tests/unit_tests/components/remote_base/test_keeloq.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a880169fb20..1911218b1f7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -57,6 +57,8 @@ DOMAIN = "remote_base" CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" +CONF_SUFFIX = "suffix" +CONF_SUFFIX_BITS = "suffix_bits" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -255,8 +257,12 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) -def register_action(name, type_, schema): +def register_action( + name, type_, schema, *, validate: Callable[[ConfigType], ConfigType] | None = None +): validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) + if validate is not None: + validator = cv.All(validator, validate) registerer = automation.register_action( f"remote_transmitter.transmit_{name}", type_, @@ -939,6 +945,27 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: + # A lambda is only known on the device. A plain value has to fit the width. + suffix = config[CONF_SUFFIX] + bits = config[CONF_SUFFIX_BITS] + if isinstance(suffix, int) and isinstance(bits, int) and suffix >> bits: + raise cv.Invalid( + f"suffix 0x{suffix:X} does not fit in {bits} suffix_bits", [CONF_SUFFIX] + ) + return config + + +KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( + { + cv.Optional(CONF_SUFFIX, default=0): cv.All( + cv.hex_int, cv.Range(min=0, max=0xFFFF) + ), + cv.Optional(CONF_SUFFIX_BITS, default=0): cv.int_range(min=0, max=16), + } +) + + @register_binary_sensor("keeloq", KeeloqBinarySensor, KEELOQ_SCHEMA) def Keeloq_binary_sensor(var, config): cg.add( @@ -962,7 +989,9 @@ def keeloq_dumper(var, config): pass -@register_action("keeloq", KeeloqAction, KEELOQ_SCHEMA) +@register_action( + "keeloq", KeeloqAction, KEELOQ_ACTION_SCHEMA, validate=_keeloq_suffix_fits +) async def keeloq_action(var, config, args): template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint32) cg.add(var.set_address(template_)) @@ -972,6 +1001,12 @@ async def keeloq_action(var, config, args): cg.add(var.set_command(template_)) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) cg.add(var.set_vlow(template_)) + # Without suffix bits nothing of the suffix is sent, so both stay at their C++ default. + if (suffix_bits := config[CONF_SUFFIX_BITS]) != 0: + template_ = await cg.templatable(config[CONF_SUFFIX], args, cg.uint16) + cg.add(var.set_suffix(template_)) + template_ = await cg.templatable(suffix_bits, args, cg.uint8) + cg.add(var.set_suffix_bits(template_)) # NEC diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index caa950218ec..fff8eb897f0 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -19,24 +19,29 @@ static constexpr uint8_t NBITS_SYNC_CNT = 16; static constexpr uint8_t NBITS_FIXED_DATA = NBITS_REPEAT + NBITS_VLOW + NBITS_BUTTONS + NBITS_SERIAL; static constexpr uint8_t NBITS_ENCRYPTED_DATA = NBITS_BUTTONS + NBITS_DISC + NBITS_SYNC_CNT; static constexpr uint8_t NBITS_DATA = NBITS_FIXED_DATA + NBITS_ENCRYPTED_DATA; +static constexpr uint8_t NBITS_SUFFIX_MAX = 16; +// A space this long is not part of the PWM: data spaces are at most 2 bit times, the guard is 39. +static constexpr uint32_t GAP_TIME_US = 4 * BIT_TIME_US; /* KeeLoq Protocol Coded using information from datasheet for Microchip HCS301 KeeLow Code Hopping Encoder -Encoder - Hopping code is generated at random. +Encoder - Hopping code is not generated. Send the encrypted field from the caller. +Optional suffix bits may follow the 66-bit HCS301 word (same PWM coding). Decoder - Hopping code is ignored and not checked when received. Serial number of -transmitter and nutton command is decoded. +transmitter and button command is decoded. Extra bits after the word are returned +as suffix / suffix_bits when present. */ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { uint32_t out_data = 0x0; - ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32, data.address, data.command, - data.encrypted); + ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32 " suffix_bits=%u", data.address, + data.command, data.encrypted, data.suffix_bits); ESP_LOGV(TAG, "Send Keeloq: data bits (%d + %d)", NBITS_ENCRYPTED_DATA, NBITS_FIXED_DATA); // Preamble = '01' x 12 @@ -92,6 +97,17 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->mark(1 * BIT_TIME_US); dst->space(2 * BIT_TIME_US); + const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; + for (uint8_t i = 0; i < extra; i++) { + if (data.suffix & (1 << i)) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } + } + // Guard time at end of packet dst->space(39 * BIT_TIME_US); } @@ -103,10 +119,11 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { .command = 0, .repeat = false, .vlow = false, - + .suffix = 0, + .suffix_bits = 0, }; - if (src.size() != (NBITS_PREAMBLE + NBITS_DATA) * 2) { + if (src.size() < (NBITS_PREAMBLE + NBITS_DATA) * 2) { return {}; } @@ -182,11 +199,43 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { return {}; } + if (out.repeat) { + src.expect_space(2 * BIT_TIME_US); + } else { + src.expect_space(BIT_TIME_US); + } + + while (out.suffix_bits < NBITS_SUFFIX_MAX) { + bool one = false; + if (src.expect_mark(2 * BIT_TIME_US)) { + one = false; + } else if (src.expect_mark(BIT_TIME_US)) { + one = true; + } else { + break; + } + // The last bit's space is merged into the guard gap, or the capture ends at idle. + if (!src.expect_space(one ? 2 * BIT_TIME_US : BIT_TIME_US) && src.is_valid() && + !src.peek_space_at_least(GAP_TIME_US)) { + break; + } + if (one) { + out.suffix |= 1 << out.suffix_bits; + } + out.suffix_bits++; + } + // The suffix ends at a gap or at the end of the capture. PWM that goes on is not a suffix. + if (src.is_valid() && !src.peek_space_at_least(GAP_TIME_US)) { + out.suffix = 0; + out.suffix_bits = 0; + } + return out; } void KeeloqProtocol::dump(const KeeloqData &data) { - ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x", data.address, data.command); + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", + data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 335fbd164b1..093f2904aa2 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -11,10 +11,11 @@ struct KeeloqData { uint8_t command; // Button Status S2-S1-S0-S3 bool repeat; // Repeated command bit bool vlow; // Battery status bit + uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word + uint8_t suffix_bits{0}; bool operator==(const KeeloqData &rhs) const { - // Treat 0x10 as a special, wildcard button press - // This allows us to match on just the address if wanted. + // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { return false; } @@ -37,6 +38,8 @@ template class KeeloqAction : public RemoteTransmitterActionBase TEMPLATABLE_VALUE(uint32_t, encrypted) TEMPLATABLE_VALUE(uint8_t, command) TEMPLATABLE_VALUE(bool, vlow) + TEMPLATABLE_VALUE(uint16_t, suffix) + TEMPLATABLE_VALUE(uint8_t, suffix_bits) void encode(RemoteTransmitData *dst, Ts... x) override { KeeloqData data{}; @@ -44,6 +47,8 @@ template class KeeloqAction : public RemoteTransmitterActionBase data.encrypted = this->encrypted_.value(x...); data.command = this->command_.value(x...); data.vlow = this->vlow_.value(x...); + data.suffix = this->suffix_.value(x...); + data.suffix_bits = this->suffix_bits_.value(x...); KeeloqProtocol().encode(dst, data); } }; diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml new file mode 100644 index 00000000000..f14bc75f305 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 100% + +button: + - platform: template + name: Plain word + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + code: 0xd19ef0a9 + - platform: template + name: Constant suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: 0x2A + suffix_bits: 6 + - platform: template + name: Lambda suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: !lambda return 0x2A; + suffix_bits: !lambda return 6; diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py new file mode 100644 index 00000000000..aafab1f2c8e --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -0,0 +1,18 @@ +"""transmit_keeloq sets the suffix only when suffix bits are sent.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_keeloq_suffix_is_set_only_with_suffix_bits( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) + + # The plain word gets the same setters as before the suffix existed. + assert "remote_base_keeloqaction_id->set_suffix" not in main_cpp + # A constant and a lambda width both set the suffix. + for action in ("remote_base_keeloqaction_id_2", "remote_base_keeloqaction_id_3"): + assert f"{action}->set_suffix([]() -> uint16_t" in main_cpp + assert f"{action}->set_suffix_bits([]() -> uint8_t" in main_cpp diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index c52eb41ca44..90734b72f28 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - for protocol in ("hob2hood", "haier", "mirage", "aeha"): + for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq"): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp new file mode 100644 index 00000000000..e96d8b9d01d --- /dev/null +++ b/tests/components/remote_base/keeloq_test.cpp @@ -0,0 +1,177 @@ +#include +#include "esphome/components/remote_base/keeloq_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t BIT_US = 380; + +KeeloqData frame(uint16_t suffix, uint8_t suffix_bits) { + KeeloqData data{}; + data.encrypted = 0xd19ef0a9; + data.address = 0x116ea01; + data.command = 0x08; + data.suffix = suffix; + data.suffix_bits = suffix_bits; + return data; +} + +RawTimings encode(const KeeloqData &data) { + RemoteTransmitData tx; + KeeloqProtocol().encode(&tx, data); + return tx.get_data(); +} + +// A receiver starts on the first mark and merges the guard into the previous space. +RawTimings as_received(RawTimings timings, bool drop_tail = false) { + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + RawTimings merged; + for (int32_t item : timings) { + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + if (drop_tail && !merged.empty() && merged.back() < 0) { + merged.pop_back(); + } + return merged; +} + +optional decode(const RawTimings &timings) { + return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(uint16_t suffix, uint8_t suffix_bits, bool drop_tail = false) { + auto decoded = decode(as_received(encode(frame(suffix, suffix_bits)), drop_tail)); + ASSERT_TRUE(decoded.has_value()) << "suffix 0x" << std::hex << suffix << "/" << std::dec << int(suffix_bits); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->command, 0x08); + EXPECT_EQ(decoded->encrypted, 0xd19ef0a9u); + EXPECT_TRUE(decoded->repeat); + EXPECT_FALSE(decoded->vlow); + EXPECT_EQ(decoded->suffix, suffix); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); +} + +// 0xA5 sends 1, 0, 1 first. Replace the space of one of those bits while the other bits still follow. +optional decode_with_suffix_space(uint8_t bit, uint32_t space_us) { + RawTimings timings = as_received(encode(frame(0xA5, 8))); + timings[timings.size() - 2 * 8 + 2 * bit + 1] = -static_cast(space_us); + return decode(timings); +} + +} // namespace + +TEST(KeeloqProtocolTest, PlainWordHasNoSuffix) { expect_round_trip(0, 0); } + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheGuardSwallowsTheLastSpace) { + expect_round_trip(0x00, 8); + expect_round_trip(0x80, 8); + expect_round_trip(0xA5, 8); + expect_round_trip(0x0000, 16); + expect_round_trip(0x8000, 16); + expect_round_trip(0, 1); + expect_round_trip(1, 1); +} + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheCaptureEndsAtIdle) { + expect_round_trip(0x00, 8, true); + expect_round_trip(0x80, 8, true); + expect_round_trip(0x8000, 16, true); + expect_round_trip(1, 1, true); +} + +TEST(KeeloqProtocolTest, ExtraBitsThatEndAtIdleStayASuffix) { + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 4); + } +} + +TEST(KeeloqProtocolTest, FollowingPwmIsNotASuffix) { + // Drop only the guard item. The repeat bit keeps its own space, then more PWM follows. + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_FALSE(timings.empty()); + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + timings.push_back(static_cast(3 * BIT_US)); + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 0); + } +} + +TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { + // A 1 bit with the space of a 0 bit, and a 0 bit with the space of a 1 bit. + for (const auto &[bit, space_us] : {std::pair{2, BIT_US}, {1, 2 * BIT_US}}) { + auto decoded = decode_with_suffix_space(bit, space_us); + ASSERT_TRUE(decoded.has_value()) << "bit " << int(bit); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->suffix, 0) << "bit " << int(bit); + EXPECT_EQ(decoded->suffix_bits, 0) << "bit " << int(bit); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { + // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + for (uint8_t suffix_bits : {0, 8}) { + RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); + const size_t repeat = timings.size() - 2 * suffix_bits - 2; + timings[repeat] = static_cast(2 * BIT_US); + if (suffix_bits > 0) { + timings[repeat + 1] = -static_cast(BIT_US); + } + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << int(suffix_bits); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + EXPECT_EQ(decoded->suffix, suffix_bits > 0 ? 0xA5 : 0); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { + // What follows the gap is the next frame. + auto decoded = decode_with_suffix_space(2, 4 * BIT_US); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0x5); + EXPECT_EQ(decoded->suffix_bits, 3); + } +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 4dadec6a015..55f489505cb 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command"] + format: "on_keeloq: %lu %lu %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 776a03e6809..f168a825d36 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -316,3 +316,19 @@ button: on_press: - remote_transmitter.transmit_hob2hood: command: light_on + - platform: template + name: KeeLoq HCS301 + on_press: + remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + command: 0x02 + code: 0xd19ef0a9 + - platform: template + name: KeeLoq with suffix + on_press: + remote_transmitter.transmit_keeloq: + address: 0x116ea01 + command: 0x08 + code: 0xd19ef0a9 + suffix: 0x00 + suffix_bits: 8 diff --git a/tests/unit_tests/components/remote_base/__init__.py b/tests/unit_tests/components/remote_base/__init__.py new file mode 100644 index 00000000000..d6233f0230e --- /dev/null +++ b/tests/unit_tests/components/remote_base/__init__.py @@ -0,0 +1 @@ +"""Unit tests for remote_base config helpers.""" diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py new file mode 100644 index 00000000000..bd0f817dd1f --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -0,0 +1,45 @@ +"""KeeLoq suffix is a transmit key and has to fit in its width.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.remote_base import ( + CONF_SUFFIX, + CONF_SUFFIX_BITS, + KEELOQ_SCHEMA, + _keeloq_suffix_fits, +) +import esphome.config_validation as cv +from esphome.core import Lambda + + +def test_binary_sensor_rejects_suffix() -> None: + with pytest.raises(cv.Invalid): + KEELOQ_SCHEMA( + {"address": 0x1, "code": 0x2, CONF_SUFFIX: 1, CONF_SUFFIX_BITS: 8} + ) + + +def test_plain_suffix_must_fit() -> None: + fitted = {"address": 0x1, "code": 0x2, CONF_SUFFIX: 0xA5, CONF_SUFFIX_BITS: 8} + assert _keeloq_suffix_fits(fitted)[CONF_SUFFIX] == 0xA5 + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + _keeloq_suffix_fits({**fitted, CONF_SUFFIX: 0x100}) + + +def test_lambda_suffix_is_not_checked_here() -> None: + config = {"address": 0x1, "code": 0x2, CONF_SUFFIX: "id(x)", CONF_SUFFIX_BITS: 4} + assert _keeloq_suffix_fits(config)[CONF_SUFFIX] == "id(x)" + + +def test_transmit_action_checks_the_width() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1, "code": 0x2, CONF_SUFFIX_BITS: 8} + assert schema({**base, CONF_SUFFIX: 0xA5})[CONF_SUFFIX] == 0xA5 + assert isinstance( + schema({**base, CONF_SUFFIX: Lambda("return 0x100;")})[CONF_SUFFIX], Lambda + ) + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + schema({**base, CONF_SUFFIX: 0x100}) From e50a0d902e2c1e5d4b72a2f52dfacecc7ffb51a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:42:21 -1000 Subject: [PATCH 311/435] [core] Expose the preferences storage helpers to components (#20535) --- esphome/preferences.py | 18 +++++------- tests/unit_tests/test_preferences.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/esphome/preferences.py b/esphome/preferences.py index fce85191302..6e7e4eda33d 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -19,18 +19,15 @@ STORAGE_FLASH = "flash" STORAGE_RTC = "rtc" -def _rtc_supported() -> bool: - """Whether the active platform has an RTC-backed preferences backend. - - Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 - and -C61 have no RTC memory at all, so RTC storage is unavailable there. - """ +def rtc_supported() -> bool: + """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: from esphome.components.esp32 import get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) return False @@ -44,9 +41,10 @@ def _default_storage() -> str: return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH -def _validate_storage(value): +def validate_storage(value: object) -> str: + """Validate a ``storage:`` value, ``flash`` or ``rtc`` where supported.""" value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) - if value == STORAGE_RTC and not _rtc_supported(): + if value == STORAGE_RTC and not rtc_supported(): raise cv.Invalid( f"'{STORAGE_RTC}' storage is not supported on this platform; only " f"'{STORAGE_FLASH}' is available" @@ -56,7 +54,7 @@ def _validate_storage(value): def storage_schema(): """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" - return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + return {cv.Optional(CONF_STORAGE, default=_default_storage): validate_storage} def request_rtc_storage() -> None: @@ -88,7 +86,7 @@ def validate_rtc_storage(value): "be disabled" ) return value - if not _rtc_supported(): + if not rtc_supported(): raise cv.Invalid("RTC preference storage is not supported on this platform") return value diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py index 677eeee7f26..a1ab78bfbff 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -147,3 +147,47 @@ def test_rtc_rejected_on_unsupported_platform() -> None: def test_flash_allowed_on_unsupported_platform() -> None: _set_platform(PLATFORM_RP2040) assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_supported_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is True + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_supported_false_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is False + + +@pytest.mark.parametrize( + ("platform", "expected"), + [(PLATFORM_ESP8266, True), (PLATFORM_RP2040, False)], +) +def test_rtc_supported_per_platform(platform: str, expected: bool) -> None: + _set_platform(platform) + assert preferences.rtc_supported() is expected + + +def test_validate_storage_accepts_flash_everywhere() -> None: + _set_platform(PLATFORM_RP2040) + assert preferences.validate_storage("flash") == preferences.STORAGE_FLASH + assert preferences.validate_storage("FLASH") == preferences.STORAGE_FLASH + + +def test_validate_storage_accepts_rtc_where_supported() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.validate_storage("rtc") == preferences.STORAGE_RTC + + +def test_validate_storage_rejects_rtc_without_rtc_memory() -> None: + _set_esp32(VARIANT_ESP32C2) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_storage("rtc") + + +def test_validate_storage_rejects_unknown_value() -> None: + _set_esp32(VARIANT_ESP32) + with pytest.raises(cv.Invalid): + preferences.validate_storage("nvs") From 59d31505e97f370d451b4b405652e2bceb29b391 Mon Sep 17 00:00:00 2001 From: Jamie Meredith <160557+jlmeredith@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:48:47 -0600 Subject: [PATCH 312/435] [mipi_spi] Add LCDwiki ES3C35P model (ST77922 QSPI) (#19011) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mipi_spi/models/lcdwiki.py | 86 +++++++++++++++++++ .../mipi_spi/test_get_sequence.py | 23 +++++ 2 files changed, 109 insertions(+) create mode 100644 esphome/components/mipi_spi/models/lcdwiki.py diff --git a/esphome/components/mipi_spi/models/lcdwiki.py b/esphome/components/mipi_spi/models/lcdwiki.py new file mode 100644 index 00000000000..6ef8b1fad79 --- /dev/null +++ b/esphome/components/mipi_spi/models/lcdwiki.py @@ -0,0 +1,86 @@ +# Init sequence sourced from LCDwiki's own published Arduino example for this board: +# https://github.com/ydedox/st77922 (Example_01_Simple_test/Simple_test/Simple_test.ino) +# It is the commented-out panel table in that file, with the 0x71 and 0xBE values from the +# active one, which is what the shipped firmware writes. + +from esphome.components.mipi import MODE_RGB, SLPOUT, DriverChip, delay +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y + +# fmt: off +ES3C35P = DriverChip( + "ES3C35P", + width=320, + height=480, + draw_rounding=4, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + color_order=MODE_RGB, + invert_colors=True, + bus_mode=TYPE_QUAD, + data_rate="40MHz", # datasheet caps the write clock at 62.5MHz + cs_pin=10, + reset_delay=120, + no_slpout=True, # SLPOUT is in the init sequence, the panel needs 120ms after it + requires={"psram"}, + initsequence=( + (0xF1, 0x00), + (0x60, 0x00, 0x00, 0x00), + (0x65, 0x80), + (0x79, 0x06), + (0x7B, 0x00, 0x08, 0x08), + (0x80, 0x55, 0x62, 0x2F, 0x17, 0xF0, 0x52, 0x70, 0xD2, 0x52, 0x62, 0xEA), + (0x81, 0x26, 0x52, 0x72, 0x27), + (0x84, 0x92, 0x25), + (0x87, 0x10, 0x10, 0x58, 0x00, 0x02, 0x3A), + (0x88, 0x00, 0x00, 0x2C, 0x10, 0x04, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x06), + (0x89, 0x00, 0x00, 0x00), + (0x8A, 0x13, 0x00, 0x2C, 0x00, 0x00, 0x2C, 0x10, 0x10, 0x00, 0x3E, 0x19), + (0x8B, 0x15, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x97, 0x8E), + (0x8C, 0x1D, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x50, 0x0F, 0x01, 0xC5, 0x12, 0x09), + (0x8D, 0x0C), + (0x8E, 0x33, 0x01, 0x0C, 0x13, 0x01, 0x01), + (0xB3, 0x00, 0x30), + (0x71, 0xD0), + (0x66, 0x02, 0x3F), + (0xBE, 0x26, 0x00, 0x9D), + (0x70, 0x01, 0xA6, 0x11, 0x40, 0xE0, 0x00, 0x11, 0x60, 0x11, 0x00, 0x00, 0x1A), + (0x90, 0x04, 0x04, 0x55, 0x74, 0x00, 0x40, 0x43, 0x2D, 0x2D), + (0x91, 0x04, 0x04, 0x55, 0x75, 0x00, 0x40, 0x42, 0x2D, 0x2D), + (0x92, 0x04, 0x44, 0x55, 0xC0, 0x06, 0x00, 0x07, 0x05, 0x90, 0x2D), + (0x93, 0x04, 0x43, 0x11, 0x00, 0x00, 0x00, 0x00, 0x05, 0x90, 0x2D), + (0x94, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0x95, 0x96, 0x16, 0x00, 0x00, 0xFF), + (0x96, 0x44, 0x53, 0x03, 0x12, 0x23, 0x24, 0x06, 0x05, 0x9A, 0x2D, 0x00, 0x44), + (0x97, 0x44, 0x53, 0x47, 0x56, 0x20, 0x20, 0x02, 0x01, 0x9A, 0x2D, 0x00, 0x44), + (0xBA, 0x55, 0x9A, 0x2D, 0x9A, 0x2D), + (0x9A, 0x40, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9B, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9C, 0x5C, 0x12, 0x00, 0x00, 0x10, 0x12, 0x00, 0x00, 0x10, 0x02, 0x00, 0x00, 0x00), + (0x9D, 0x8A, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0x9E, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0xB4, 0x1D, 0x1C, 0x1E, 0x0B, 0x14, 0x02, 0x13, 0x09, 0x1E, 0x00, 0x1E, 0x10), + (0xB5, 0x1D, 0x1C, 0x1E, 0x0A, 0x15, 0x03, 0x11, 0x08, 0x1E, 0x01, 0x1E, 0x12), + (0xB6, 0x77, 0x77, 0x00, 0x0A, 0xFF, 0x0A, 0xFF), + (0x86, 0xC6, 0x04, 0xB1, 0x02, 0x58, 0x12, 0x58, 0x0C, 0x13, 0x01, 0xA5, 0x00, 0xA5, 0xA5), + (0xB7, 0x07, 0x0A, 0x0E, 0x06, 0x05, 0x03, 0x2B, 0x03, 0x03, 0x42, 0x07, 0x10, 0x10, 0x2E, 0x3F, 0x0D), + (0xB8, 0x07, 0x0A, 0x0D, 0x05, 0x05, 0x02, 0x2B, 0x02, 0x03, 0x42, 0x06, 0x10, 0x0F, 0x2E, 0x3F, 0x0D), + (0xB9, 0x23, 0x23), + (0xBF, 0x10, 0x14, 0x14, 0x0B, 0x0B, 0x0B), + (0xF2, 0x00), + (0x73, 0x04, 0xDA, 0x12, 0x54, 0x47), + (0x77, 0x6B, 0x5B, 0xFD, 0xC3, 0xC5), + (0x7A, 0x15, 0x27), + (0x7B, 0x04, 0x57), + (0x7E, 0x01, 0x0E), + (0xBF, 0x36), + (0xE3, 0x40, 0x40), + (0xF0, 0x00), + (0xD0, 0x00), + (SLPOUT,), + delay(120), + ), +) +# fmt: on + +# The speakerless variant uses the same display +ES3C35P.extend("ES3C35P-NS") diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py index f8614b17a3a..118f844c848 100644 --- a/tests/component_tests/mipi_spi/test_get_sequence.py +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -8,8 +8,11 @@ from esphome.components.mipi import ( BRIGHTNESS, CONF_INVERT_COLORS, CONF_PIXEL_MODE, + SLPOUT, + SWRESET, DriverChip, ) +from esphome.components.mipi_spi.display import MODELS from esphome.const import CONF_BRIGHTNESS # A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run @@ -96,3 +99,23 @@ def test_get_sequence_omits_brightness_when_not_set() -> None: sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False) assert not any(_has_brightness_command(sequence, v) for v in range(256)) + + +def _count_subsequence(sequence: tuple[int, ...], part: tuple[int, ...]) -> int: + return sum( + sequence[i : i + len(part)] == part + for i in range(len(sequence) - len(part) + 1) + ) + + +def test_es3c35p_waits_after_reset_and_issues_its_own_slpout() -> None: + """The ES3C35P settles 120ms after reset and sends SLPOUT itself with a 120ms wait.""" + sequence = MODELS["ES3C35P"].get_sequence( + _BASE_CONFIG, add_madctl=False, add_reset=True + ) + + assert sequence[:4] == (SWRESET, 0, 120, 255) + # Exactly one SLPOUT, followed by the panel's 120ms settle, and none appended by the framework + assert _count_subsequence(sequence, (SLPOUT, 0, 120, 255)) == 1 + assert _count_subsequence(sequence, (SLPOUT, 0, 10, 255)) == 0 + assert "ES3C35P-NS" in MODELS From 643f29fae6434393bea9c64cff62edf8ebe8d562 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:49:19 +0200 Subject: [PATCH 313/435] [uart] Let a UART say it has no line timing (#20227) --- esphome/components/ble_nus/__init__.py | 3 +- esphome/components/tcp_uart/__init__.py | 2 +- esphome/components/uart/__init__.py | 29 ++++++++++ esphome/components/usb_cdc_acm/__init__.py | 2 +- esphome/components/usb_uart/__init__.py | 5 +- tests/component_tests/uart/test_unclocked.py | 57 ++++++++++++++++++++ 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/uart/test_unclocked.py diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index 54f111e4af9..5d431622412 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -3,6 +3,7 @@ from esphome.components.logger import request_log_listener from esphome.components.uart import ( UARTComponent, debug_to_code, + mark_unclocked, maybe_empty_debug, uart_ns, ) @@ -42,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(BLENUS), + cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked), cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of( *[CONF_LOGS, CONF_UART], lower=True ), diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 35326d77d52..df9c1720ee6 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -35,7 +35,7 @@ TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) BASE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(TcpUart), + cv.GenerateID(): cv.All(cv.declare_id(TcpUart), uart.mark_unclocked), cv.Required(CONF_PORT): cv.port, cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 2250b9d5088..5eea8060ff9 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from logging import getLogger import math import re @@ -444,6 +445,34 @@ UART_DEVICE_SCHEMA = cv.Schema( KEY_UART_DEVICES = "uart_devices" +@dataclass +class UARTData: + # UARTs whose received bytes do not keep the timing of a serial line. + unclocked: list[ID] = field(default_factory=list) + + +def _get_data() -> UARTData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UARTData() + return CORE.data[DOMAIN] + + +def mark_unclocked(uart_id: ID) -> ID: + """Record that the bytes of uart_id do not arrive with the timing of a serial line, e.g. over TCP or USB. + + A gap between them says nothing about where a frame ends. Use it on the UART's declared id, + cv.All(cv.declare_id(MyUart), uart.mark_unclocked), so that every to_code sees it. + """ + _get_data().unclocked.append(uart_id) + return uart_id + + +def is_unclocked(uart_id: ID) -> bool: + """Return True if uart_id was passed to mark_unclocked().""" + # By name: a generated id is named only after the schemas ran. + return any(str(uart) == str(uart_id) for uart in _get_data().unclocked) + + def final_validate_device_schema( name: str, *, diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index e49531c80bc..08cd13e3d27 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -29,7 +29,7 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( # Schema for individual CDC ACM interface instances INTERFACE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), } ) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 581727b326a..0875966071d 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -6,6 +6,7 @@ from esphome.components.uart import ( CONF_FLUSH_TIMEOUT, UARTComponent, UARTParityOptions, + mark_unclocked, ) from esphome.components.usb_host import ( get_max_packet_size, @@ -162,7 +163,9 @@ def channel_schema(type_: "Type") -> cv.Schema: cv.ensure_list( cv.Schema( { - cv.GenerateID(): cv.declare_id(USBUartChannel), + cv.GenerateID(): cv.All( + cv.declare_id(USBUartChannel), mark_unclocked + ), cv.Optional(CONF_BUFFER_SIZE, default=256): cv.int_range( min=64, max=8192 ), diff --git a/tests/component_tests/uart/test_unclocked.py b/tests/component_tests/uart/test_unclocked.py new file mode 100644 index 00000000000..bbd131037d3 --- /dev/null +++ b/tests/component_tests/uart/test_unclocked.py @@ -0,0 +1,57 @@ +"""Tests for marking UARTs whose bytes do not arrive with the timing of a serial line.""" + +from esphome.components import ble_nus, tcp_uart, uart, usb_cdc_acm +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +def test_only_a_marked_uart_is_unclocked() -> None: + marked = ID("link", is_declaration=True) + assert uart.mark_unclocked(marked) is marked + assert uart.is_unclocked(ID("link")) + assert not uart.is_unclocked(ID("bus")) + + +def test_a_generated_id_is_found_once_named() -> None: + generated = uart.mark_unclocked( + ID(None, is_declaration=True, type=uart.UARTComponent) + ) + name = generated.resolve([]) + assert uart.is_unclocked(ID(name)) + + +def test_tcp_uart_is_unclocked() -> None: + tcp_uart.CONFIG_SCHEMA({CONF_ID: "link", "role": "server", "port": 6638}) + assert uart.is_unclocked(ID("link")) + + +def test_every_usb_uart_channel_is_unclocked( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={"variant": "ESP32S3"}) + # Its schema is built on import and needs the target. + from esphome.components import usb_uart + + schema = usb_uart.channel_schema(usb_uart._TYPES_BY_NAME["FT2232"]) + schema( + { + "channels": [ + {CONF_ID: "ch_a", "baud_rate": 9600}, + {CONF_ID: "ch_b", "baud_rate": 115200}, + ] + } + ) + assert uart.is_unclocked(ID("ch_a")) + assert uart.is_unclocked(ID("ch_b")) + + +def test_usb_cdc_acm_interface_is_unclocked() -> None: + usb_cdc_acm.INTERFACE_SCHEMA({CONF_ID: "cdc"}) + assert uart.is_unclocked(ID("cdc")) + + +def test_ble_nus_is_unclocked(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + ble_nus.CONFIG_SCHEMA({CONF_ID: "nus", "type": "uart"}) + assert uart.is_unclocked(ID("nus")) From 40425610d5b19dfe8ac6e5cc9ac8593cb89a2ceb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:55:22 -1000 Subject: [PATCH 314/435] [remote_base] Keep constant abbwelcome payloads in shared flash tables (#20311) --- esphome/components/remote_base/__init__.py | 21 +++++------ .../remote_base/abbwelcome_protocol.h | 35 +++++------------- .../config/abbwelcome_payload_tables.yaml | 36 +++++++++++++++++++ .../test_abbwelcome_payload_tables.py | 21 +++++++++++ 4 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml create mode 100644 tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 1911218b1f7..eca57bca8a3 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -44,7 +44,7 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import ID, coroutine +from esphome.core import coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, TemplateArgsType @@ -2393,17 +2393,14 @@ async def abbwelcome_action(var, config, args): await cg.templatable(config[CONF_MESSAGE_ID], args, cg.uint8) ) ) - if CONF_DATA in config: - data_ = config[CONF_DATA] - if cg.is_template(data_): - template_ = await cg.templatable( - data_, args, cg.std_vector.template(cg.uint8) - ) - cg.add(var.set_data_template(template_)) - else: - arr_id = ID(f"{var.base}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data_)) - cg.add(var.set_data_static(arr, len(data_))) + # TemplatableBytes starts in lambda mode with no function, so a transmit without data sets an empty payload + await automation.templatable_bytes( + config.get(CONF_DATA, []), + args, + var.set_data_template, + var.set_data_static, + "abbwelcome_data", + ) # Mirage diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index a309c124eed..901950d3634 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -112,13 +112,14 @@ class ABBWelcomeData { } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } uint8_t get_message_id() const { return this->data_[4 + 2 * this->get_address_length()]; } - void set_data(std::vector data) { - uint8_t size = std::min(MAX_DATA_LENGTH, static_cast(data.size())); + void set_data(const uint8_t *data, size_t len) { + uint8_t size = static_cast(std::min(MAX_DATA_LENGTH, len)); this->data_[2] &= (0xff ^ DATA_LENGTH_MASK); this->data_[2] |= (size & DATA_LENGTH_MASK); if (size) - std::copy_n(data.begin(), size, this->data_.begin() + 5 + 2 * this->get_address_length()); + std::copy_n(data, size, this->data_.begin() + 5 + 2 * this->get_address_length()); } + void set_data(const std::vector &data) { this->set_data(data.data(), data.size()); } std::vector get_data() const { std::vector data(this->data_.begin() + 5 + 2 * this->get_address_length(), this->data_.begin() + 5 + 2 * this->get_address_length() + this->get_data_size()); @@ -233,14 +234,8 @@ template class ABBWelcomeAction : public RemoteTransmitterAction TEMPLATABLE_VALUE(uint8_t, message_type) TEMPLATABLE_VALUE(uint8_t, message_id) TEMPLATABLE_VALUE(bool, auto_message_id) - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) + void encode(RemoteTransmitData *dst, Ts... x) override { ABBWelcomeData data; data.set_three_byte_address(this->three_byte_address_.value(x...)); @@ -250,25 +245,11 @@ template class ABBWelcomeAction : public RemoteTransmitterAction data.set_message_type(this->message_type_.value(x...)); data.set_message_id(this->message_id_.value(x...)); data.auto_message_id = this->auto_message_id_.value(x...); - std::vector data_vec; - if (this->len_ > 0) { - // Static mode: copy from flash to vector - data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else if (this->len_ < 0) { - // Template mode: call function - data_vec = this->data_.func(x...); - } - data.set_data(data_vec); + this->data_.template visit( + [&data](const uint8_t *bytes, size_t len) { data.set_data(bytes, len); }, x...); data.finalize(); ABBWelcomeProtocol().encode(dst, data); } - - protected: - ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; }; } // namespace esphome::remote_base diff --git a/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml new file mode 100644 index 00000000000..66e0fe4e7fa --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml @@ -0,0 +1,36 @@ +--- +esphome: + name: test + on_boot: + then: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x01 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x02 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x03 + data: [] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x04 + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x05 + data: !lambda return {0x09}; + +esp32: + board: esp32dev + +remote_transmitter: + pin: 4 + carrier_duty_percent: 100% diff --git a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py new file mode 100644 index 00000000000..a653b5137ef --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py @@ -0,0 +1,21 @@ +"""Tests for abbwelcome constant data in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_data_shares_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal data shares one table, empty or missing data is an empty table, lambdas stay templates.""" + main_cpp = generate_main(component_config_path("abbwelcome_payload_tables.yaml")) + + tables = re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + assert [v for _, v in tables] == ["{0x10, 0x20, 0x30}"] + assert main_cpp.count(f"set_data_static({tables[0][0]}, 3);") == 2 + assert main_cpp.count("set_data_static(nullptr, 0);") == 2 + assert main_cpp.count("set_data_template(") == 1 From da0c59a57c6abc9e8fc176c17f949ed947a71f22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:57:06 -1000 Subject: [PATCH 315/435] [http_request] Keep action request headers in a shared flash table (#20225) --- esphome/components/http_request/__init__.py | 28 ++++++-- .../components/http_request/http_request.h | 21 ++++-- .../http_request/config/request_headers.yaml | 72 +++++++++++++++++++ .../http_request/test_request_headers.py | 37 ++++++++++ 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/http_request/config/request_headers.yaml create mode 100644 tests/component_tests/http_request/test_request_headers.py diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index a2c181a5ae0..89fe5f8b1cc 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -39,6 +39,7 @@ HttpRequestIDF = http_request_ns.class_("HttpRequestIDF", HttpRequestComponent) HttpRequestHost = http_request_ns.class_("HttpRequestHost", HttpRequestComponent) HttpContainer = http_request_ns.class_("HttpContainer") +RequestHeader = http_request_ns.struct("RequestHeader") HttpRequestSendAction = http_request_ns.class_( "HttpRequestSendAction", automation.Action @@ -360,12 +361,27 @@ async def http_request_action_to_code( for key in json_: template_ = await cg.templatable(json_[key], args, cg.std_string) cg.add(var.add_json(key, template_)) - request_headers = config.get(CONF_REQUEST_HEADERS, {}) - if request_headers: - cg.add(var.init_request_headers(len(request_headers))) - for key, value in request_headers.items(): - template_ = await cg.templatable(value, args, cg.const_char_ptr) - cg.add(var.add_request_header(key, template_)) + if request_headers := config.get(CONF_REQUEST_HEADERS): + # A user lambda may keep static state, so only all-constant lists are shared + share = not any(map(cg.is_template, request_headers.values())) + # Constant headers ignore their arguments, so drop the names: identical lists + # from scripts with different parameter names then render the same table. + header_args = [(arg_type, "") for arg_type, _ in args] if share else args + headers = [ + cg.StructInitializer( + "", + ("name", key), + ("value", await cg.templatable(value, header_args, cg.const_char_ptr)), + ) + for key, value in request_headers.items() + ] + table = cg.shared_progmem_array( + "http_request_headers", + RequestHeader.template(*[arg_type for arg_type, _ in args]), + headers, + share=share, + ) + cg.add(var.set_request_headers(table, len(headers))) for value in config.get(CONF_COLLECT_HEADERS, []): cg.add(var.add_collect_header(value.lower())) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 68696118ea8..424fcf84e86 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -22,6 +22,12 @@ struct Header { std::string value; }; +/// One request header of an action; codegen puts these in a shared flash table. +template struct RequestHeader { + const char *name; + const char *(*value)(Ts...); +}; + // Some common HTTP status codes enum HttpStatus { HTTP_STATUS_OK = 200, @@ -492,9 +498,12 @@ template class HttpRequestSendAction final : public Actionrequest_headers_.init(count); } - void add_request_header(const char *key, TemplatableFn value) { - this->request_headers_.push_back({key, value}); + static_assert(std::is_trivially_copyable_v> && + sizeof(RequestHeader) % sizeof(uint32_t) == 0, + "ESP8266 reads request headers from flash in whole words"); + /// Codegen only: the table must outlive the action. + void set_request_headers(const RequestHeader *headers, size_t count) { + this->request_headers_ = ConstVector>(headers, count); } void add_collect_header(const char *value) { this->lower_case_collect_headers_.emplace_back(value); } @@ -530,8 +539,8 @@ template class HttpRequestSendAction final : public Action request_headers; request_headers.reserve(this->request_headers_.size()); - for (const auto &[key, val] : this->request_headers_) { - request_headers.push_back({key, val.value(x...)}); + for (const auto &header : this->request_headers_) { + request_headers.push_back({header.name, header.value(x...)}); } auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, @@ -596,7 +605,7 @@ template class HttpRequestSendAction final : public Action>> request_headers_{}; + ConstVector> request_headers_{}; std::vector lower_case_collect_headers_{"content-type", "content-length"}; FixedVector>> json_{}; std::function json_func_{nullptr}; diff --git a/tests/component_tests/http_request/config/request_headers.yaml b/tests/component_tests/http_request/config/request_headers.yaml new file mode 100644 index 00000000000..bdd4cd66304 --- /dev/null +++ b/tests/component_tests/http_request/config/request_headers.yaml @@ -0,0 +1,72 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +http_request: + +button: + - platform: template + id: shared_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: shared_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: other + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: text/plain + - platform: template + id: lambda_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + - platform: template + id: lambda_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + +script: + - id: param_a + parameters: + level: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - id: param_b + parameters: + value: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a diff --git a/tests/component_tests/http_request/test_request_headers.py b/tests/component_tests/http_request/test_request_headers.py new file mode 100644 index 00000000000..4eefa55b20b --- /dev/null +++ b/tests/component_tests/http_request/test_request_headers.py @@ -0,0 +1,37 @@ +"""Request headers are PROGMEM tables, shared only between all-constant lists.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_request_headers_share_constant_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("request_headers.yaml")) + + # Actions in config order: the five buttons, then the two scripts + tables = [ + (table, int(count)) + for table, count in re.findall( + r"->set_request_headers\((\w+), (\d+)\);", main_cpp + ) + ] + assert len(tables) == 7 + shared_a, shared_b, other, lambda_a, lambda_b, param_a, param_b = tables + assert shared_a == shared_b + assert shared_a[1] == 2 + assert other[1] == 1 + # A list with a lambda may keep static state, so it never shares even with an identical one + assert lambda_a[0] != lambda_b[0] + assert len({shared_a[0], other[0], lambda_a[0], lambda_b[0]}) == 4 + for table, _ in tables: + assert re.search(rf"\b{table}\[\] PROGMEM = \{{", main_cpp) + # Constant headers ignore their arguments, so scripts whose parameters differ + # only by name share a table, and it is the only one of its argument type + assert param_a == param_b + assert param_a[0] not in {shared_a[0], other[0], lambda_a[0], lambda_b[0]} + assert re.findall(r"RequestHeader<[^>]+> (\w+)\[\] PROGMEM = \{", main_cpp) == [ + param_a[0] + ] From e16b3a6245f4322c26b4756fe848d3e12c14c3fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:57:20 -1000 Subject: [PATCH 316/435] [api] Share one flash string pool between API action and Home Assistant action strings (#20281) --- esphome/codegen.py | 1 + esphome/components/api/__init__.py | 40 +++-------------- esphome/cpp_generator.py | 13 ++++++ .../api/test_action_metadata.py | 44 ++++++++++++------- tests/unit_tests/test_cpp_generator.py | 31 +++++++++++++ 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 4db01a21740..9ed36de0d55 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -44,6 +44,7 @@ from esphome.cpp_generator import ( # noqa: F401 safe_exp, set_cpp_standard, shared_progmem_array, + shared_progmem_string, statement, static_const_array, static_function, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f538c0b5e50..f105c1d7f79 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import Expression, MockObj, TemplateArgsType import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash +from esphome.helpers import fnv1_hash from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType @@ -537,28 +537,11 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _add_action_strings( - index: int, strings: list[str | None], interned: dict[str, MockObj] -) -> MockObj: - """Emit the PROGMEM string table for one action. - - Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical - strings are shared between actions through `interned`. - """ - entries: list[MockObj] = [] - for string in strings: - if string is None: - entries.append(cg.nullptr) - continue - if (var := interned.get(string)) is None: - var = interned[string] = cg.progmem_array( - ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char), - string, - ) - entries.append(var) +def _add_action_strings(index: int, strings: list[str | None]) -> MockObj: + """Emit the PROGMEM string table for one action.""" return cg.progmem_array( ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr), - entries, + [cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings], ) @@ -611,7 +594,6 @@ async def to_code(config: ConfigType) -> None: has_metadata = _has_action_metadata(actions) if has_metadata: cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") - interned_strings: dict[str, MockObj] = {} # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.MockObj] = [] for index, conf in enumerate(actions): @@ -656,7 +638,7 @@ async def to_code(config: ConfigType) -> None: service_template_args.append(native) func_args.append((native, name)) strings = _action_strings(conf, has_metadata) - table = _add_action_strings(index, strings, interned_strings) + table = _add_action_strings(index, strings) if CORE.is_esp8266: scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types @@ -828,14 +810,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) -def _field_string(value: str) -> Expression: - # ESP8266 can only keep a string in flash as its own PROGMEM array - literal = cg.RawExpression(cpp_string_escape(value)) - if CORE.is_esp8266: - return cg.shared_progmem_array("ha_field_str", cg.char, literal) - return literal - - async def _new_service_call_action( server_id: ID, action_id: ID, @@ -858,12 +832,12 @@ async def _new_service_call_action( ) async def field(key: str | None, value: Any, output_type: Any = None) -> Expression: - key_exp = cg.nullptr if key is None else _field_string(key) + key_exp = cg.nullptr if key is None else cg.shared_progmem_string(key) if cg.is_template(value): # output_type=None lets lambdas return numbers or char pointers; C++ converts them lam = await cg.process_lambda(value, args, return_type=output_type) return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})") - return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr) + return cg.ArrayInitializer(key_exp, cg.shared_progmem_string(value), cg.nullptr) entries = [await field(None, service, cg.std_string)] for group in groups: diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 9c9fed66012..1196669662c 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -540,6 +540,19 @@ def shared_progmem_array( return array +def shared_progmem_string(value: str) -> Expression: + """Return a string that stays in flash: one shared PROGMEM array per text on ESP8266. + + Other platforms keep string literals in flash already, so they get the plain literal. + """ + from esphome.cpp_types import char + + text = StringLiteral(value) + if CORE.is_esp8266: + return shared_progmem_array("progmem_str", char, text) + return text + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index adbfdf306eb..69c9605a675 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -2,6 +2,7 @@ from collections.abc import Callable from pathlib import Path +import re import pytest @@ -28,35 +29,46 @@ CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yam def test_metadata_is_emitted_as_progmem_table( generate_main: Callable[[str | Path], str], ) -> None: - """Every action string is a PROGMEM array referenced from one PROGMEM table.""" + """Every action string is a literal referenced from one PROGMEM table.""" main_cpp = generate_main(CONFIG) - assert ( - 'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp - ) - assert ( - 'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";' - in main_cpp - ) - assert ( - 'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";' - in main_cpp - ) assert ( "static constexpr const char * api_action0_strings[] PROGMEM = {" - "api_action_str0, api_action_str1, api_action_str2, api_action_str3, " - "api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp + '"play_buzzer", "Play an RTTTL melody on the buzzer", "song_str", ' + '"RTTTL melody string", "two_short:d=4,o=5,b=100:16e6,16e6", "volume", ' + "nullptr, nullptr};" in main_cpp ) # An action without metadata still carries the metadata slots (as nullptr) assert ( "static constexpr const char * api_action1_strings[] PROGMEM = {" - "api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp + '"plain_action", nullptr, "value", nullptr, nullptr};' in main_cpp ) assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines} assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None +def test_esp8266_strings_are_shared_progmem_arrays( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP8266 each string is its own PROGMEM array, referenced from the table.""" + main_cpp = generate_main(CONFIG_ESP8266) + + def array_for(text: str) -> str: + match = re.search( + rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp + ) + assert match, text + return match.group(1) + + assert array_for("play_buzzer") + assert ( + "static constexpr const char * api_action1_strings[] PROGMEM = {" + f"{array_for('plain_action')}, nullptr, {array_for('value')}, nullptr, nullptr}};" + in main_cpp + ) + + def test_esp8266_sizes_scratch_buffer_for_largest_action( generate_main: Callable[[str | Path], str], ) -> None: @@ -76,7 +88,7 @@ def test_shorthand_variables_emit_no_metadata( assert ( "static constexpr const char * api_action0_strings[] PROGMEM = " - "{api_action_str0, api_action_str1};" in main_cpp + '{"plain_action", "value"};' in main_cpp ) assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines} diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 6fdd197c952..9529e448008 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -4,6 +4,12 @@ import math import pytest from esphome import cpp_generator as cg, cpp_types as ct +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE, ID, EsphomeError @@ -887,6 +893,31 @@ class TestSharedProgmemArray: assert str(array) == "table_2" +class TestProgmemString: + @staticmethod + def _set_platform(platform: str) -> None: + CORE.config = {} + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + def test_esp8266_shares_one_array_per_text(self) -> None: + self._set_platform(PLATFORM_ESP8266) + a = cg.shared_progmem_string("level") + b = cg.shared_progmem_string("level") + c = cg.shared_progmem_string('say "hi"') + assert a is b + assert str(a) == "progmem_str" + assert str(c) == "progmem_str_2" + assert [str(st) for st in CORE.global_statements] == [ + 'static constexpr char progmem_str[] PROGMEM = "level";', + 'static constexpr char progmem_str_2[] PROGMEM = "say \\042hi\\042";', + ] + + def test_other_platforms_return_the_literal(self) -> None: + self._set_platform(PLATFORM_ESP32) + assert str(cg.shared_progmem_string('say "hi"')) == '"say \\042hi\\042"' + assert not CORE.global_statements + + def test_extern_progmem_array_is_an_externally_linked_global() -> None: array = cg.extern_progmem_array("esphome::api::API_TABLE", ct.uint8, [1, 2]) assert str(array) == "esphome::api::API_TABLE" From 9295342c16f7eecdb69f1f2730deb91845c17616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:58:49 -1000 Subject: [PATCH 317/435] [event] Keep event types in a shared flash table (#20217) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.h | 2 +- esphome/components/event/__init__.py | 4 +- esphome/components/event/event.cpp | 7 +-- esphome/components/event/event.h | 26 +++++++--- tests/component_tests/event/__init__.py | 0 .../event/config/event_types.yaml | 19 +++++++ .../component_tests/event/test_event_types.py | 25 +++++++++ tests/components/event/event_types_test.cpp | 51 +++++++++++++++++++ 9 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/event/__init__.py create mode 100644 tests/component_tests/event/config/event_types.yaml create mode 100644 tests/component_tests/event/test_event_types.py create mode 100644 tests/components/event/event_types_test.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1b06348e2ce..f8f31d81df0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2570,7 +2570,7 @@ message ListEntitiesEventResponse { EntityCategory entity_category = 7; string device_class = 8 [(max_data_length) = 47]; - repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector"]; + repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector"]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } message EventResponse { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 17436d33aab..14204e97aaa 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3460,7 +3460,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } #endif StringRef device_class{nullptr, 0}; // null until set, encode only - const FixedVector *event_types{}; + const ConstVector *event_types{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index c40130634f9..b09d9791414 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -98,7 +98,9 @@ async def setup_event_core_( ) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - cg.add(var.set_event_types(event_types)) + if event_types: + table = cg.shared_progmem_array("event_types", cg.const_char_ptr, event_types) + cg.add(var.set_event_types_static(table, len(event_types))) setup_device_class(config) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 3170c7a8dd0..671e11e7d21 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -28,11 +28,8 @@ void Event::trigger(const char *event_type) { #endif } -void Event::set_event_types(const FixedVector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } +void Event::set_event_types_copy_(const char *const *event_types, size_t count) { + this->types_.assign_copy(event_types, count); this->last_event_type_ = nullptr; // Reset when types change } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index f57327e33f0..203ee52ac8e 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -11,6 +11,9 @@ namespace esphome::event { +/// A codegen table in flash, or a heap copy of a runtime list (see ConstVector). +using EventTypes = ConstVector; + #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ @@ -24,20 +27,29 @@ class Event : public EntityBase { void trigger(const char *event_type); void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } - /// Set the event types supported by this event; called by generated code with string literals. + /// Codegen only: points at a table that outlives the event (see ConstVector::assign_static). + void set_event_types_static(const char *const *event_types, size_t count) { + this->types_.assign_static(event_types, count); + this->last_event_type_ = nullptr; + } + /// Runtime lists are copied; the strings must still outlive the event. void set_event_types(std::initializer_list event_types) { - this->types_ = event_types; - this->last_event_type_ = nullptr; // Reset when types change + this->set_event_types_copy_(event_types.begin(), event_types.size()); } /// Copy the event types of another event, for components that wrap one. - void set_event_types(const FixedVector &event_types); + void set_event_types(const EventTypes &event_types) { + this->set_event_types_copy_(event_types.data(), event_types.size()); + } + void set_event_types(const FixedVector &event_types) { + this->set_event_types_copy_(event_types.begin(), event_types.size()); + } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_event_types(std::initializer_list event_types) = delete; void set_event_types(const FixedVector &event_types) = delete; /// Return the event types supported by this event. - const FixedVector &get_event_types() const { return this->types_; } + const EventTypes &get_event_types() const { return this->types_; } /// Return the last triggered event type, or empty StringRef if no event triggered yet. StringRef get_last_event_type() const { return StringRef::from_maybe_nullptr(this->last_event_type_); } @@ -68,8 +80,10 @@ class Event : public EntityBase { } protected: + void set_event_types_copy_(const char *const *event_types, size_t count); + LazyCallbackManager event_callback_; - FixedVector types_; + EventTypes types_; private: /// Last triggered event type - must point to entry in types_ to ensure valid lifetime. diff --git a/tests/component_tests/event/__init__.py b/tests/component_tests/event/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/event/config/event_types.yaml b/tests/component_tests/event/config/event_types.yaml new file mode 100644 index 00000000000..8e849d4a782 --- /dev/null +++ b/tests/component_tests/event/config/event_types.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +event: + - platform: template + id: event_a + name: Event A + event_types: [pressed, held] + - platform: template + id: event_b + name: Event B + event_types: [pressed, held] + - platform: template + id: event_c + name: Event C + event_types: [ring] diff --git a/tests/component_tests/event/test_event_types.py b/tests/component_tests/event/test_event_types.py new file mode 100644 index 00000000000..30e653bc70d --- /dev/null +++ b/tests/component_tests/event/test_event_types.py @@ -0,0 +1,25 @@ +"""Event types are shared PROGMEM tables of type pointers.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_event_types_use_shared_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("event_types.yaml")) + + calls = { + var: table + for var, table, _ in re.findall( + r"(\w+)->set_event_types_static\((\w+), (\d+)\);", main_cpp + ) + } + assert set(calls) == {"event_a", "event_b", "event_c"} + assert calls["event_a"] == calls["event_b"] != calls["event_c"] + assert ( + f'static constexpr const char * {calls["event_a"]}[] PROGMEM = {{"pressed", "held"}};' + in main_cpp + ) diff --git a/tests/components/event/event_types_test.cpp b/tests/components/event/event_types_test.cpp new file mode 100644 index 00000000000..0bc99e1a8e3 --- /dev/null +++ b/tests/components/event/event_types_test.cpp @@ -0,0 +1,51 @@ +#include + +#include "esphome/components/event/event.h" + +namespace esphome::event::testing { + +static const char *const TYPES[] = {"pressed", "held"}; + +TEST(EventTypes, StaticTableIsViewed) { + Event event; + event.set_event_types_static(TYPES, 2); + EXPECT_EQ(event.get_event_types().data(), TYPES); + EXPECT_EQ(event.get_event_types().size(), 2u); +} + +TEST(EventTypes, CopiesAnotherEventAndResetsLastType) { + Event source; + source.set_event_types({"x", "y"}); + Event copy; + copy.set_event_types_static(TYPES, 2); + copy.trigger("held"); + ASSERT_TRUE(copy.has_event()); + copy.set_event_types(source.get_event_types()); + EXPECT_FALSE(copy.has_event()); + EXPECT_NE(copy.get_event_types().data(), TYPES); + EXPECT_NE(copy.get_event_types().data(), source.get_event_types().data()); + EXPECT_EQ(copy.get_event_types().size(), 2u); + EXPECT_STREQ(copy.get_event_types()[1], "y"); +} + +TEST(EventTypes, CopiesItsOwnList) { + Event event; + event.set_event_types({"a", "b"}); + event.set_event_types(event.get_event_types()); // copies before freeing the old list + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[1], "b"); +} + +TEST(EventTypes, CopiesFromFixedVector) { + FixedVector types; + types.init(2); + types.push_back("on"); + types.push_back("off"); + Event event; + event.set_event_types(types); + EXPECT_NE(event.get_event_types().data(), types.begin()); + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[0], "on"); +} + +} // namespace esphome::event::testing From 993fd8d7a212b57416149802af9c795cf03e1f5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:04:16 -1000 Subject: [PATCH 318/435] [thermostat] Keep presets in shared flash tables (#20080) --- esphome/components/thermostat/climate.py | 107 +++++++----------- .../thermostat/thermostat_climate.cpp | 65 ++++++----- .../thermostat/thermostat_climate.h | 46 +++++--- tests/component_tests/thermostat/__init__.py | 0 .../thermostat/config/presets.yaml | 53 +++++++++ .../thermostat/test_presets.py | 28 +++++ 6 files changed, 189 insertions(+), 110 deletions(-) create mode 100644 tests/component_tests/thermostat/__init__.py create mode 100644 tests/component_tests/thermostat/config/presets.yaml create mode 100644 tests/component_tests/thermostat/test_presets.py diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 3cc4dc70091..8a954b181eb 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -39,7 +39,6 @@ from esphome.const import ( CONF_HEAT_MODE, CONF_HEAT_OVERRUN, CONF_HUMIDITY_SENSOR, - CONF_ID, CONF_IDLE_ACTION, CONF_MAX_COOLING_RUN_TIME, CONF_MAX_HEATING_RUN_TIME, @@ -72,6 +71,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.helpers import cpp_string_escape from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" @@ -93,6 +93,8 @@ ThermostatClimate = thermostat_ns.class_( ThermostatClimateTargetTempConfig = thermostat_ns.struct( "ThermostatClimateTargetTempConfig" ) +ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") +ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") ON_BOOT_RESTORE_FROM = { "MEMORY": OnBootRestoreFrom.MEMORY, @@ -116,6 +118,7 @@ ClimatePreset = climate_ns.enum("ClimatePreset") PRESET_CONFIG_SCHEMA = cv.Schema( { + # Still accepted for existing configs; presets are flash table entries, not variables. cv.GenerateID(): cv.declare_id(ThermostatClimateTargetTempConfig), cv.Required(CONF_NAME): cv.string_strict, cv.Optional(CONF_MODE): validate_climate_mode, @@ -674,7 +677,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, } ), - cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_PRESET): cv.All( + cv.ensure_list(PRESET_CONFIG_SCHEMA), cv.Length(max=255) + ), cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from, cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( single=True @@ -964,85 +969,51 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: - # Separate standard and custom presets, and build preset config variables - standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = [] - custom_presets: list[tuple[str, cg.MockObj]] = [] + # Presets live in shared PROGMEM tables; the thermostat keeps a pointer and a count. + standard_presets: list[cg.StructInitializer] = [] + custom_presets: list[cg.StructInitializer] = [] + nan = float("nan") + unset = cg.RawExpression("std::nullopt") for preset_config in config[CONF_PRESET]: name = preset_config[CONF_NAME] - standard_preset = None - if name.upper() in climate.CLIMATE_PRESETS: - standard_preset = climate.CLIMATE_PRESETS[name.upper()] - + low = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_LOW) + high = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH) if two_points_available: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] + temperatures = (nan, low, high) + else: + temperatures = (high if high is not None else low, nan, nan) + target = ThermostatClimateTargetTempConfig( + *(nan if t is None else t for t in temperatures), + preset_config.get(CONF_MODE, unset), + preset_config.get(CONF_FAN_MODE, unset), + preset_config.get(CONF_SWING_MODE, unset), + ) + if (standard := climate.CLIMATE_PRESETS.get(name.upper())) is not None: + standard_presets.append( + cg.StructInitializer( + ThermostatPresetEntry, ("preset", standard), ("config", target) + ) ) else: - preset_target_config = None - - preset_target_variable = cg.new_variable( - preset_config[CONF_ID], preset_target_config - ) - - if CONF_MODE in preset_config: - cg.add(preset_target_variable.set_mode(preset_config[CONF_MODE])) - - if CONF_FAN_MODE in preset_config: - cg.add( - preset_target_variable.set_fan_mode(preset_config[CONF_FAN_MODE]) - ) - - if CONF_SWING_MODE in preset_config: - cg.add( - preset_target_variable.set_swing_mode( - preset_config[CONF_SWING_MODE] + custom_presets.append( + cg.StructInitializer( + ThermostatCustomPresetEntry, + ("name", cg.RawExpression(cpp_string_escape(name))), + ("config", target), ) ) - if standard_preset is not None: - standard_presets.append((standard_preset, preset_target_variable)) - else: - custom_presets.append((name, preset_target_variable)) - - # Build initializer list for standard presets if standard_presets: - cg.add( - var.set_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatPresetEntry"), - ("preset", preset), - ("config", preset_var), - ) - for preset, preset_var in standard_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_presets", ThermostatPresetEntry, standard_presets ) - - # Build initializer list for custom presets + cg.add(var.set_preset_config(table, len(standard_presets))) if custom_presets: - cg.add( - var.set_custom_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatCustomPresetEntry"), - ("name", cg.RawExpression(f'"{name}"')), - ("config", preset_var), - ) - for name, preset_var in custom_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_custom_presets", ThermostatCustomPresetEntry, custom_presets ) + cg.add(var.set_custom_preset_config(table, len(custom_presets))) if CONF_DEFAULT_PRESET in config: default_preset_name = config[CONF_DEFAULT_PRESET] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index abfe1b027a5..5d24759c2d9 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -322,8 +322,8 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - for (const auto &entry : this->preset_config_) { - traits.add_supported_preset(entry.preset); + for (uint8_t i = 0; i < this->preset_count_; i++) { + traits.add_supported_preset(this->preset_entry_(i).preset); } // Custom presets are stored on Climate base class and wired via get_traits() @@ -1175,17 +1175,30 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm } } +ThermostatPresetEntry ThermostatClimate::preset_entry_(uint8_t index) const { + ThermostatPresetEntry entry; + progmem_memcpy(&entry, &this->preset_config_[index], sizeof(entry)); + return entry; +} + +ThermostatCustomPresetEntry ThermostatClimate::custom_preset_entry_(uint8_t index) const { + ThermostatCustomPresetEntry entry; + progmem_memcpy(&entry, &this->custom_preset_config_[index], sizeof(entry)); + return entry; +} + void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { // Linear search through preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->preset_config_) { + optional config; + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); if (entry.preset == preset) { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset))); if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) { // Fire preset changed trigger @@ -1205,16 +1218,17 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { void ThermostatClimate::change_custom_preset_(const char *custom_preset, size_t len) { // Linear search through custom preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->custom_preset_config_) { + optional config; + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); // Compare first len chars, then verify entry.name ends there (same length) if (strncmp(entry.name, custom_preset, len) == 0 && entry.name[len] == '\0') { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset); if (this->change_preset_internal_(*config) || !this->has_custom_preset() || this->get_custom_preset() != custom_preset) { @@ -1279,13 +1293,14 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { +void ThermostatClimate::set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count) { this->custom_preset_config_ = presets; + this->custom_preset_count_ = count; // Populate Climate base class custom presets vector std::vector names; - names.reserve(presets.size()); - for (const auto &entry : this->custom_preset_config_) { - names.push_back(entry.name); + names.reserve(count); + for (uint8_t i = 0; i < count; i++) { + names.push_back(this->custom_preset_entry_(i).name); } this->set_supported_custom_presets(names); } @@ -1294,7 +1309,8 @@ ThermostatClimate::ThermostatClimate() = default; void ThermostatClimate::set_default_preset(const char *custom_preset) { // Find the preset in custom_preset_config_ and store pointer from there - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); if (strcmp(entry.name, custom_preset) == 0) { this->default_custom_preset_ = entry.name; return; @@ -1545,9 +1561,10 @@ void ThermostatClimate::dump_config() { YESNO(this->supports_dehumidification_ || this->supports_humidification_), YESNO(this->supports_dehumidification_), YESNO(this->supports_humidification_)); - if (!this->preset_config_.empty()) { + if (this->preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported PRESETS:"); - for (const auto &entry : this->preset_config_) { + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset)); ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? LOG_STR_LITERAL(" (default)") : ""); @@ -1555,9 +1572,10 @@ void ThermostatClimate::dump_config() { } } - if (!this->custom_preset_config_.empty()) { + if (this->custom_preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); const auto *preset_name = entry.name; ESP_LOGCONFIG(TAG, " %s:%s", preset_name, (this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0) @@ -1568,13 +1586,4 @@ void ThermostatClimate::dump_config() { } } -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature) - : default_temperature(default_temperature) {} - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature_low, - float default_temperature_high) - : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} - } // namespace esphome::thermostat diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index b7d46eae227..e9c0ae75fdc 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -49,9 +49,21 @@ struct ThermostatClimateTimer { struct ThermostatClimateTargetTempConfig { public: - ThermostatClimateTargetTempConfig(); - ThermostatClimateTargetTempConfig(float default_temperature); - ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high); + constexpr ThermostatClimateTargetTempConfig() = default; + constexpr ThermostatClimateTargetTempConfig(float default_temperature) : default_temperature(default_temperature) {} + constexpr ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high) + : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} + /// Full form used by the generated preset tables in flash. + constexpr ThermostatClimateTargetTempConfig(float default_temperature, float default_temperature_low, + float default_temperature_high, optional mode, + optional fan_mode, + optional swing_mode) + : default_temperature(default_temperature), + default_temperature_low(default_temperature_low), + default_temperature_high(default_temperature_high), + fan_mode_(fan_mode), + swing_mode_(swing_mode), + mode_(mode) {} void set_fan_mode(climate::ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; } void set_swing_mode(climate::ClimateSwingMode swing_mode) { this->swing_mode_ = swing_mode; } @@ -60,10 +72,6 @@ struct ThermostatClimateTargetTempConfig { float default_temperature{NAN}; float default_temperature_low{NAN}; float default_temperature_high{NAN}; - float cool_deadband_{NAN}; - float cool_overrun_{NAN}; - float heat_deadband_{NAN}; - float heat_overrun_{NAN}; optional fan_mode_{}; optional swing_mode_{}; optional mode_{}; @@ -175,8 +183,12 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_supports_humidification(bool supports_humidification); void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } - void set_custom_preset_config(std::initializer_list presets); + /// `presets` is a PROGMEM table that outlives the thermostat. + void set_preset_config(const PresetEntry *presets, uint8_t count) { + this->preset_config_ = presets; + this->preset_count_ = count; + } + void set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count); Trigger<> *get_cool_action_trigger(); Trigger<> *get_supplemental_cool_action_trigger(); @@ -328,6 +340,9 @@ class ThermostatClimate final : public climate::Climate, public Component { bool humidification_required_(); void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config); + /// Copies of table entries; the tables live in PROGMEM and hold byte-sized fields. + PresetEntry preset_entry_(uint8_t index) const; + CustomPresetEntry custom_preset_entry_(uint8_t index) const; /// Minimum allowable duration in seconds for action timers const uint8_t min_timer_duration_{1}; @@ -349,6 +364,9 @@ class ThermostatClimate final : public climate::Climate, public Component { /// state will attempt to be restored if possible OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY}; + uint8_t preset_count_{0}; + uint8_t custom_preset_count_{0}; + /// Whether the controller supports auto/cooling/drying/fanning/heating. /// /// A false value for any given attribute means that the controller has no such action @@ -522,13 +540,13 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Climate action timers std::array timer_{}; - /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) - FixedVector preset_config_{}; - /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") - FixedVector custom_preset_config_{}; + /// Standard preset configurations (Eg. AWAY, ECO, etc), a PROGMEM table of preset_count_ entries + const PresetEntry *preset_config_{nullptr}; + /// Custom preset configurations (eg. "My Custom Preset"), a PROGMEM table of custom_preset_count_ entries + const CustomPresetEntry *custom_preset_config_{nullptr}; private: - /// Default custom preset to use on start up (pointer to entry in custom_preset_config_) + /// Default custom preset to use on start up (name pointer from an entry in custom_preset_config_) const char *default_custom_preset_{nullptr}; }; diff --git a/tests/component_tests/thermostat/__init__.py b/tests/component_tests/thermostat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/thermostat/config/presets.yaml b/tests/component_tests/thermostat/config/presets.yaml new file mode 100644 index 00000000000..956562ff63f --- /dev/null +++ b/tests/component_tests/thermostat/config/presets.yaml @@ -0,0 +1,53 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo_a + name: Thermo A + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C + - platform: thermostat + id: thermo_b + name: Thermo B + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C diff --git a/tests/component_tests/thermostat/test_presets.py b/tests/component_tests/thermostat/test_presets.py new file mode 100644 index 00000000000..129cb1587fa --- /dev/null +++ b/tests/component_tests/thermostat/test_presets.py @@ -0,0 +1,28 @@ +"""Tests for the thermostat preset codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_presets_shared_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Thermostats with the same presets share one PROGMEM table per kind.""" + main_cpp = generate_main(component_config_path("presets.yaml")) + + standard = re.findall( + r"static constexpr thermostat::ThermostatPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + custom = re.findall( + r"static constexpr thermostat::ThermostatCustomPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(standard) == 1 + assert len(custom) == 1 + for var in ("thermo_a", "thermo_b"): + assert f"{var}->set_preset_config({standard[0]}, 2);" in main_cpp + assert f"{var}->set_custom_preset_config({custom[0]}, 1);" in main_cpp + assert "climate::CLIMATE_MODE_HEAT" in main_cpp From 5d64b65d725663015ea4c0903b300f2d3aafc5e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:12:09 -1000 Subject: [PATCH 319/435] [esp32_ble_server] Keep constant set_value payloads in shared flash tables (#20314) --- .../components/esp32_ble_server/__init__.py | 68 ++++++++++++++---- .../esp32_ble_server/ble_characteristic.cpp | 8 +++ .../esp32_ble_server/ble_characteristic.h | 1 + .../esp32_ble_server/ble_descriptor.cpp | 9 +-- .../esp32_ble_server/ble_descriptor.h | 3 +- .../esp32_ble_server/ble_server_automations.h | 26 ++++--- .../esp32_ble_server/config/set_value.yaml | 37 ++++++++++ .../esp32_ble_server/test_esp32_ble_server.py | 72 +++++++++++++++++++ .../bytebuffer/test_wrap_values.cpp | 9 ++- tests/components/esp32_ble_server/common.yaml | 7 ++ 10 files changed, 204 insertions(+), 36 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/config/set_value.yaml diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 75a099a3c49..d37b2f4020d 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,4 +1,6 @@ import encodings +import struct +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,8 +27,10 @@ from esphome.const import ( __version__ as ESPHOME_VERSION, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigType, TemplateArgsType AUTO_LOAD = ["esp32_ble", "bytebuffer"] CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] @@ -111,8 +115,6 @@ BLECharacteristicNotifyAction = esp32_ble_server_automations_ns.class_( ) bytebuffer_ns = cg.esphome_ns.namespace("bytebuffer") Endianness_ns = bytebuffer_ns.namespace("Endian") -ByteBuffer_ns = bytebuffer_ns.namespace("ByteBuffer") -ByteBuffer = bytebuffer_ns.class_("ByteBuffer") PROPERTY_MAP = { @@ -141,6 +143,15 @@ class ValueType: return value +def float32(value: Any) -> float: + value = cv.float_(value) + try: + struct.pack(" list[int]: + """The bytes of a constant value, as ByteBuffer::wrap packed them at runtime.""" + value = value_config[CONF_DATA] + if isinstance(value, str): + return list(value.encode(value_config[CONF_STRING_ENCODING])) + if isinstance(value, list): + return value + order = ">" if value_config[CONF_ENDIANNESS] == "BIG" else "<" + return list(struct.pack(order + _STRUCT_FORMATS[value_config[CONF_TYPE]], value)) + + async def parse_value(value_config, args): value = value_config[CONF_DATA] if isinstance(value, cv.Lambda): return await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + # An initializer list calls the set_value(std::initializer_list) overload + return cg.ArrayInitializer(*value_bytes(value_config)) - if isinstance(value, str): - value = list(value.encode(value_config[CONF_STRING_ENCODING])) - if isinstance(value, list): - # Generate initializer list {1, 2, 3} instead of std::vector({1, 2, 3}) - # This calls the set_value(std::initializer_list) overload - return cg.ArrayInitializer(*value) - val = cg.RawExpression(f"{value_config[CONF_TYPE]}({cg.safe_exp(value)})") - return ByteBuffer_ns.wrap(val, value_config[CONF_ENDIANNESS]) + +async def set_value_buffer( + var: MockObj, value_config: ConfigType, args: TemplateArgsType +) -> None: + value = value_config[CONF_DATA] + if not isinstance(value, cv.Lambda): + value = value_bytes(value_config) + await automation.templatable_bytes( + value, args, var.set_buffer_template, var.set_buffer_static, "ble_server_value" + ) def calculate_num_handles(service_config): @@ -662,8 +702,7 @@ async def to_code(config): async def ble_server_characteristic_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_SET_VALUE_ACTION") return var @@ -682,8 +721,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a async def ble_server_descriptor_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION") return var diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 75a33993797..1571a6077bd 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -30,6 +30,14 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } +void BLECharacteristic::set_value(const uint8_t *data, size_t length) { + if (length == 0) { // An empty constant is a nullptr table + this->value_.clear(); + return; + } + this->value_.assign(data, data + length); +} + void BLECharacteristic::set_value(std::vector &&buffer) { this->value_ = std::move(buffer); } void BLECharacteristic::set_value(std::initializer_list data) { diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index b7a3fae1a5b..905f5b7a0d0 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -30,6 +30,7 @@ class BLECharacteristic final { ~BLECharacteristic(); void set_value(ByteBuffer buffer); + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(const std::string &buffer); diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 228313e2d8e..7810dba9f5c 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -49,17 +49,18 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { this->state_ = CREATING; } -void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value_impl_(buffer.data(), buffer.size()); } +void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value(buffer.data(), buffer.size()); } -void BLEDescriptor::set_value(std::initializer_list data) { this->set_value_impl_(data.begin(), data.size()); } +void BLEDescriptor::set_value(std::initializer_list data) { this->set_value(data.begin(), data.size()); } -void BLEDescriptor::set_value_impl_(const uint8_t *data, size_t length) { +void BLEDescriptor::set_value(const uint8_t *data, size_t length) { if (length > this->value_.attr_max_len) { ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", length, this->value_.attr_max_len); return; } this->value_.attr_len = length; - memcpy(this->value_.attr_value, data, length); + if (length != 0) // An empty constant is a nullptr table + memcpy(this->value_.attr_value, data, length); } void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 5096d39f284..1ffd4034346 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -26,6 +26,7 @@ class BLEDescriptor { void do_create(BLECharacteristic *characteristic); ESPBTUUID get_uuid() const { return this->uuid_; } + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } @@ -42,8 +43,6 @@ class BLEDescriptor { } protected: - void set_value_impl_(const uint8_t *data, size_t length); - BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index e5463847fa8..d90b2a28fd5 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -16,6 +16,16 @@ namespace esphome::esp32_ble_server::esp32_ble_server_automations { using namespace esp32_ble; +/// Sets a characteristic or descriptor value from a constant flash table or a lambda's vector. +// Not TemplatableBytes::visit, which would copy the lambda's vector instead of moving it into the value. +template void apply_value(T *target, const V &value, Xs &&...x) { + if (value.is_static()) { + target->set_value(value.data(), value.size()); + } else { + target->set_value(value.value(x...)); + } +} + class BLETriggers { public: #ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE @@ -67,25 +77,23 @@ class BLECharacteristicSetValueActionManager { template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } + TEMPLATABLE_BYTES(buffer) void play(const Ts &...x) override { // If the listener is already set, do nothing if (BLECharacteristicSetValueActionManager::get_instance()->has_listener(this->parent_)) return; // Set initial value - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); // Set the listener for read events // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference // (e.g. climate on_control's ClimateCall&). See #17142. this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { apply_value(this->parent_, this->buffer_, x...); }); } protected: @@ -115,10 +123,8 @@ template class BLECharacteristicNotifyAction final : public Acti template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } - void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } + TEMPLATABLE_BYTES(buffer) + void play(const Ts &...x) override { apply_value(this->parent_, this->buffer_, x...); } protected: BLEDescriptor *parent_; diff --git a/tests/component_tests/esp32_ble_server/config/set_value.yaml b/tests/component_tests/esp32_ble_server/config/set_value.yaml new file mode 100644 index 00000000000..69f1a17dc80 --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/set_value.yaml @@ -0,0 +1,37 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - id: test_characteristic + uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] + descriptors: + - id: test_descriptor + uuid: 0x4414 + value: "Initial" + - uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc99a + write: true + on_write: + - ble_server.characteristic.set_value: + id: test_characteristic + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.descriptor.set_value: + id: test_descriptor + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.characteristic.set_value: + id: test_characteristic + value: !lambda return x; diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 21a12d9cf2e..e3006d2c692 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -9,10 +9,16 @@ import pytest from esphome.components.esp32_ble_server import ( CCCD_DESCRIPTOR_UUID, + CONF_ENDIANNESS, + CONF_STRING_ENCODING, CUD_DESCRIPTOR_UUID, DEVICE_INFORMATION_SERVICE_UUID, + float32, uuid_is, + value_bytes, ) +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_TYPE @pytest.mark.parametrize( @@ -73,3 +79,69 @@ def test_advertising_required( main_cpp = generate_main(component_config_path(config_file)) assert f"set_advertising_required({str(required).lower()})" in main_cpp + + +# The same values and bytes as tests/components/bytebuffer/test_wrap_values.cpp, which pins +# what ByteBuffer::wrap produced from the expressions this component used to emit. +@pytest.mark.parametrize( + ("data", "type_", "endianness", "expected"), + [ + (18, "uint8_t", "LITTLE", [0x12]), + (18, "uint16_t", "LITTLE", [0x12, 0x00]), + (4660, "uint16_t", "BIG", [0x12, 0x34]), + (305419896, "uint32_t", "LITTLE", [0x78, 0x56, 0x34, 0x12]), + ( + 1311768467294899695, + "uint64_t", + "BIG", + [0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF], + ), + (-5, "int8_t", "LITTLE", [0xFB]), + (-2, "int16_t", "BIG", [0xFF, 0xFE]), + (-2, "int32_t", "LITTLE", [0xFE, 0xFF, 0xFF, 0xFF]), + (-2, "int64_t", "LITTLE", [0xFE] + [0xFF] * 7), + (123.1, "float", "BIG", [0x42, 0xF6, 0x33, 0x33]), + (0.1, "float", "LITTLE", [0xCD, 0xCC, 0xCC, 0x3D]), + (0.1, "double", "LITTLE", [0x9A, 0x99, 0x99, 0x99, 0x99, 0x99, 0xB9, 0x3F]), + (2.5, "double", "BIG", [0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ], +) +def test_value_bytes_match_bytebuffer( + data: float, type_: str, endianness: str, expected: list[int] +) -> None: + config = { + CONF_DATA: data, + CONF_TYPE: type_, + CONF_ENDIANNESS: endianness, + CONF_STRING_ENCODING: "utf_8", + } + assert value_bytes(config) == expected + + +def test_value_bytes_strings_and_lists() -> None: + assert value_bytes({CONF_DATA: "hé", CONF_STRING_ENCODING: "utf_8"}) == [ + 0x68, + 0xC3, + 0xA9, + ] + assert value_bytes({CONF_DATA: [1, 2], CONF_STRING_ENCODING: "utf_8"}) == [1, 2] + + +def test_set_value_constants_share_a_flash_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("set_value.yaml")) + + assert main_cpp.count("ble_server_value[] PROGMEM = {0x12, 0x34};") == 1 + assert main_cpp.count("set_buffer_static(ble_server_value, 2);") == 2 + assert "set_buffer_template(" in main_cpp + assert "ByteBuffer::wrap" not in main_cpp + + +def test_float32_rejects_out_of_range() -> None: + for value in (1e40, -1e40): + with pytest.raises(cv.Invalid, match="out of range for a float"): + float32(value) + assert float32(3.4028235e38) == 3.4028235e38 + assert float32(float("inf")) == float("inf") diff --git a/tests/components/bytebuffer/test_wrap_values.cpp b/tests/components/bytebuffer/test_wrap_values.cpp index 9a470d8a088..bcae7125bf2 100644 --- a/tests/components/bytebuffer/test_wrap_values.cpp +++ b/tests/components/bytebuffer/test_wrap_values.cpp @@ -8,8 +8,7 @@ namespace esphome::bytebuffer { -// The expressions esp32_ble_server emits for typed constant values, and the bytes they produce. -// Doubles are written as float literals, so they carry float precision. +// Typed values as ByteBuffer::wrap packs them; esp32_ble_server's value_bytes() is tested against the same bytes. TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { EXPECT_EQ(ByteBuffer::wrap(uint8_t(18), LITTLE).get_data(), (std::vector{0x12})); EXPECT_EQ(ByteBuffer::wrap(uint16_t(18), LITTLE).get_data(), (std::vector{0x12, 0x00})); @@ -24,9 +23,9 @@ TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { (std::vector{0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff})); EXPECT_EQ(ByteBuffer::wrap(float(123.1f), BIG).get_data(), (std::vector{0x42, 0xf6, 0x33, 0x33})); EXPECT_EQ(ByteBuffer::wrap(float(0.1f), LITTLE).get_data(), (std::vector{0xcd, 0xcc, 0xcc, 0x3d})); - EXPECT_EQ(ByteBuffer::wrap(double(0.1f), LITTLE).get_data(), - (std::vector{0x00, 0x00, 0x00, 0xa0, 0x99, 0x99, 0xb9, 0x3f})); - EXPECT_EQ(ByteBuffer::wrap(double(2.5f), BIG).get_data(), + EXPECT_EQ(ByteBuffer::wrap(double(0.1), LITTLE).get_data(), + (std::vector{0x9a, 0x99, 0x99, 0x99, 0x99, 0x99, 0xb9, 0x3f})); + EXPECT_EQ(ByteBuffer::wrap(double(2.5), BIG).get_data(), (std::vector{0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})); } diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index b513c43234b..01994e2d7a7 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -110,6 +110,13 @@ esp32_ble_server: - ble_server.descriptor.set_value: id: test_change_descriptor value: "Changed" + # Empty constants + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: "" + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: "" # Lambdas that read the trigger argument - ble_server.characteristic.set_value: id: test_change_characteristic From ed6ab4565df661d0f3da70980c23457825ea82cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:12:59 -1000 Subject: [PATCH 320/435] [core] Emit filter, action and condition lists one item at a time (#20116) --- esphome/automation.py | 24 ++++--- esphome/components/api/__init__.py | 2 +- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/sensor/__init__.py | 5 +- esphome/components/sensor/filter.h | 3 + esphome/components/text_sensor/__init__.py | 3 +- esphome/core/base_automation.h | 33 ++++++++-- .../automation/test_condition_groups.py | 2 +- .../sensor/test_timeout_throttle.py | 10 +-- .../template/config/per_item_lists.yaml | 62 +++++++++++++++++++ .../template/test_per_item_lists.py | 30 +++++++++ 11 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/template/config/per_item_lists.yaml create mode 100644 tests/component_tests/template/test_per_item_lists.py diff --git a/esphome/automation.py b/esphome/automation.py index fea5769050e..2f90cbfcbbf 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -724,7 +724,7 @@ async def condition_group_to_code( if len(conditions) == 1: return conditions[0] return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions ) @@ -840,11 +840,13 @@ async def if_action_to_code( condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) if CONF_THEN in config: - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) if has_else: - actions = await build_action_list(config[CONF_ELSE], template_arg, args) - cg.add(var.add_else(actions)) + for action in await build_action_list(config[CONF_ELSE], template_arg, args): + cg.add(var.add_else_action(action)) + cg.add(var.finish_else()) return var @@ -867,8 +869,9 @@ async def while_action_to_code( ) -> MockObj: condition = await build_condition(config[CONF_CONDITION], template_arg, args) var = cg.new_Pvariable(action_id, template_arg, condition) - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -897,7 +900,9 @@ async def repeat_action_to_code( cg.TemplateArguments(cg.uint32, *template_arg.args), [(cg.uint32, "iteration"), *args], ) - cg.add(var.add_then(actions)) + for action in actions: + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -1113,7 +1118,8 @@ async def _new_automation( templ = cg.TemplateArguments(*(arg[0] for arg in args)) obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) + for action in actions: + cg.add(obj.add_action(action)) return obj diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f105c1d7f79..9611228ba1d 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -664,7 +664,7 @@ async def to_code(config: ConfigType) -> None: unregister_id, var, ) - cg.add(auto.add_actions([unregister_action])) + cg.add(auto.add_action(unregister_action)) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) if CORE.is_esp8266 and has_user_actions: diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 60e20aa1935..109a22c7bca 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -607,7 +607,8 @@ async def setup_binary_sensor_core_(var, config): if filters_config := config.get(CONF_FILTERS): cg.add_define("USE_BINARY_SENSOR_FILTER") filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) - cg.add(var.add_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_binary_sensor_automations, var, config) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2b404442a4d..1440e07f763 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -672,7 +672,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), *filters) @FILTER_REGISTRY.register( @@ -1033,7 +1033,8 @@ async def setup_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") filters = await _build_chain_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_sensor_automations, var, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 510ee94c6a7..01949f45df2 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -545,6 +545,9 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, template class OrFilter : public Filter { public: explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } + // Codegen passes each filter as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Fs) == N) explicit OrFilter(Fs *...filters) : filters_{filters...} {} void initialize(Sensor *parent, Filter *next) override { Filter::initialize(parent, next); diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index e9c70f7a2e3..959ce77c7a9 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -208,7 +208,8 @@ async def setup_text_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") filters = await build_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_text_sensor_automations, var, config) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 69def222da2..09cde1261d0 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -22,6 +22,9 @@ template class AndCondition : public Condition explicit AndCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit AndCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -40,6 +43,9 @@ template class OrCondition : public Condition { explicit OrCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit OrCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -67,6 +73,9 @@ template class XorCondition : public Condition explicit XorCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit XorCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -311,10 +320,9 @@ template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} - // Precondition: add_then/add_else must be called at most once per instance. - // Codegen always batches the full action list into a single call. Calling - // twice would re-append the same inline continuation pointer and form a - // self-loop in the next_ chain. + // Precondition: build each branch either with one add_then/add_else call, or with + // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. + // Appending the inline continuation twice forms a self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->then_continuation_); @@ -325,6 +333,13 @@ template class IfAction : public Action { this->else_.add_action(&this->else_continuation_.action); } + // Codegen appends branch actions one at a time, then finishes each branch once; a list literal + // would be a constant array in .rodata, which is RAM on ESP8266. + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->then_continuation_); } + void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } + void finish_else() requires(HasElse) { this->else_.add_action(&this->else_continuation_.action); } + void play_complex(const Ts &...x) override { this->num_running_++; if (this->condition_->check(x...)) { @@ -364,11 +379,14 @@ template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -428,11 +446,14 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py index effe7b5b0bd..5d9f9b828ed 100644 --- a/tests/component_tests/automation/test_condition_groups.py +++ b/tests/component_tests/automation/test_condition_groups.py @@ -15,7 +15,7 @@ def test_single_condition_groups_are_unwrapped( assert "IfAction(lambdacondition_id);" in main_cpp assert "IfAction(lambdacondition_id_2);" in main_cpp group = re.search( - r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + r"new\((\w+)\) AndCondition<2>\(lambdacondition_id_3, lambdacondition_id_4\);", main_cpp, ) assert group is not None diff --git a/tests/component_tests/sensor/test_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py index 8154471e472..ceecd2f1226 100644 --- a/tests/component_tests/sensor/test_timeout_throttle.py +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -15,10 +15,10 @@ def test_timeout_throttle_pair_is_merged( len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp)) == 1 ) - merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp) - assert merged is not None - assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp + merged = re.findall(r"merged->add_filter\((\w+)\);", main_cpp) + assert len(merged) == 1 + assert f"new({merged[0]}) sensor::TimeoutThrottleFilter(1000);" in main_cpp # Every other shape keeps its two filters for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"): - call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp) - assert call is not None and len(call.group(1).split(",")) == 2, sensor_id + calls = re.findall(rf"{sensor_id}->add_filter\(\w+\);", main_cpp) + assert len(calls) == 2, sensor_id diff --git a/tests/component_tests/template/config/per_item_lists.yaml b/tests/component_tests/template/config/per_item_lists.yaml new file mode 100644 index 00000000000..89f1a80ce2d --- /dev/null +++ b/tests/component_tests/template/config/per_item_lists.yaml @@ -0,0 +1,62 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +globals: + - id: counter + type: int + initial_value: "0" + +sensor: + - platform: template + id: temp + lambda: return 1.0; + filters: + - multiply: 2.0 + - offset: 1.0 + - or: + - throttle: 2s + - delta: 1.0 + +binary_sensor: + - platform: template + id: button + lambda: return true; + filters: + - invert: + - delayed_on: 10ms + +text_sensor: + - platform: template + id: label + lambda: return {"x"}; + filters: + - to_upper: + - append: "!" + +interval: + - interval: 10s + then: + - if: + condition: + and: + - lambda: return id(counter) == 0; + - binary_sensor.is_on: button + then: + - lambda: id(counter) += 1; + - logger.log: even + else: + - logger.log: odd + - repeat: + count: 2 + then: + - logger.log: again + - while: + condition: + lambda: return id(counter) < 3; + then: + - lambda: id(counter) += 1; diff --git a/tests/component_tests/template/test_per_item_lists.py b/tests/component_tests/template/test_per_item_lists.py new file mode 100644 index 00000000000..adf1498e37e --- /dev/null +++ b/tests/component_tests/template/test_per_item_lists.py @@ -0,0 +1,30 @@ +"""Filters and action lists are added one item at a time, so no pointer list lands in .rodata.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_filters_and_actions_are_added_per_item( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("per_item_lists.yaml")) + + for list_call in ( + "add_filters(", + "set_filters(", + "add_actions(", + "add_then(", + "add_else(", + ): + assert list_call not in main_cpp + assert len(re.findall(r"temp->add_filter\(", main_cpp)) == 3 + assert len(re.findall(r"button->add_filter\(", main_cpp)) == 2 + assert len(re.findall(r"label->add_filter\(", main_cpp)) == 2 + assert main_cpp.count("->add_then_action(") == 4 + assert main_cpp.count("->add_else_action(") == 1 + assert main_cpp.count("->finish_then();") == 3 + assert main_cpp.count("->finish_else();") == 1 + # and/or conditions and the sensor `or` filter take their items as separate arguments + assert not re.search(r"(Condition|OrFilter)<[^>]*>\(\{", main_cpp) From 0cd239dfd1176026963bf04fa81989a59f31dce7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:16:34 -1000 Subject: [PATCH 321/435] [uart] Keep constant switch, button and write payloads in flash (#20082) --- esphome/components/uart/__init__.py | 21 +++++------- esphome/components/uart/automation.h | 29 +++------------- esphome/components/uart/button/__init__.py | 10 +++--- .../components/uart/button/uart_button.cpp | 2 +- esphome/components/uart/button/uart_button.h | 11 +++--- esphome/components/uart/switch/__init__.py | 34 ++++++++----------- .../components/uart/switch/uart_switch.cpp | 8 ++--- esphome/components/uart/switch/uart_switch.h | 19 +++++++---- esphome/components/uart/uart.h | 1 + esphome/components/uart/uart_component.cpp | 13 +++++++ esphome/components/uart/uart_component.h | 7 ++++ tests/component_tests/uart/__init__.py | 0 .../uart/config/payload_tables.yaml | 34 +++++++++++++++++++ .../uart/test_payload_tables.py | 28 +++++++++++++++ 14 files changed, 137 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/uart/__init__.py create mode 100644 tests/component_tests/uart/config/payload_tables.yaml create mode 100644 tests/component_tests/uart/test_payload_tables.py diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 5eea8060ff9..d1ac806b5fd 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -137,6 +137,10 @@ def validate_raw_data(value): ) +# Lengths are stored as uint16_t; reject longer payloads at validation. +validate_raw_payload = cv.All(validate_raw_data, cv.Length(max=65535)) + + def validate_rx_pin(value): value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: @@ -596,7 +600,7 @@ async def register_uart_device(var, config): cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(UARTComponent), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_raw_payload), }, key=CONF_DATA, ), @@ -605,18 +609,9 @@ async def register_uart_device(var, config): async def uart_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static, "uart_data" + ) return var diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index e5a9fa7c7bc..b134c8af9f6 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -8,36 +8,15 @@ namespace esphome::uart { template class UARTWriteAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers - this->code_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - // Store pointer to static data in flash (no RAM copy) - void set_data_static(const uint8_t *data, size_t len) { - this->code_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->len_ >= 0) { - // Static mode: use pointer and length - this->parent_->write_array(this->code_.data, static_cast(this->len_)); + if (this->data_.is_static()) { + this->parent_->write_array_progmem(this->data_.data(), this->data_.size()); } else { - // Template mode: call function - auto val = this->code_.func(x...); - this->parent_->write_array(val); + this->parent_->write_array(this->data_.value(x...)); } } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Code { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } code_; }; } // namespace esphome::uart diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 95fe21271db..821d8264dc4 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import button, uart import esphome.config_validation as cv from esphome.const import CONF_DATA -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload CODEOWNERS = ["@ssieb"] @@ -17,7 +17,7 @@ CONFIG_SCHEMA = ( button.button_schema(UARTButton) .extend( { - cv.Required(CONF_DATA): validate_raw_data, + cv.Required(CONF_DATA): validate_raw_payload, } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -31,6 +31,4 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data(cg.ArrayInitializer(*data))) + cg.add(var.set_data(automation.progmem_bytes("uart_data", data), len(data))) diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index b55207cb46e..99b5ee548d0 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -7,7 +7,7 @@ ESPHOME_LOG_TAG(TAG, "uart.button"); void UARTButton::press_action() { ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); - this->write_array(this->data_.data(), this->data_.size()); + this->write_array_progmem(this->data_, this->data_len_); } void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 47f45d4899c..903f945fd30 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -4,20 +4,21 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/button/button.h" -#include - namespace esphome::uart { class UARTButton final : public button::Button, public UARTDevice, public Component { public: - void set_data(std::vector &&data) { this->data_ = std::move(data); } - void set_data(std::initializer_list data) { this->data_ = std::vector(data); } + void set_data(const uint8_t *data, uint16_t len) { + this->data_ = data; + this->data_len_ = len; + } void dump_config() override; protected: void press_action() override; - std::vector data_; + const uint8_t *data_{nullptr}; + uint16_t data_len_{0}; }; } // namespace esphome::uart diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index 290bbed5d30..3288ceeee20 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import switch, uart import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_SEND_EVERY -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload DEPENDENCIES = ["uart"] @@ -18,11 +18,11 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_DATA): cv.Any( - validate_raw_data, + validate_raw_payload, cv.Schema( { - cv.Optional(CONF_TURN_OFF): validate_raw_data, - cv.Optional(CONF_TURN_ON): validate_raw_data, + cv.Optional(CONF_TURN_OFF): validate_raw_payload, + cv.Optional(CONF_TURN_ON): validate_raw_payload, } ), ), @@ -40,20 +40,16 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, dict): - if data_on := data.get(CONF_TURN_ON): - if isinstance(data_on, bytes): - data_on = [HexInt(x) for x in data_on] - cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) - if data_off := data.get(CONF_TURN_OFF): - if isinstance(data_off, bytes): - data_off = [HexInt(x) for x in data_off] - cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) - else: - data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data_on(cg.ArrayInitializer(*data))) + single_state = not isinstance(data, dict) + if single_state: + data = {CONF_TURN_ON: data} + for key, setter in ( + (CONF_TURN_ON, var.set_data_on), + (CONF_TURN_OFF, var.set_data_off), + ): + if payload := data.get(key): + cg.add(setter(automation.progmem_bytes("uart_data", payload), len(payload))) + if single_state: cg.add(var.set_single_state(True)) if CONF_SEND_EVERY in config: cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 6b6aa273f7a..1af497ffcfd 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -17,13 +17,13 @@ void UARTSwitch::loop() { } void UARTSwitch::write_command_(bool state) { - if (state && !this->data_on_.empty()) { + if (state && this->data_on_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending on data", this->get_name().c_str()); - this->write_array(this->data_on_.data(), this->data_on_.size()); + this->write_array_progmem(this->data_on_, this->data_on_len_); } - if (!state && !this->data_off_.empty()) { + if (!state && this->data_off_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending off data", this->get_name().c_str()); - this->write_array(this->data_off_.data(), this->data_off_.size()); + this->write_array_progmem(this->data_off_, this->data_off_len_); } } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index c924c7d4e54..96d8833d7a8 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -5,7 +5,6 @@ #include "esphome/components/switch/switch.h" #include -#include namespace esphome::uart { @@ -13,10 +12,14 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo public: void loop() override; - void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } - void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } - void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } - void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } + void set_data_on(const uint8_t *data, uint16_t len) { + this->data_on_ = data; + this->data_on_len_ = len; + } + void set_data_off(const uint8_t *data, uint16_t len) { + this->data_off_ = data; + this->data_off_len_ = len; + } void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } void set_single_state(bool single) { this->single_state_ = single; } @@ -25,8 +28,10 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo protected: void write_command_(bool state); void write_state(bool state) override; - std::vector data_on_; - std::vector data_off_; + const uint8_t *data_on_{nullptr}; + const uint8_t *data_off_{nullptr}; + uint16_t data_on_len_{0}; + uint16_t data_off_len_{0}; bool single_state_{false}; uint32_t send_every_; uint32_t last_transmission_; diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index d1c67733634..7a34a20583e 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -25,6 +25,7 @@ class UARTDevice { size_t get_rx_timeout() { return this->parent_->get_rx_timeout(); } void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } + void write_array_progmem(const uint8_t *data, size_t len) { this->parent_->write_array_progmem(data, len); } void write_array(const std::vector &data) { this->parent_->write_array(data); } template void write_array(const std::array &data) { this->parent_->write_array(data.data(), data.size()); diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 82bc5927024..e77e37cd089 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -46,4 +46,17 @@ void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { this->set_rx_full_threshold(val); } +#ifdef USE_ESP8266 +void UARTComponent::write_array_progmem(const uint8_t *data, size_t len) { + uint8_t buf[32]; + while (len > 0) { + const size_t chunk = std::min(len, sizeof(buf)); + progmem_memcpy(buf, data, chunk); + this->write_array(buf, chunk); + data += chunk; + len -= chunk; + } +} +#endif + } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 6d446fd6a6f..14709d44906 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -65,6 +65,13 @@ class UARTComponent { // @param len Length of the array. virtual void write_array(const uint8_t *data, size_t len) = 0; + // Writes bytes that may live in PROGMEM; ESP8266 cannot read flash bytewise, so it copies chunks. +#ifdef USE_ESP8266 + void write_array_progmem(const uint8_t *data, size_t len); +#else + void write_array_progmem(const uint8_t *data, size_t len) { this->write_array(data, len); } +#endif + // Reads a single byte from the UART bus. // @param data Pointer to the byte where the read data will be stored. // @return True if a byte was successfully read, false otherwise. diff --git a/tests/component_tests/uart/__init__.py b/tests/component_tests/uart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/uart/config/payload_tables.yaml b/tests/component_tests/uart/config/payload_tables.yaml new file mode 100644 index 00000000000..e93ab4754d0 --- /dev/null +++ b/tests/component_tests/uart/config/payload_tables.yaml @@ -0,0 +1,34 @@ +--- +esphome: + name: test + on_boot: + then: + - uart.write: [0x01, 0x02, 0x03] + - uart.write: !lambda return {0x09}; + - uart.write: "" + +esp32: + board: esp32dev + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 9600 + +switch: + - platform: uart + id: shared_switch + name: Shared Switch + data: [0x01, 0x02, 0x03] + - platform: uart + id: on_off_switch + name: On Off Switch + data: + turn_on: "ON" + turn_off: [0x04] + +button: + - platform: uart + id: shared_button + name: Shared Button + data: [0x01, 0x02, 0x03] diff --git a/tests/component_tests/uart/test_payload_tables.py b/tests/component_tests/uart/test_payload_tables.py new file mode 100644 index 00000000000..2ba341136a7 --- /dev/null +++ b/tests/component_tests/uart/test_payload_tables.py @@ -0,0 +1,28 @@ +"""Tests for UART constant payloads in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_payloads_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal payloads share one table across switch, button and write action.""" + main_cpp = generate_main(component_config_path("payload_tables.yaml")) + + tables = dict( + re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + ) + shared = next(k for k, v in tables.items() if v == "{0x01, 0x02, 0x03}") + assert sorted(tables.values()) == sorted( + ["{0x01, 0x02, 0x03}", "{0x4F, 0x4E}", "{0x04}"] + ) + assert f"shared_switch->set_data_on({shared}, 3);" in main_cpp + assert f"shared_button->set_data({shared}, 3);" in main_cpp + assert f"set_data_static({shared}, 3);" in main_cpp + assert "set_data_static(nullptr, 0);" in main_cpp + assert "set_data_template(" in main_cpp From 4ba5afaa1b99e514ade6f099b36774fb159f7118 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:37:17 +1100 Subject: [PATCH 322/435] [core] Move CONF_ON_STOP to the shared component constants (#20548) Co-authored-by: Claude Opus 5.5 --- esphome/components/const/__init__.py | 1 + esphome/components/improv_ble/__init__.py | 2 +- esphome/components/lvgl/animation.py | 3 +-- esphome/components/lvgl/defines.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index c44a323f65d..78778553aab 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -50,6 +50,7 @@ CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" +CONF_ON_STOP = "on_stop" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index 9dfc700f748..e7d4be8cc3d 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor, improv_base, output +from esphome.components.const import CONF_ON_STOP import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, PLATFORM_ESP32 from esphome.core import CORE @@ -38,7 +39,6 @@ CONF_BLE_SERVER_ID = "ble_server_id" CONF_IDENTIFY_DURATION = "identify_duration" CONF_ON_PROVISIONED = "on_provisioned" CONF_ON_PROVISIONING = "on_provisioning" -CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index bab413f04d7..f1c3887d5c2 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -15,11 +15,10 @@ from esphome.const import ( ) from esphome.cpp_generator import MockObj, TemplateArguments -from ..const import CONF_LOOP +from ..const import CONF_LOOP, CONF_ON_STOP from .defines import ( CONF_AUTO_START, CONF_LVGL_ID, - CONF_ON_STOP, CONF_WIDGETS, LValidator, add_define, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 854ef0fb494..2858b41f719 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -788,7 +788,6 @@ CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" -CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" From c38674bb7bf08cf9fcd1f471c01350592763cd9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:53:55 -1000 Subject: [PATCH 323/435] [climate] Build the on_state and on_control triggers with build_callback_automation (#19611) --- esphome/components/climate/__init__.py | 43 ++++++------------- esphome/components/climate/automation.h | 22 ---------- tests/components/climate/common-triggers.yaml | 25 +++++++++++ .../climate/test-triggers.esp32-idf.yaml | 3 ++ 4 files changed, 41 insertions(+), 52 deletions(-) delete mode 100644 esphome/components/climate/automation.h create mode 100644 tests/components/climate/common-triggers.yaml create mode 100644 tests/components/climate/test-triggers.esp32-idf.yaml diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 17aeb1467d3..a59c03ed1e5 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -46,7 +46,6 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC, CONF_TARGET_TEMPERATURE_STATE_TOPIC, CONF_TEMPERATURE_STEP, - CONF_TRIGGER_ID, CONF_VISUAL, CONF_WEB_SERVER, ) @@ -164,14 +163,6 @@ def visual_temperature_step(value: Any) -> ConfigType: ) -# Actions -StateTrigger = climate_ns.class_( - "StateTrigger", automation.Trigger.template(Climate.operator("ref")) -) -ControlTrigger = climate_ns.class_( - "ControlTrigger", automation.Trigger.template(ClimateCall.operator("ref")) -) - _CLIMATE_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -250,16 +241,8 @@ _CLIMATE_SCHEMA = ( cv.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): cv.All( cv.requires_component("mqtt"), cv.publish_topic ), - cv.Optional(CONF_ON_CONTROL): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ControlTrigger), - } - ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger), - } - ), + cv.Optional(CONF_ON_CONTROL): automation.validate_automation(), + cv.Optional(CONF_ON_STATE): automation.validate_automation(), } ) ) @@ -288,6 +271,16 @@ def climate_schema( return _CLIMATE_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", [(Climate.operator("ref"), "x")] + ), + automation.CallbackAutomation( + CONF_ON_CONTROL, "add_on_control_callback", [(ClimateCall.operator("ref"), "x")] + ), +) + + @setup_entity("climate") async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: visual = config.get(CONF_VISUAL, {}) @@ -443,17 +436,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: ) ) - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(Climate.operator("ref"), "x")], conf - ) - - for conf in config.get(CONF_ON_CONTROL, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(ClimateCall.operator("ref"), "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h deleted file mode 100644 index d6aba02f920..00000000000 --- a/esphome/components/climate/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "climate.h" - -namespace esphome::climate { - -class ControlTrigger final : public Trigger { - public: - ControlTrigger(Climate *climate) { - climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); - } -}; - -class StateTrigger final : public Trigger { - public: - StateTrigger(Climate *climate) { - climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); - } -}; - -} // namespace esphome::climate diff --git a/tests/components/climate/common-triggers.yaml b/tests/components/climate/common-triggers.yaml new file mode 100644 index 00000000000..b79d871f0ff --- /dev/null +++ b/tests/components/climate/common-triggers.yaml @@ -0,0 +1,25 @@ +# on_state and on_control on a thermostat. +sensor: + - platform: template + id: climate_triggers_temperature + +climate: + - platform: thermostat + name: Climate Triggers + id: climate_triggers + sensor: climate_triggers_temperature + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + on_state: + - logger.log: + format: "Climate mode %d" + args: [x.mode] + on_control: + - lambda: |- + if (x.get_mode().has_value()) + ESP_LOGD("test", "Control mode %d", *x.get_mode()); diff --git a/tests/components/climate/test-triggers.esp32-idf.yaml b/tests/components/climate/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..bbf01242bd3 --- /dev/null +++ b/tests/components/climate/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + climate: !include common.yaml + triggers: !include common-triggers.yaml From 028db614cab6232e0c60a3c7d3a1f2c6d01c95e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:54:09 -1000 Subject: [PATCH 324/435] [select] Build the on_value trigger with build_callback_automation (#19609) --- esphome/components/select/__init__.py | 26 ++++++++----------- esphome/components/select/automation.h | 11 -------- tests/components/select/common-triggers.yaml | 14 ++++++++++ .../select/test-triggers.esp32-idf.yaml | 3 +++ 4 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 tests/components/select/common-triggers.yaml create mode 100644 tests/components/select/test-triggers.esp32-idf.yaml diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 07f86c1330f..7b9f616460b 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_OPERATION, CONF_OPTION, CONF_OPTIONS, - CONF_TRIGGER_ID, CONF_WEB_SERVER, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority @@ -37,12 +36,6 @@ select_ns = cg.esphome_ns.namespace("select") Select = select_ns.class_("Select", cg.EntityBase) SelectPtr = Select.operator("ptr") -# Triggers -SelectStateTrigger = select_ns.class_( - "SelectStateTrigger", - automation.Trigger.template(cg.StringRef, cg.size_t), -) - # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -63,11 +56,7 @@ _SELECT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSelectComponent), cv.GenerateID(): cv.declare_id(Select), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SelectStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), } ) ) @@ -101,9 +90,16 @@ async def setup_select_core_(var, config, *, options: list[str]): cg.add(var.traits.set_options_static(table, len(options))) for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.StringRef, "x"), (cg.size_t, "i")], conf + # The callback carries the index; the automation gets the option text as well. + parent = automation.parent_ref(var) + index = cg.RawExpression("index") + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.StringRef, "x"), (cg.size_t, "i")], + conf, + params=[(cg.size_t, "index")], + forward=[cg.StringRef(parent.option_at(index)), index], ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index c2dc752ca5a..9bef819e6d0 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,17 +6,6 @@ namespace esphome::select { -class SelectStateTrigger final : public Trigger { - public: - explicit SelectStateTrigger(Select *parent) : parent_(parent) { - parent->add_on_state_callback( - [this](size_t index) { this->trigger(StringRef(this->parent_->option_at(index)), index); }); - } - - protected: - Select *parent_; -}; - template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} diff --git a/tests/components/select/common-triggers.yaml b/tests/components/select/common-triggers.yaml new file mode 100644 index 00000000000..bc3510950c1 --- /dev/null +++ b/tests/components/select/common-triggers.yaml @@ -0,0 +1,14 @@ +# on_value with both trigger arguments. +select: + - platform: template + name: Select Triggers + id: select_triggers + optimistic: true + options: + - one + - two + - three + on_value: + - logger.log: + format: "Selected %s at %zu" + args: [x.c_str(), i] diff --git a/tests/components/select/test-triggers.esp32-idf.yaml b/tests/components/select/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..17b435f5a27 --- /dev/null +++ b/tests/components/select/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + select: !include common.yaml + triggers: !include common-triggers.yaml From 95de93bebb427baf254ad5c3b73231e1ecd8628c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:54:25 -1000 Subject: [PATCH 325/435] [fan] Build the on_state trigger with build_parent_callback_automation (#19612) --- esphome/components/fan/__init__.py | 44 +++++++------------ esphome/components/fan/automation.h | 10 ----- tests/components/fan/common-triggers.yaml | 35 +++++++++++++++ .../fan/test-triggers.esp8266-ard.yaml | 3 ++ 4 files changed, 54 insertions(+), 38 deletions(-) create mode 100644 tests/components/fan/common-triggers.yaml create mode 100644 tests/components/fan/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index ac5469fc42c..cb54e89c3ca 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -64,9 +64,6 @@ RESTORE_MODES = { # Actions CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) -FanStateTrigger = fan_ns.class_( - "FanStateTrigger", automation.Trigger.template(Fan.operator("ptr")) -) FanTurnOnTrigger = fan_ns.class_("FanTurnOnTrigger", automation.Trigger.template()) FanTurnOffTrigger = fan_ns.class_("FanTurnOffTrigger", automation.Trigger.template()) FanDirectionSetTrigger = fan_ns.class_( @@ -115,11 +112,7 @@ _FAN_SCHEMA = ( cv.Optional(CONF_SPEED_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanStateTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation(), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanTurnOnTrigger), @@ -221,6 +214,17 @@ def validate_preset_modes(value): return value +# The edge and set triggers keep their classes; each tracks the previous value. +_TRIGGER_AUTOMATIONS = ( + (CONF_ON_TURN_ON, []), + (CONF_ON_TURN_OFF, []), + (CONF_ON_DIRECTION_SET, [(FanDirection, "x")]), + (CONF_ON_OSCILLATING_SET, [(cg.bool_, "x")]), + (CONF_ON_SPEED_SET, [(cg.int_, "x")]), + (CONF_ON_PRESET_SET, [(cg.StringRef, "x")]), +) + + @setup_entity("fan") async def setup_fan_core_(var, config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) @@ -266,26 +270,10 @@ async def setup_fan_core_(var, config): await web_server.add_entity_config(var, web_server_config) for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(Fan.operator("ptr"), "x")], conf) - for conf in config.get(CONF_ON_TURN_ON, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_TURN_OFF, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_DIRECTION_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(FanDirection, "x")], conf) - for conf in config.get(CONF_ON_OSCILLATING_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.bool_, "x")], conf) - for conf in config.get(CONF_ON_SPEED_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.int_, "x")], conf) - for conf in config.get(CONF_ON_PRESET_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.StringRef, "x")], conf) + await automation.build_parent_callback_automation( + var, "add_on_state_callback", (Fan.operator("ptr"), "x"), conf + ) + await automation.build_trigger_automations(var, config, _TRIGGER_AUTOMATIONS) async def register_fan(var, config): diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 4c587904b73..e5ce09172b7 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -50,16 +50,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -class FanStateTrigger final : public Trigger { - public: - FanStateTrigger(Fan *state) : fan_(state) { - state->add_on_state_callback([this]() { this->trigger(this->fan_); }); - } - - protected: - Fan *fan_; -}; - class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { diff --git a/tests/components/fan/common-triggers.yaml b/tests/components/fan/common-triggers.yaml new file mode 100644 index 00000000000..d64f7b5e829 --- /dev/null +++ b/tests/components/fan/common-triggers.yaml @@ -0,0 +1,35 @@ +# on_state with the fan as trigger argument, plus the edge and set triggers. +fan: + - platform: template + name: Fan Triggers + id: fan_triggers + preset_modes: + - Eco + - Turbo + has_oscillating: true + has_direction: true + speed_count: 3 + on_state: + - logger.log: + format: "Fan is %s" + args: ['x->state ? "on" : "off"'] + on_turn_on: + - logger.log: Fan turned on + on_turn_off: + - logger.log: Fan turned off + on_direction_set: + - logger.log: + format: "Direction %d" + args: ["(int) x"] + on_oscillating_set: + - logger.log: + format: "Oscillating %d" + args: ["x"] + on_speed_set: + - logger.log: + format: "Speed %d" + args: ["x"] + on_preset_set: + - logger.log: + format: "Preset %s" + args: ["x.c_str()"] diff --git a/tests/components/fan/test-triggers.esp8266-ard.yaml b/tests/components/fan/test-triggers.esp8266-ard.yaml new file mode 100644 index 00000000000..0cfba2d491a --- /dev/null +++ b/tests/components/fan/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + fan: !include common.yaml + triggers: !include common-triggers.yaml From fa4bc28d3e442363e016136d070d66760ec2c92e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:54:44 -1000 Subject: [PATCH 326/435] [text] Build the on_value trigger with build_callback_automation (#19613) --- esphome/components/text/__init__.py | 17 ++++------------- esphome/components/text/automation.h | 16 ---------------- tests/components/text/common-triggers.yaml | 11 +++++++++++ .../text/test-triggers.esp8266-ard.yaml | 3 +++ 4 files changed, 18 insertions(+), 29 deletions(-) delete mode 100644 esphome/components/text/automation.h create mode 100644 tests/components/text/common-triggers.yaml create mode 100644 tests/components/text/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index fbbc09bd2d1..243185b674d 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_MODE, CONF_MQTT_ID, CONF_ON_VALUE, - CONF_TRIGGER_ID, CONF_VALUE, CONF_WEB_SERVER, ) @@ -30,11 +29,6 @@ text_ns = cg.esphome_ns.namespace("text") Text = text_ns.class_("Text", cg.EntityBase) TextPtr = Text.operator("ptr") -# Triggers -TextStateTrigger = text_ns.class_( - "TextStateTrigger", automation.Trigger.template(cg.std_string) -) - # Conditions TextMode = text_ns.enum("TextMode") @@ -50,11 +44,7 @@ _TEXT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextComponent), cv.GenerateID(): cv.declare_id(Text), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TextStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), cv.Required(CONF_MODE): cv.enum(TEXT_MODES, upper=True), } ) @@ -104,8 +94,9 @@ async def setup_text_core_( cg.add(var.traits.set_mode(config[CONF_MODE])) for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(cg.std_string, "x")], conf + ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h deleted file mode 100644 index bd6fb3cd23c..00000000000 --- a/esphome/components/text/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "text.h" - -namespace esphome::text { - -class TextStateTrigger final : public Trigger { - public: - explicit TextStateTrigger(Text *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -} // namespace esphome::text diff --git a/tests/components/text/common-triggers.yaml b/tests/components/text/common-triggers.yaml new file mode 100644 index 00000000000..585cb30c2de --- /dev/null +++ b/tests/components/text/common-triggers.yaml @@ -0,0 +1,11 @@ +# on_value with the new text as trigger argument. +text: + - platform: template + name: Text Triggers + id: text_triggers + optimistic: true + mode: text + on_value: + - logger.log: + format: "Text is %s" + args: ["x.c_str()"] diff --git a/tests/components/text/test-triggers.esp8266-ard.yaml b/tests/components/text/test-triggers.esp8266-ard.yaml new file mode 100644 index 00000000000..aecb27e6023 --- /dev/null +++ b/tests/components/text/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + text: !include common.yaml + triggers: !include common-triggers.yaml From c6ed2069a566ea8f92b94828d0e2635718b342ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:55:51 -1000 Subject: [PATCH 327/435] [json][web_server] Link strings that outlive the document instead of copying them (#19380) --- esphome/components/json/json_util.h | 12 ++ .../components/light/light_json_schema.cpp | 4 +- esphome/components/web_server/web_server.cpp | 108 +++++++++++------- .../web_server_idf/web_server_idf.cpp | 2 +- esphome/core/string_ref.h | 6 + 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index be12d0aa38a..9b4cc53a2e8 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -8,6 +8,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #define ARDUINOJSON_ENABLE_STD_STRING 1 // NOLINT @@ -17,6 +18,17 @@ namespace esphome::json { +// Link a string into the document instead of copying it. Only for a literal, a codegen table +// entry or a name set once during setup; never a stack buffer or an entity state. +inline JsonString linked(const char *s) { return JsonString(s, true); } +inline JsonString linked(const char *s, size_t len) { return JsonString(s, len, true); } +inline JsonString linked(const std::string &s) { return JsonString(s.c_str(), s.size(), true); } +JsonString linked(std::string &&) = delete; // a temporary dies before the document is serialized +#ifdef USE_ESP8266 +// A PROGMEM string is copied out of flash +inline ProgmemStr linked(ProgmemStr s) { return s; } +#endif + /// Buffer for JSON serialization that uses stack allocation for small payloads. /// Template parameter STACK_SIZE specifies the stack buffer size (default 512 bytes). /// Supports move semantics for efficient return-by-value. diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 98df98ce5d6..40e9c0b1aaf 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -27,7 +27,7 @@ static ProgmemStr get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root[ESPHOME_F("effect")] = state.get_effect_name().c_str(); + root[ESPHOME_F("effect")] = json::linked(state.get_effect_name()); root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); root[ESPHOME_F("effect_count")] = state.get_effect_count(); } @@ -37,7 +37,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { const auto color_mode = values.get_color_mode(); const auto *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { - root[ESPHOME_F("color_mode")] = mode_str; + root[ESPHOME_F("color_mode")] = json::linked(mode_str); } if (color_mode & ColorCapability::ON_OFF) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 323857c37e8..5fa3ea054b5 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -57,8 +57,25 @@ namespace esphome::web_server { ESPHOME_LOG_TAG(TAG, "web_server"); +using json::linked; + +#ifdef USE_STORE_LOG_STR_IN_FLASH // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +#else +// Without the define a LogString is in RAM and can be linked +[[maybe_unused]] static JsonString json_state_str(const LogString *s) { return linked(LOG_STR_ARG(s)); } +#endif + +// get_icon_to() and get_device_class_to() return a table pointer, except on ESP8266 where they +// copy out of PROGMEM into the caller's buffer +[[maybe_unused]] static JsonString json_table_str(const char *s) { +#ifdef USE_ESP8266 + return JsonString(s); +#else + return linked(s); +#endif +} // One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the // table needs no RAM on ESP8266. @@ -348,7 +365,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("name")] = linked(group.second.name); root[ESPHOME_F("sorting_weight")] = group.second.weight; auto group_msg = builder.serialize(); @@ -380,7 +397,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); + root[ESPHOME_F("title")] = linked(App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name()); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); root[ESPHOME_F("comment")] = static_cast(comment_buffer); @@ -616,17 +633,16 @@ static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, Js root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("domain")] = prefix; - // Use .c_str() to avoid instantiating set template (saves ~24B) - root[ESPHOME_F("name")] = name.c_str(); + root[ESPHOME_F("domain")] = linked(prefix, prefix_len); + root[ESPHOME_F("name")] = linked(name); #ifdef USE_DEVICES if (device_name) { - root[ESPHOME_F("device")] = device_name; + root[ESPHOME_F("device")] = linked(device_name, device_len); } #endif #ifdef USE_ENTITY_ICON char icon_buf[MAX_ICON_LENGTH]; - root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf); + root[ESPHOME_F("icon")] = json_table_str(obj->get_icon_to(icon_buf)); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); @@ -695,7 +711,7 @@ void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_ if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); } } #endif @@ -734,7 +750,9 @@ void WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::strin json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "text_sensor", value.c_str(), value.c_str(), start_config); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString state(value.c_str(), value.size()); + set_json_icon_state_value(root, obj, "text_sensor", state, state, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1041,9 +1059,9 @@ void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, jso light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("effects")].to(); - opt.add("None"); + opt.add(linked("None")); for (auto const &option : obj->get_effects()) { - opt.add(option->get_name()); + opt.add(linked(option->get_name())); } this->add_sorting_info_(root, obj); } @@ -1109,7 +1127,7 @@ void WebServer::cover_all_json_generator(WebServer *web_server, void *source, js void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "cover", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); @@ -1173,9 +1191,14 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ // Need two buffers: one for value, one for state with UOM char val_buf[VALUE_ACCURACY_MAX_LEN]; char state_buf[VALUE_ACCURACY_MAX_LEN]; - const char *val_str = std::isnan(value) ? "\"NaN\"" : (value_accuracy_to_buf(val_buf, value, accuracy), val_buf); - const char *state_str = - std::isnan(value) ? "NA" : (value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref), state_buf); + JsonString val_str = linked("\"NaN\""); + JsonString state_str = linked("NA"); + if (!std::isnan(value)) { + value_accuracy_to_buf(val_buf, value, accuracy); + value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref); + val_str = JsonString(val_buf); + state_str = JsonString(state_buf); + } set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { // ArduinoJson copies the string immediately, so we can reuse val_buf @@ -1184,7 +1207,7 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ root[ESPHOME_F("step")] = (value_accuracy_to_buf(val_buf, obj->traits.get_step(), accuracy), val_buf); root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); this->add_sorting_info_(root, obj); } } @@ -1412,14 +1435,16 @@ void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail json::JsonBuilder &builder) { JsonObject root = builder.root(); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString copied_value(value.c_str(), value.size()); // A password entity shows the mask and prefills the input with nothing, so the secret never // reaches the JSON and the mask cannot be written back as the value const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; - set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), - start_config); + set_json_icon_state_value(root, obj, "text", password ? linked("********") : copied_value, + password ? linked("") : copied_value, start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); - root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); + root[ESPHOME_F("pattern")] = linked(obj->traits.get_pattern_c_str()); if (start_config == DETAIL_ALL) { root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); @@ -1475,12 +1500,12 @@ void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail st json::JsonBuilder &builder) { JsonObject root = builder.root(); - // value points to null-terminated string literals from codegen (via current_option()) - set_json_icon_state_value(root, obj, "select", value.c_str(), value.c_str(), start_config); + const JsonString state = linked(value); + set_json_icon_state_value(root, obj, "select", state, state, start_config); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { - opt.add(option); + opt.add(linked(option)); } this->add_sorting_info_(root, obj); } @@ -1574,7 +1599,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_fan_modes().empty()) { JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - opt.add(custom_fan_mode); + opt.add(linked(custom_fan_mode)); } if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); @@ -1589,7 +1614,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) - opt.add(custom_preset); + opt.add(linked(custom_preset)); } root[ESPHOME_F("max_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_max_temperature(), target_accuracy), temp_buf); @@ -1611,13 +1636,13 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = linked(obj->get_custom_fan_mode()); } if (traits.get_supports_presets() && obj->preset.has_value()) { root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = linked(obj->get_custom_preset()); } if (traits.get_supports_swing_modes()) { root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); @@ -1788,7 +1813,7 @@ void WebServer::valve_all_json_generator(WebServer *web_server, void *source, js void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "valve", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); @@ -1936,9 +1961,8 @@ void WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail st JsonObject root = builder.root(); const auto mode = obj->get_mode(); - ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); - - set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); + set_json_icon_state_value(root, obj, "water_heater", json_state_str(water_heater::water_heater_mode_to_string(mode)), + mode, start_config); auto traits = obj->get_traits(); @@ -2209,15 +2233,15 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail set_json_id(root, obj, "event", start_config); if (!event_type.empty()) { - root[ESPHOME_F("event_type")] = event_type; + root[ESPHOME_F("event_type")] = linked(event_type); } if (start_config == DETAIL_ALL) { JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { - event_types.add(event_type); + event_types.add(linked(event_type)); } char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); + root[ESPHOME_F("device_class")] = json_table_str(obj->get_device_class_to(dc_buf)); this->add_sorting_info_(root, obj); } } @@ -2265,17 +2289,17 @@ void WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config, // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson JsonObject root = builder.root(); + // Not linked: the main loop can rewrite update_info before the document is serialized + const auto &info = obj->update_info; set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version.c_str(), start_config); + info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("current_version")] = obj->update_info.current_version; - root[ESPHOME_F("title")] = obj->update_info.title; - // Truncate long changelogs — full text available via release_url + root[ESPHOME_F("current_version")] = info.current_version; + root[ESPHOME_F("title")] = info.title; + // Truncate long changelogs, the full text is available via release_url constexpr size_t max_summary_len = 256; - root[ESPHOME_F("summary")] = obj->update_info.summary.size() <= max_summary_len - ? obj->update_info.summary - : obj->update_info.summary.substr(0, max_summary_len); - root[ESPHOME_F("release_url")] = obj->update_info.release_url; + root[ESPHOME_F("summary")] = JsonString(info.summary.c_str(), std::min(info.summary.size(), max_summary_len)); + root[ESPHOME_F("release_url")] = info.release_url; this->add_sorting_info_(root, obj); } @@ -2610,7 +2634,7 @@ void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { - root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = linked(this->sorting_groups_[this->sorting_entitys_[entity].group_id].name); } } #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9a6e419e439..953351f259b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -850,7 +850,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; + root["name"] = json::linked(group.second.name); root["sorting_weight"] = group.second.weight; message = builder.serialize(); diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 4c0ce28a5ac..14275412294 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -272,6 +272,7 @@ inline double stod(const StringRef &str, size_t *pos = nullptr) { // NOLINTEND(readability-identifier-naming,google-runtime-int,readability-non-const-parameter) #ifdef USE_JSON +// A StringRef may view a scratch buffer (mqtt topics do), so the document copies it // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { // Bounded by the view length; a null, empty view becomes "" rather than JSON null @@ -283,6 +284,11 @@ inline void convertToJson(const StringRef &src, JsonVariant dst) { // constructor and asks for empty() instead dst.set(JsonString(src.c_str(), src.size(), /* isStatic= */ false)); } + +namespace json { +// For a StringRef that outlives the document, see json_util.h. A null, empty view links "" too +inline JsonString linked(const StringRef &s) { return JsonString(s.empty() ? "" : s.c_str(), s.size(), true); } +} // namespace json #endif // USE_JSON } // namespace esphome From 650a02beb0a3018c7635a2cb04bdb7356e9e0950 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:13:15 -1000 Subject: [PATCH 328/435] [remote_transmitter] Compile the transmit actions the test config left out (#20550) --- .../remote_transmitter/common-buttons.yaml | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f168a825d36..261f78b2623 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -332,3 +332,80 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq lambda with repeat + on_press: + remote_transmitter.transmit_keeloq: + address: !lambda return 0x57ffe7b; + command: 0x02 + code: 0xd19ef0a9 + repeat: + times: 3 + wait_time: 10ms + - platform: template + name: Toto with repeat + on_press: + remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + repeat: + times: 2 + wait_time: 20ms + - platform: template + name: ByronSX + on_press: + remote_transmitter.transmit_byronsx: + address: 0x12 + command: 0x05 + - platform: template + name: CanalSat + on_press: + remote_transmitter.transmit_canalsat: + device: 0x25 + address: 0x01 + command: 0x1C + - platform: template + name: CanalSatLD + on_press: + remote_transmitter.transmit_canalsatld: + device: 0x25 + command: !lambda return 0x1C; + - platform: template + name: Coolix + on_press: + remote_transmitter.transmit_coolix: + first: 0xB2BF40 + second: 0xB2BF40 + - platform: template + name: Dish + on_press: + remote_transmitter.transmit_dish: + address: 1 + command: 16 + - platform: template + name: Drayton + on_press: + remote_transmitter.transmit_drayton: + address: 0x1234 + channel: 0x01 + command: 0x05 + - platform: template + name: Gobox + on_press: + remote_transmitter.transmit_gobox: + code: 1 + - platform: template + name: Nexa + on_press: + remote_transmitter.transmit_nexa: + device: 0x38DDB5A + group: 0 + state: 1 + channel: 15 + level: 0 + - platform: template + name: Roomba + on_press: + remote_transmitter.transmit_roomba: + data: 0x88 From 40606a6c0866bcbfa698f7dff107d4c942ecd38f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:24:36 -1000 Subject: [PATCH 329/435] [core] Do not search the same heap twice in RAMAllocator single-region requests (#19381) --- esphome/core/helpers.h | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c54d1bc16f0..50be278bb36 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2214,7 +2214,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_malloc_prefer(size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_malloc_prefer(size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(malloc(size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2229,7 +2229,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_realloc_prefer(p, size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_realloc_prefer(p, size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(realloc(p, size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2301,12 +2301,21 @@ template class RAMAllocator { private: #ifdef USE_ESP32 - /// Returns {primary_caps, fallback_caps} for heap_caps_*_prefer based on the configured flags. + struct Caps { + uint32_t primary; + uint32_t fallback; + uint8_t num; // distinct capability sets to hand to heap_caps_*_prefer + constexpr Caps(uint32_t primary_caps, uint32_t fallback_caps) + : primary(primary_caps), fallback(fallback_caps), num(primary_caps == fallback_caps ? 1 : 2) {} + }; + + /// Returns the capability sets for heap_caps_*_prefer based on the configured flags. /// PREFER_INTERNAL implies both regions are enabled (enforced by the constructor), so when it is set /// the primary is internal and the fallback is external. Otherwise the primary is whichever region - /// is enabled (external preferred when both are enabled), and the fallback is the other region (or - /// the same region when only one is enabled, making the second attempt a no-op). - std::array get_caps_() const { + /// is enabled (external preferred when both are enabled), and the fallback is the other region (or the + /// same region when only one is enabled). With a single region enabled num is 1, so the duplicate + /// fallback is never searched. + constexpr Caps get_caps_() const { constexpr uint32_t external_caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; constexpr uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; if (this->flags_ & PREFER_INTERNAL) { From 847be14d1eb2979c4828ea0c0ff2e625c08711f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:27:41 -1000 Subject: [PATCH 330/435] [socket] Add lwip raw UDP socket implementation (#14677) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/socket/__init__.py | 2 + esphome/components/socket/bsd_sockets_impl.h | 3 + esphome/components/socket/headers.h | 10 + .../socket/lwip_raw_common_impl.cpp | 116 ++++++ .../components/socket/lwip_raw_common_impl.h | 53 +++ .../components/socket/lwip_raw_tcp_impl.cpp | 136 +------ esphome/components/socket/lwip_raw_tcp_impl.h | 5 +- .../components/socket/lwip_raw_udp_impl.cpp | 333 ++++++++++++++++++ esphome/components/socket/lwip_raw_udp_impl.h | 113 ++++++ esphome/components/socket/lwip_sockets_impl.h | 3 + esphome/components/socket/socket.cpp | 20 +- esphome/components/socket/socket.h | 57 ++- 12 files changed, 705 insertions(+), 146 deletions(-) create mode 100644 esphome/components/socket/lwip_raw_common_impl.cpp create mode 100644 esphome/components/socket/lwip_raw_common_impl.h create mode 100644 esphome/components/socket/lwip_raw_udp_impl.cpp create mode 100644 esphome/components/socket/lwip_raw_udp_impl.h diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index cc5f672ad55..effa7d864fc 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -269,7 +269,9 @@ async def to_code(config: ConfigType) -> None: # consumers that called the matching require_ function. FILTER_SOURCE_FILES = filter_source_files_from_defines( { + "lwip_raw_common_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "lwip_raw_tcp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", + "lwip_raw_udp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 1b5ea9ebcd6..e2b5de6f106 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -144,6 +144,9 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index f4597f2522f..1d50555cd1b 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -20,6 +20,16 @@ #define IPPROTO_IP 0 #define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 + +#define IP_ADD_MEMBERSHIP 3 +#define IP_DROP_MEMBERSHIP 4 + +// NOLINTNEXTLINE(readability-identifier-naming) +struct ip_mreq { + struct in_addr imr_multiaddr; + struct in_addr imr_interface; +}; #if LWIP_IPV6 #define AF_INET6 10 diff --git a/esphome/components/socket/lwip_raw_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp new file mode 100644 index 00000000000..4ec0a373550 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -0,0 +1,116 @@ +#include "lwip_raw_common_impl.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +namespace esphome::socket { + +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen) { + if (family == AF_INET) { + if (*addrlen < sizeof(struct sockaddr_in)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin_family = AF_INET; + *addrlen = addr->sin_len = sizeof(struct sockaddr_in); + addr->sin_port = htons(port_host); + inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); + return 0; + } +#if LWIP_IPV6 + if (family == AF_INET6) { + if (*addrlen < sizeof(struct sockaddr_in6)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin6_family = AF_INET6; + *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); + addr->sin6_port = htons(port_host); + // AF_INET6 sockets may receive IPv4 packets; convert to IPv4-mapped IPv6. + if (IP_IS_V4(ip)) { + ip_addr_t mapped; + ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); + } else { + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); + } + return 0; + } +#endif + errno = EAFNOSUPPORT; + return -1; +} + +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { + // headers.h defines sockaddr and sockaddr_in with the same size, so this covers AF_INET + if (addrlen < sizeof(struct sockaddr)) + return false; + // Zero the whole struct — the IPv6 zone byte would otherwise be stack garbage + memset(ip, 0, sizeof(*ip)); + if (addr->sa_family == AF_INET) { + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V4); + ip_2_ip4(ip)->addr = addr4->sin_addr.s_addr; + return true; + } +#if LWIP_IPV6 + if (addr->sa_family == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) + return false; + auto *addr6 = reinterpret_cast(addr); + *port = ntohs(addr6->sin6_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V6); + memcpy(&ip_2_ip6(ip)->addr, &addr6->sin6_addr.un.u8_addr, 16); + // Unmap ::ffff:a.b.c.d so replies to recvfrom addresses route as IPv4 + if (ip6_addr_isipv4mappedipv6(ip_2_ip6(ip))) { + unmap_ipv4_mapped_ipv6(ip_2_ip4(ip), ip_2_ip6(ip)); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V4); + } + return true; + } +#endif + return false; +} + +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port) { + if (!sockaddr_to_lwip(addr, addrlen, ip, port)) + return false; +#if LWIP_IPV6 + // Only promote wildcard binds — a specific address must keep filtering + if (family == AF_INET6 && ip_addr_isany_val(*ip)) + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); +#endif + return true; +} + +int lwip_err_to_errno(err_t err) { + switch (err) { + case ERR_MEM: + return ENOMEM; + case ERR_BUF: + return EAGAIN; // transient, e.g. no free local port + case ERR_RTE: + return EHOSTUNREACH; // no route, e.g. no address yet + case ERR_VAL: + case ERR_ARG: + return EINVAL; + case ERR_USE: + return EADDRINUSE; + case ERR_ISCONN: + return EISCONN; + default: + return EIO; + } +} + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_common_impl.h b/esphome/components/socket/lwip_raw_common_impl.h new file mode 100644 index 00000000000..49e3a23f234 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.h @@ -0,0 +1,53 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include "headers.h" +#include "lwip/ip.h" + +namespace esphome::socket { + +// ---- LWIP thread safety ---- +// +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority +// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). They can preempt main-loop code at any point. +// +// Without locking, this causes race conditions between recv_fn and read() on the +// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing +// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. +// +// On ESP8266, lwip callbacks run from the SYS context which cooperates with user +// code (CONT context) — they never preempt each other, so no locking is needed. +// +// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). +// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end +// (Ethernet). On ESP8266, it's a no-op. +// +// Each .cpp file that needs locking defines its own LWIP_LOCK() macro: +// #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard +// This is a per-TU convenience macro, not defined here to avoid macro leaking. + +/// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// TCP callers pass ntohs(pcb port) to preserve historical getpeername/getsockname output. +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen); + +/// Convert sockaddr to lwip ip_addr_t and host-order port. +/// For IPv6, sets type to IPADDR_TYPE_V6 — correct for sendto destinations. +/// Bind paths must use sockaddr_to_lwip_bind() instead. +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port); + +/// sockaddr_to_lwip variant for bind: promotes AF_INET6 sockets to +/// IPADDR_TYPE_ANY so they accept both IPv4 and IPv6 (dual-stack). +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port); + +/// errno for a failed lwip call +int lwip_err_to_errno(err_t err); + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 2b21291c30b..77ff0f47358 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" +#include "lwip_raw_common_impl.h" #ifdef USE_OTA_PLATFORM_ESPHOME extern "C" void esphome_wake_ota_component_any_context(); @@ -24,23 +25,9 @@ extern "C" void esphome_wake_ota_component_any_context(); namespace esphome::socket { -// ---- LWIP thread safety ---- -// -// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. -// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority -// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk -// async_context_threadsafe_background.c). They can preempt main-loop code at any point. -// -// Without locking, this causes race conditions between recv_fn and read() on the -// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing -// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. -// -// On ESP8266, lwip callbacks run from the SYS context which cooperates with user -// code (CONT context) — they never preempt each other, so no locking is needed. -// -// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). -// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end -// (Ethernet). On ESP8266, it's a no-op. +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT ESPHOME_LOG_TAG(TAG, "socket"); @@ -54,27 +41,6 @@ static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); static inline void yield_to_sys() {} #endif -// errno for a failed tcp_* call -static int lwip_err_to_errno(err_t err) { - switch (err) { - case ERR_MEM: - return ENOMEM; - case ERR_BUF: - return EAGAIN; // transient, e.g. no free local port - case ERR_RTE: - return EHOSTUNREACH; // no route, e.g. no address yet - case ERR_VAL: - case ERR_ARG: - return EINVAL; - case ERR_USE: - return EADDRINUSE; - case ERR_ISCONN: - return EISCONN; - default: - return EIO; - } -} - // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -125,33 +91,14 @@ LWIPRawCommon::~LWIPRawCommon() { } } -bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const { - if (name == nullptr) { - errno = EINVAL; - return false; - } -#if LWIP_IPV6 - if (this->family_ == AF_INET6) { - if (addrlen < sizeof(sockaddr_in6)) { - errno = EINVAL; - return false; - } - auto *addr6 = reinterpret_cast(name); - *port = ntohs(addr6->sin6_port); - inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr); - // ANY lets bind() accept both families; connect() picks the concrete type - IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); +bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, + bool for_bind) const { + if (name != nullptr && name->sa_family == this->family_ && + (for_bind ? sockaddr_to_lwip_bind(this->family_, name, addrlen, ip, port) + : sockaddr_to_lwip(name, addrlen, ip, port))) return true; - } -#endif - if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) { - errno = EINVAL; - return false; - } - auto *addr4 = reinterpret_cast(name); - *port = ntohs(addr4->sin_port); - ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr); - return true; + errno = EINVAL; + return false; } int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { @@ -162,7 +109,7 @@ int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { } ip_addr_t ip; uint16_t port; - if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) { + if (!this->sockaddr2ip_(name, addrlen, &ip, &port, true)) { return -1; } LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); @@ -357,43 +304,8 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle } int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { - if (this->family_ == AF_INET) { - if (*addrlen < sizeof(struct sockaddr_in)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in *addr = reinterpret_cast(name); - addr->sin_family = AF_INET; - *addrlen = addr->sin_len = sizeof(struct sockaddr_in); - addr->sin_port = port; - inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); - return 0; - } -#if LWIP_IPV6 - else if (this->family_ == AF_INET6) { - if (*addrlen < sizeof(struct sockaddr_in6)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in6 *addr = reinterpret_cast(name); - addr->sin6_family = AF_INET6; - *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); - addr->sin6_port = port; - - // AF_INET6 sockets are bound to IPv4 as well, so we may encounter IPv4 addresses that must be converted to IPv6. - if (IP_IS_V4(ip)) { - ip_addr_t mapped; - ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); - } else { - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); - } - return 0; - } -#endif - return -1; + // lwip pcb ports are host order; ntohs preserves historical byte-swapped sin_port output + return lwip_ip_to_sockaddr(this->family_, ip, ntohs(port), name, addrlen); } // ---- LWIPRawImpl methods ---- @@ -462,20 +374,10 @@ int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) { } ip_addr_t ip; uint16_t port; - if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) { + // Concrete type for tcp_connect; a remembered IPv4 peer arrives v4-mapped and is unmapped + if (!this->sockaddr2ip_(addr, addrlen, &ip, &port, false)) { return -1; } -#if LWIP_IPV6 - // tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped - if (IP_IS_ANY_TYPE_VAL(ip)) { - if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) { - unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip)); - IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4); - } else { - IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6); - } - } -#endif LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn); if (err != ERR_OK) { @@ -965,11 +867,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { return ERR_OK; } -// ---- Factory functions ---- +// ---- TCP Factory functions ---- std::unique_ptr socket(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } @@ -989,7 +891,7 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol std::unique_ptr socket_listen(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index ed8057e289a..69158cc8fe0 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -50,8 +50,9 @@ class LWIPRawCommon { protected: int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); - /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch - bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const; + /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch. + /// for_bind keeps an IPv6 wildcard dual stack, otherwise the type is concrete. + bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, bool for_bind) const; // Member ordering optimized to minimize padding on 32-bit systems struct tcp_pcb *pcb_; diff --git a/esphome/components/socket/lwip_raw_udp_impl.cpp b/esphome/components/socket/lwip_raw_udp_impl.cpp new file mode 100644 index 00000000000..dceba802ae8 --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.cpp @@ -0,0 +1,333 @@ +#include "socket.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" +#include "lwip_raw_common_impl.h" + +#include "lwip/igmp.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. +#define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT + +// ---- LWIPRawUDPSendImpl (send-only) methods ---- + +LWIPRawUDPSendImpl::~LWIPRawUDPSendImpl() { + // Guard avoids acquiring the lwip lock when already closed + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPSendImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (name == nullptr) { + errno = EINVAL; + return -1; + } + ip_addr_t ip; + uint16_t port; + if (!sockaddr_to_lwip_bind(this->family_, name, addrlen, &ip, &port)) { + errno = EINVAL; + return -1; + } + err_t err = udp_bind(this->pcb_, &ip, port); + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return 0; +} + +int LWIPRawUDPSendImpl::close() { + LWIP_LOCK(); + return this->close_internal_locked_(); +} + +int LWIPRawUDPSendImpl::close_internal_locked_() { + // Caller must hold LWIP_LOCK + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + udp_remove(this->pcb_); + this->pcb_ = nullptr; + return 0; +} + +int LWIPRawUDPSendImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { + // UDP recv callback provides port in host byte order + return lwip_ip_to_sockaddr(this->family_, ip, port, name, addrlen); +} + +ssize_t LWIPRawUDPSendImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, + socklen_t addrlen) { + (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking + if (buf == nullptr || dest_addr == nullptr) { + errno = EINVAL; + return -1; + } + + // pbuf_alloc takes u16_t length; reject oversized packets + if (len > UINT16_MAX) { + errno = EMSGSIZE; + return -1; + } + + ip_addr_t dst_ip; + uint16_t dst_port; + if (!sockaddr_to_lwip(dest_addr, addrlen, &dst_ip, &dst_port)) { + errno = EINVAL; + return -1; + } + + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + + // Allocate pbuf and copy data + struct pbuf *pb = pbuf_alloc(PBUF_TRANSPORT, (uint16_t) len, PBUF_RAM); + if (pb == nullptr) { + errno = ENOMEM; + return -1; + } + memcpy(pb->payload, buf, len); + + err_t err = udp_sendto(this->pcb_, pb, &dst_ip, dst_port); + pbuf_free(pb); + + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return (ssize_t) len; +} + +int LWIPRawUDPSendImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + // Effective only where lwip is built with SO_REUSE=1 (ESP8266 yes, RP2040 currently no) + if (*reinterpret_cast(optval)) { + ip_set_option(this->pcb_, SOF_REUSEADDR); + } else { + ip_reset_option(this->pcb_, SOF_REUSEADDR); + } + return 0; + } + if (level == SOL_SOCKET && optname == SO_BROADCAST) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + int val = *reinterpret_cast(optval); + if (val) { + ip_set_option(this->pcb_, SOF_BROADCAST); + } else { + ip_reset_option(this->pcb_, SOF_BROADCAST); + } + return 0; + } + if (level == IPPROTO_IP && (optname == IP_ADD_MEMBERSHIP || optname == IP_DROP_MEMBERSHIP)) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr{mreq->imr_multiaddr.s_addr}; + ip4_addr_t ifaddr{mreq->imr_interface.s_addr}; + err_t err = + optname == IP_ADD_MEMBERSHIP ? igmp_joingroup(&ifaddr, &multiaddr) : igmp_leavegroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen == nullptr || *optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + *reinterpret_cast(optval) = ip_get_option(this->pcb_, SOF_REUSEADDR) ? 1 : 0; + *optlen = sizeof(int); + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::setblocking(bool blocking) { + if (blocking) { + // blocking operation not supported on raw lwip + errno = EINVAL; + return -1; + } + return 0; +} + +// ---- LWIPRawUDPImpl methods ---- + +LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb) : LWIPRawUDPSendImpl(family, pcb) { + // Registered here (not in bind) so unbound client sockets can receive replies + udp_recv(this->pcb_, LWIPRawUDPImpl::s_recv_fn, this); +} + +LWIPRawUDPImpl::~LWIPRawUDPImpl() { + // Flush rx queue and unregister callback before base destructor removes pcb + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPImpl::close() { + LWIP_LOCK(); + // Unregister recv callback before removing pcb + if (this->pcb_ != nullptr) { + udp_recv(this->pcb_, nullptr, nullptr); + } + // Flush queued rx packets; slots within rx_count_ always hold a live pbuf + for (; this->rx_count_ > 0; this->rx_count_--) { + pbuf_free(this->rx_queue_[this->rx_read_idx_].pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + } + // close_internal_locked_() returns EBADF if already closed, which is fine from destructor + return this->close_internal_locked_(); +} + +ssize_t LWIPRawUDPImpl::read(void *buf, size_t len) { return this->recvfrom(buf, len, nullptr, nullptr); } + +ssize_t LWIPRawUDPImpl::recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen) { + if (buf == nullptr && len > 0) { + errno = EINVAL; + return -1; + } + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (this->rx_count_ == 0) { + errno = EWOULDBLOCK; + return -1; + } + + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + // On address conversion failure, still consume the packet — the failure is + // deterministic (family_ and *addrlen), so keeping it would wedge the queue + ssize_t ret = -1; + if (src_addr == nullptr || addrlen == nullptr || + this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen) == 0) { + ret = (ssize_t) std::min(len, (size_t) pkt.pb->tot_len); + pbuf_copy_partial(pkt.pb, buf, ret, 0); + } + pbuf_free(pkt.pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + this->rx_count_--; + return ret; +} + +void LWIPRawUDPImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + auto *self = reinterpret_cast(arg); + self->recv_fn_(p, addr, port); +} + +// LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). +// No heap allocation allowed — malloc is not IRQ-safe (see #14687). +// No LWIP_LOCK() needed — lwip core already holds the async_context lock. +void LWIPRawUDPImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { + if (p == nullptr) + return; + + // Check if queue is full + if (this->rx_count_ >= UDP_RX_QUEUE_SIZE) { + // Drop packet — queue full. Can't log from IRQ context, so count it + // (saturating) for consumers to surface via get_rx_dropped(). + if (this->rx_dropped_ != UINT16_MAX) + this->rx_dropped_++; + pbuf_free(p); + return; + } + + // Enqueue the packet + uint8_t write_idx = (this->rx_read_idx_ + this->rx_count_) & UDP_RX_MASK; + auto &slot = this->rx_queue_[write_idx]; + slot.pb = p; + slot.src_addr = *addr; + slot.src_port = port; + this->rx_count_++; + + esphome::wake_loop_any_context(); +} + +// ---- UDP Factory functions ---- + +static struct udp_pcb *new_udp_pcb(int domain) { +#if LWIP_IPV6 + return udp_new_ip_type(domain == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); +#else + return udp_new(); +#endif +} + +std::unique_ptr socket_udp_send(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + return make_unique((sa_family_t) domain, pcb); +} + +std::unique_ptr socket_udp(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + // Ctor registers the recv callback under the lock held here + return make_unique((sa_family_t) domain, pcb); +} + +#undef LWIP_LOCK + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_udp_impl.h b/esphome/components/socket/lwip_raw_udp_impl.h new file mode 100644 index 00000000000..268515106ff --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.h @@ -0,0 +1,113 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "headers.h" +#include "lwip/ip.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +/// Send-only UDP socket implementation for LWIP raw API. +/// Non-virtual, concrete type. Uses lwip/udp.h raw API. +/// No receive capability — use LWIPRawUDPImpl for sockets that need to receive. +class LWIPRawUDPSendImpl { + public: + /// The pcb is allocated by the factory (like the TCP impl); never null here. + LWIPRawUDPSendImpl(sa_family_t family, struct udp_pcb *pcb) : pcb_(pcb), family_(family) {} + ~LWIPRawUDPSendImpl(); + LWIPRawUDPSendImpl(const LWIPRawUDPSendImpl &) = delete; + LWIPRawUDPSendImpl &operator=(const LWIPRawUDPSendImpl &) = delete; + + int bind(const struct sockaddr *name, socklen_t addrlen); + int close(); + + /// Send a UDP packet to the specified destination. + ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); + + int setsockopt(int level, int optname, const void *optval, socklen_t optlen); + int getsockopt(int level, int optname, void *optval, socklen_t *optlen); + + int setblocking(bool blocking); + + bool ready() const { return false; } + int get_fd() const { return -1; } + + protected: + /// Convert lwip ip_addr_t and port to sockaddr. + int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + + /// Shared close logic — removes the udp pcb. Caller must hold LWIP_LOCK. + int close_internal_locked_(); + + struct udp_pcb *pcb_; + sa_family_t family_; +}; + +/// UDP socket with receive support for LWIP raw API. +/// Extends LWIPRawUDPSendImpl with a fixed-size ring buffer for incoming packets. +/// Inheritance is private (base dtor is non-virtual; converting to a base +/// pointer would leak queued pbufs on destruction). +class LWIPRawUDPImpl : private LWIPRawUDPSendImpl { + public: + /// Caller (the factory) must hold the lwip lock; registers the recv callback. + LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb); + ~LWIPRawUDPImpl(); + + using LWIPRawUDPSendImpl::bind; + using LWIPRawUDPSendImpl::get_fd; + using LWIPRawUDPSendImpl::getsockopt; + using LWIPRawUDPSendImpl::sendto; + using LWIPRawUDPSendImpl::setblocking; + using LWIPRawUDPSendImpl::setsockopt; + + /// Close the socket, flushing any queued rx packets first. + int close(); + + /// Read the next queued packet, discarding source address info. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t read(void *buf, size_t len); + /// Read the next queued packet and return the source address. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); + + /// Returns true if there are packets available to read. + /// Intentionally unlocked — same rationale as LWIPRawImpl::ready(). + bool ready() const { return this->rx_count_ > 0; } + + /// Number of packets dropped because the rx queue was full (saturating). + uint16_t get_rx_dropped() const { return this->rx_dropped_; } + + protected: + static void s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + void recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port); + + /// Ring buffer for received UDP packets. + /// Both producer (recv callback) and consumer (main loop) are serialized by the + /// lwip lock — the callback runs under lwip core lock, and consumer methods hold + /// LWIP_LOCK(). All 4 slots are usable (no wasted slot for full/empty distinction). + /// No heap allocation in the recv callback — packets are dropped if the queue is full. + static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; + static constexpr uint8_t UDP_RX_MASK = UDP_RX_QUEUE_SIZE - 1; + static_assert((UDP_RX_QUEUE_SIZE & UDP_RX_MASK) == 0, "UDP_RX_QUEUE_SIZE must be power of 2"); + // Fields are written by recv_fn_ before rx_count_ makes a slot visible + struct UDPRxPacket { + ip_addr_t src_addr; + struct pbuf *pb; + uint16_t src_port; + }; + std::array rx_queue_{}; + uint16_t rx_dropped_{0}; + uint8_t rx_read_idx_{0}; + uint8_t rx_count_{0}; +}; + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 7f3b706cd8d..9cba1647163 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,6 +84,9 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 89002cdd19e..a91c455e0fd 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -129,25 +129,7 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s return 0; } -std::unique_ptr socket_ip(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket(AF_INET6, type, protocol); -#else - return socket(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} - -#ifdef USE_SOCKET_IMPL_LWIP_TCP -// LWIP_TCP has separate Socket/ListenSocket types — needs out-of-line factory. -// BSD and LWIP_SOCKETS define this inline in socket.h. -std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket_listen_loop_monitored(AF_INET6, type, protocol); -#else - return socket_listen_loop_monitored(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} -#endif +std::unique_ptr socket_ip(int type, int protocol) { return socket(IP_DOMAIN, type, protocol); } #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) // Callback must be a temporary lambda diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index d1a70c5aa84..8f5860e26e1 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -20,6 +20,7 @@ #include "lwip_sockets_impl.h" #elif defined(USE_SOCKET_IMPL_LWIP_TCP) #include "lwip_raw_tcp_impl.h" +#include "lwip_raw_udp_impl.h" #endif namespace esphome::socket { @@ -27,17 +28,32 @@ namespace esphome::socket { // Type aliases — only one implementation is active per build. // Socket is the concrete type for connected sockets. // ListenSocket is the concrete type for listening/server sockets. -// On BSD and LWIP_SOCKETS, both aliases resolve to the same type. +// UDPSocket is the concrete type for UDP sockets (send + receive). +// UDPSendSocket is the concrete type for send-only UDP sockets. +// On BSD and LWIP_SOCKETS, all aliases resolve to the same type. // On LWIP_TCP, they are different types (no virtual dispatch between them). #ifdef USE_SOCKET_IMPL_BSD_SOCKETS using Socket = BSDSocketImpl; using ListenSocket = BSDSocketImpl; +using UDPSendSocket = BSDSocketImpl; +using UDPSocket = BSDSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS) using Socket = LwIPSocketImpl; using ListenSocket = LwIPSocketImpl; +using UDPSendSocket = LwIPSocketImpl; +using UDPSocket = LwIPSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_TCP) using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; +using UDPSendSocket = LWIPRawUDPSendImpl; +using UDPSocket = LWIPRawUDPImpl; +#endif + +// Domain used by the socket_ip_* helpers: newest available IP domain. +#if USE_NETWORK_IPV6 +inline constexpr int IP_DOMAIN = AF_INET6; +#else +inline constexpr int IP_DOMAIN = AF_INET; #endif #ifdef USE_LWIP_FAST_SELECT @@ -105,6 +121,36 @@ std::unique_ptr socket_ip(int type, int protocol); /// File descriptors >= FD_SETSIZE will not be monitored and will log an error. std::unique_ptr socket_loop_monitored(int domain, int type, int protocol); +/// Create a send-only UDP socket (socket_udp_send), a UDP socket with receive +/// support (socket_udp), or a UDP socket monitored for data in the main loop +/// (socket_udp_loop_monitored). +#ifdef USE_SOCKET_IMPL_LWIP_TCP +std::unique_ptr socket_udp_send(int domain, int protocol); +std::unique_ptr socket_udp(int domain, int protocol); +// Wake is built into the recv callback, so monitoring needs nothing extra. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_udp(domain, protocol); +} +#else +inline std::unique_ptr socket_udp_send(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +inline std::unique_ptr socket_udp(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +// Registers the socket with the Application's select() loop. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_loop_monitored(domain, SOCK_DGRAM, protocol); +} +#endif + +/// socket_udp* variants using the newest available IP domain. +inline std::unique_ptr socket_ip_udp_send(int protocol) { return socket_udp_send(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp(int protocol) { return socket_udp(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp_loop_monitored(int protocol) { + return socket_udp_loop_monitored(IP_DOMAIN, protocol); +} + /// Create a listening socket of the given domain, type and protocol. /// Create a listening socket and monitor it for data in the main loop. /// Create a listening socket in the newest available IP domain and monitor it. @@ -112,7 +158,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol // LWIP_TCP has separate Socket/ListenSocket types — needs distinct factory functions. std::unique_ptr socket_listen(int domain, int type, int protocol); std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); -std::unique_ptr socket_ip_loop_monitored(int type, int protocol); #else // BSD and LWIP_SOCKETS: Socket == ListenSocket, so listen variants just delegate. inline std::unique_ptr socket_listen(int domain, int type, int protocol) { @@ -121,14 +166,10 @@ inline std::unique_ptr socket_listen(int domain, int type, int pro inline std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { return socket_loop_monitored(domain, type, protocol); } +#endif inline std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket_loop_monitored(AF_INET6, type, protocol); -#else - return socket_loop_monitored(AF_INET, type, protocol); -#endif + return socket_listen_loop_monitored(IP_DOMAIN, type, protocol); } -#endif /// Set a sockaddr to the specified address and port for the IP version used by socket_ip(). /// @param addr Destination sockaddr structure From 6095d9d610c85f2606aeddc85ed93b8763610786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:56:56 -1000 Subject: [PATCH 331/435] [improv_serial] Remove the duplicate timeout id left by the beta merge (#20562) --- esphome/components/improv_serial/improv_serial_component.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ac0b33b6aef..906781e17bf 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -20,8 +20,6 @@ ESPHOME_LOG_TAG(TAG, "improv_serial"); static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; -static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; - void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_IMPROV_SERIAL_UART From d19bb7f4be7589723a2bf9d451b5630d0b099b06 Mon Sep 17 00:00:00 2001 From: mick96 Date: Mon, 12 Oct 2026 05:00:16 +0200 Subject: [PATCH 332/435] [cse7761] Add current_input option for current transformer boards such as the Sonoff POW CT (#7836) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/cse7761/cse7761.cpp | 29 ++++++++++++++----- esphome/components/cse7761/cse7761.h | 10 +++++++ esphome/components/cse7761/sensor.py | 12 +++++++- .../cse7761/common-current-transformer.yaml | 11 +++++++ tests/components/cse7761/test.esp32-idf.yaml | 3 +- .../components/cse7761/test.esp8266-ard.yaml | 3 +- tests/components/cse7761/test.rp2040-ard.yaml | 3 +- ...alidate-current-transformer.esp32-idf.yaml | 7 +++++ 8 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 tests/components/cse7761/common-current-transformer.yaml create mode 100644 tests/components/cse7761/validate-current-transformer.esp32-idf.yaml diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index a6101812223..4ba93af01f1 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -18,6 +18,14 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc static constexpr int CSE7761_IREF = 52241; // RmsIAC static constexpr int CSE7761_PREF = 44513; // PowerPAC +// Empirical Sonoff POW CT factor taken from Tasmota +static constexpr uint32_t CURRENT_TRANSFORMER_COEFFICIENT_SCALE = 5; + +// PGA 16x on both current channels +static constexpr uint16_t SYSCON_SHUNT = 0xFF04; +// All PGAs 1x, ADC2ON +static constexpr uint16_t SYSCON_CURRENT_TRANSFORMER = 0xFE00; + // System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f; @@ -68,6 +76,8 @@ void CSE7761Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } + ESP_LOGCONFIG(TAG, " Current input: %s", + LOG_STR_ARG(this->is_current_transformer_() ? LOG_STR("current transformer") : LOG_STR("shunt"))); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current 1", this->current_sensor_1_); @@ -93,7 +103,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[1] = reg; uint32_t len = 2; if (data) { - if (data < 0xFF) { + if (data <= 0xFF) { buffer[2] = data & 0xFF; len = 3; } else { @@ -158,16 +168,17 @@ uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { } uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) { + const uint32_t scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1; uint32_t coeff = 0; switch (unit) { case RMS_UC: coeff = this->data_.coefficient[RMS_UC]; return coeff ? 0x400000 * 100 / coeff : 0; case RMS_IAC: - coeff = this->data_.coefficient[RMS_IAC]; + coeff = this->data_.coefficient[RMS_IAC] * scale; return coeff ? (0x800000 * 100 / coeff) * 10 : 0; // Stay within 32 bits case POWER_PAC: - coeff = this->data_.coefficient[POWER_PAC]; + coeff = this->data_.coefficient[POWER_PAC] * scale; return coeff ? 0x80000000 / coeff : 0; } return 0; @@ -195,9 +206,11 @@ bool CSE7761Component::chip_init_() { if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) { ESP_LOGW(TAG, "HFConst read failed, energy unavailable"); } + // The pulse counters follow the raw power, so they need the same front end factor as power + const float input_scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1.0f; for (uint8_t channel = 0; channel < 2; channel++) { uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const; - this->energy_[channel].wh_per_pulse = static_cast(scale) * CSE7761_ENERGY_WH_SCALE; + this->energy_[channel].wh_per_pulse = static_cast(scale) * CSE7761_ENERGY_WH_SCALE * input_scale; } } @@ -205,7 +218,8 @@ bool CSE7761Component::chip_init_() { uint8_t sys_status = this->read_(CSE7761_REG_SYSSTATUS, 1); if (sys_status & 0x10) { // Write enable to protected registers (WREN) - this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04); + this->write_(CSE7761_REG_SYSCON | 0x80, + this->is_current_transformer_() ? SYSCON_CURRENT_TRANSFORMER : SYSCON_SHUNT); this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183); // WaveEN|ZxEN, needed for Ufreq this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1); @@ -226,14 +240,15 @@ void CSE7761Component::get_data_() { uint32_t value = this->read_(CSE7761_REG_RMSU, 3); this->data_.voltage_rms = (value >= 0x800000) ? 0 : value; + // No load threshold of 10mA with a shunt; the raw value stands for 5 times more with a current transformer value = this->read_(CSE7761_REG_RMSIA, 3); - this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA + this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; value = this->read_(CSE7761_REG_POWERPA, 4); // PowerPA is two's complement signed 32-bit per datasheet this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast(value); value = this->read_(CSE7761_REG_RMSIB, 3); - this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA + this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; value = this->read_(CSE7761_REG_POWERPB, 4); // PowerPB is two's complement signed 32-bit per datasheet this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast(value); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 557de0e3422..fc4eac6477c 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -21,9 +21,15 @@ struct CSE7761EnergyChannel { float wh_per_pulse{0.0f}; }; +enum class CSE7761CurrentInput : uint8_t { + CSE7761_CURRENT_INPUT_SHUNT, + CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, +}; + /// This class implements support for the CSE7761 UART power sensor. class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: + explicit CSE7761Component(CSE7761CurrentInput current_input) : current_input_(current_input) {} void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; } void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) { @@ -51,7 +57,11 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice sensor::Sensor *energy_sensors_[2]{nullptr, nullptr}; CSE7761DataStruct data_; CSE7761EnergyChannel energy_[2]; + const CSE7761CurrentInput current_input_; + bool is_current_transformer_() const { + return this->current_input_ == CSE7761CurrentInput::CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER; + } void write_(uint8_t reg, uint16_t data); bool read_once_(uint8_t reg, uint8_t size, uint32_t *value); bool read_(uint8_t reg, uint8_t size, uint32_t *value); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 8ffbb44771a..6f695307de8 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -36,11 +36,21 @@ CONF_ACTIVE_POWER_2 = "active_power_2" CONF_ENERGY_1 = "energy_1" CONF_ENERGY_2 = "energy_2" CONF_POWER_FACTOR_1 = "power_factor_1" +CONF_CURRENT_INPUT = "current_input" + +CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True) +CURRENT_INPUTS = { + "shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT, + "current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, +} CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(CSE7761Component), + cv.Optional(CONF_CURRENT_INPUT, default="shunt"): cv.enum( + CURRENT_INPUTS, lower=True + ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, @@ -112,7 +122,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) + var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/tests/components/cse7761/common-current-transformer.yaml b/tests/components/cse7761/common-current-transformer.yaml new file mode 100644 index 00000000000..6c5854d8af5 --- /dev/null +++ b/tests/components/cse7761/common-current-transformer.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: cse7761 + current_input: current_transformer + voltage: + name: CSE7761 CT Voltage + current_1: + name: CSE7761 CT Current 1 + active_power_1: + name: CSE7761 CT Active Power 1 + energy_1: + name: CSE7761 CT Energy 1 diff --git a/tests/components/cse7761/test.esp32-idf.yaml b/tests/components/cse7761/test.esp32-idf.yaml index b9ae061c253..c0c97c2becb 100644 --- a/tests/components/cse7761/test.esp32-idf.yaml +++ b/tests/components/cse7761/test.esp32-idf.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/test.esp8266-ard.yaml b/tests/components/cse7761/test.esp8266-ard.yaml index 0d57039e1c4..d1d376eebc1 100644 --- a/tests/components/cse7761/test.esp8266-ard.yaml +++ b/tests/components/cse7761/test.esp8266-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp8266-ard.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/test.rp2040-ard.yaml b/tests/components/cse7761/test.rp2040-ard.yaml index 65e6252c511..3850be00707 100644 --- a/tests/components/cse7761/test.rp2040-ard.yaml +++ b/tests/components/cse7761/test.rp2040-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/rp2040-ard.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml b/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml new file mode 100644 index 00000000000..9ad82dcf964 --- /dev/null +++ b/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO14 + +packages: + uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml + cse7761: !include common-current-transformer.yaml From 08a55c97b7e1c43a5eb5992d515391c8dc8ea4e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 17:03:59 -1000 Subject: [PATCH 333/435] [remote_base] Generate the simple transmit actions with no C++ class (#20549) --- esphome/automation.py | 20 +- esphome/components/remote_base/__init__.py | 569 ++++++++++-------- .../components/remote_base/beo4_protocol.h | 15 - .../remote_base/brennenstuhl_protocol.h | 11 - .../components/remote_base/byronsx_protocol.h | 13 - .../remote_base/canalsat_protocol.h | 30 - .../components/remote_base/coolix_protocol.h | 8 - .../components/remote_base/dish_protocol.h | 13 - .../components/remote_base/dooya_protocol.h | 17 - .../components/remote_base/drayton_protocol.h | 15 - .../components/remote_base/dyson_protocol.h | 13 - .../components/remote_base/gobox_protocol.h | 11 - .../remote_base/hob2hood_protocol.h | 8 - esphome/components/remote_base/jvc_protocol.h | 11 - .../components/remote_base/keeloq_protocol.h | 21 - esphome/components/remote_base/lg_protocol.h | 13 - .../remote_base/magiquest_protocol.h | 13 - esphome/components/remote_base/nec_protocol.h | 15 - .../components/remote_base/nexa_protocol.h | 18 - .../components/remote_base/onkyori_protocol.h | 11 - .../remote_base/panasonic_protocol.h | 13 - .../components/remote_base/pioneer_protocol.h | 13 - esphome/components/remote_base/rc5_protocol.h | 13 - .../components/remote_base/remote_base.cpp | 9 + esphome/components/remote_base/remote_base.h | 30 +- .../components/remote_base/roomba_protocol.h | 11 - .../remote_base/samsung36_protocol.h | 13 - .../components/remote_base/samsung_protocol.h | 13 - .../components/remote_base/sony_protocol.h | 13 - .../remote_base/symphony_protocol.h | 15 - .../remote_base/toshiba_ac_protocol.h | 13 - .../components/remote_base/toto_protocol.h | 15 - .../remote_transmitter/__init__.py | 13 + .../config/transmit_repeat.yaml | 41 ++ .../remote_transmitter/test_keeloq_action.py | 23 +- .../test_transmit_repeat.py | 27 + 36 files changed, 460 insertions(+), 660 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/transmit_repeat.yaml create mode 100644 tests/component_tests/remote_transmitter/test_transmit_repeat.py diff --git a/esphome/automation.py b/esphome/automation.py index 2f90cbfcbbf..393a79422bc 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,4 +1,4 @@ -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field import logging import string @@ -465,6 +465,22 @@ def register_apply_action( ) -> None: """Register an action that only forwards config values to its parent, with no C++ class. + See ``apply_action_builder``. + """ + register_action(name, ApplyAction, schema, synchronous=True)( + apply_action_builder(name, schema, *fields, call=call, id_key=id_key) + ) + + +def apply_action_builder( + name: str, + schema: cv.Schema, + *fields: ApplyField | ApplyCall, + call: str | None = None, + id_key: str = CONF_ID, +) -> Callable[..., Awaitable[MockObj]]: + """The builder ``register_apply_action`` registers, for a caller that wraps it. + Generates one static function with the parent (read from ``id_key``) and constants baked in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it. A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. @@ -515,7 +531,7 @@ def register_apply_action( action_id, cg.void, template_arg, lambda_args, statements ) - register_action(name, ApplyAction, schema, synchronous=True)(builder) + return builder def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool: diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index eca57bca8a3..01e25714e7b 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -44,10 +44,10 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import coroutine +from esphome.core import ID, coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] @@ -257,14 +257,23 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) +def _transmit_action( + name: str, + schema: cv.Schema, + validate: Callable[[ConfigType], ConfigType] | None, +) -> tuple[str, cv.Schema, Any]: + """The action name, its schema and the schema with ``validate`` applied.""" + schema = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) + validator = schema if validate is None else cv.All(schema, validate) + return f"remote_transmitter.transmit_{name}", schema, validator + + def register_action( name, type_, schema, *, validate: Callable[[ConfigType], ConfigType] | None = None ): - validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) - if validate is not None: - validator = cv.All(validator, validate) + action, _, validator = _transmit_action(name, schema, validate) registerer = automation.register_action( - f"remote_transmitter.transmit_{name}", + action, type_, validator, synchronous=True, @@ -289,6 +298,48 @@ def register_action( return decorator +def register_protocol_action( + name: str, + protocol: str, + schema: cv.Schema, + *fields: tuple[str, str, SafeExpType], + validate: Callable[[ConfigType], ConfigType] | None = None, +) -> None: + """Register ``transmit_`` with no C++ action class. + + One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields + and sends it; the action is a core ``ApplyAction``. + """ + action, schema, validator = _transmit_action(name, schema, validate) + builder = automation.apply_action_builder( + action, + schema, + *( + automation.ApplyField(key, f"data.{member} = {{}}", type_) + for key, member, type_ in fields + ), + automation.ApplyField((CONF_REPEAT, CONF_TIMES), "set_send_times", cg.uint32), + automation.ApplyField( + (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 + ), + call=f"protocol_call<{ns.class_(f'{protocol}Protocol')}>", + id_key=CONF_TRANSMITTER_ID, + ) + + async def protocol_builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + _request_protocol_if_in_tree(name) + return await builder(config, action_id, template_arg, args) + + automation.register_action( + action, automation.ApplyAction, validator, synchronous=True + )(protocol_builder) + + def declare_protocol(name): data = ns.struct(f"{name}Data") binary_sensor_ = ns.class_(f"{name}BinarySensor", RemoteReceiverBinarySensorBase) @@ -372,9 +423,7 @@ async def build_dumpers(config): # Beo4 -Beo4Data, Beo4BinarySensor, Beo4Trigger, Beo4Action, Beo4Dumper = declare_protocol( - "Beo4" -) +Beo4Data, Beo4BinarySensor, Beo4Trigger, _, Beo4Dumper = declare_protocol("Beo4") BEO4_SCHEMA = cv.Schema( { cv.Required(CONF_SOURCE): cv.hex_uint8_t, @@ -408,14 +457,14 @@ def beo4_dumper(var, config): pass -@register_action("beo4", Beo4Action, BEO4_SCHEMA) -async def beo4_action(var, config, args): - template_ = await cg.templatable(config[CONF_SOURCE], args, cg.uint8) - cg.add(var.set_source(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "beo4", + "Beo4", + BEO4_SCHEMA, + (CONF_SOURCE, "source", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Brennenstuhl @@ -423,7 +472,7 @@ async def beo4_action(var, config, args): BrennenstuhlData, BrennenstuhlBinarySensor, BrennenstuhlTrigger, - BrennenstuhlAction, + _, BrennenstuhlDumper, ) = declare_protocol("Brennenstuhl") @@ -456,10 +505,12 @@ def brennenstuhl_dumper(var, config): pass -@register_action("brennenstuhl", BrennenstuhlAction, BRENNENSTUHL_SCHEMA) -async def brennenstuhl_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_code(template_)) +register_protocol_action( + "brennenstuhl", + "Brennenstuhl", + BRENNENSTUHL_SCHEMA, + (CONF_CODE, "code", cg.uint32), +) # ByronSX @@ -467,7 +518,7 @@ async def brennenstuhl_action(var, config, args): ByronSXData, ByronSXBinarySensor, ByronSXTrigger, - ByronSXAction, + _, ByronSXDumper, ) = declare_protocol("ByronSX") BYRONSX_SCHEMA = cv.Schema( @@ -503,12 +554,13 @@ def byronsx_dumper(var, config): pass -@register_action("byronsx", ByronSXAction, BYRONSX_SCHEMA) -async def byronsx_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "byronsx", + "ByronSX", + BYRONSX_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # CanalSat @@ -516,7 +568,7 @@ async def byronsx_action(var, config, args): CanalSatData, CanalSatBinarySensor, CanalSatTrigger, - CanalSatAction, + _, CanalSatDumper, ) = declare_protocol("CanalSat") CANALSAT_SCHEMA = cv.Schema( @@ -552,21 +604,21 @@ def canalsat_dumper(var, config): pass -@register_action("canalsat", CanalSatAction, CANALSAT_SCHEMA) -async def canalsat_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsat", + "CanalSat", + CANALSAT_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) ( CanalSatLDData, CanalSatLDBinarySensor, CanalSatLDTrigger, - CanalSatLDAction, + _, CanalSatLDDumper, ) = declare_protocol("CanalSatLD") CANALSATLD_SCHEMA = cv.Schema( @@ -602,14 +654,14 @@ def canalsatld_dumper(var, config): pass -@register_action("canalsatld", CanalSatLDAction, CANALSATLD_SCHEMA) -async def canalsatld_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsatld", + "CanalSatLD", + CANALSATLD_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Coolix @@ -617,7 +669,7 @@ async def canalsatld_action(var, config, args): CoolixData, CoolixBinarySensor, CoolixTrigger, - CoolixAction, + _, CoolixDumper, ) = declare_protocol("Coolix") @@ -647,12 +699,13 @@ def coolix_binary_sensor(var, config): cg.add(var.set_data(cg.ArrayInitializer(0, config))) -@register_action("coolix", CoolixAction, COOLIX_BASE_SCHEMA) -async def coolix_action(var, config, args): - template_ = await cg.templatable(config[CONF_FIRST], args, cg.uint32) - cg.add(var.set_first(template_)) - template_ = await cg.templatable(config[CONF_SECOND], args, cg.uint32) - cg.add(var.set_second(template_)) +register_protocol_action( + "coolix", + "Coolix", + COOLIX_BASE_SCHEMA, + (CONF_FIRST, "first", cg.uint32), + (CONF_SECOND, "second", cg.uint32), +) @register_trigger("coolix", CoolixTrigger, CoolixData) @@ -666,9 +719,7 @@ def coolix_dumper(var, config): # Dish -DishData, DishBinarySensor, DishTrigger, DishAction, DishDumper = declare_protocol( - "Dish" -) +DishData, DishBinarySensor, DishTrigger, _, DishDumper = declare_protocol("Dish") DISH_SCHEMA = cv.Schema( { cv.Optional(CONF_ADDRESS, default=1): cv.int_range(min=1, max=16), @@ -700,18 +751,17 @@ def dish_dumper(var, config): pass -@register_action("dish", DishAction, DISH_SCHEMA) -async def dish_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "dish", + "Dish", + DISH_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Dooya -DooyaData, DooyaBinarySensor, DooyaTrigger, DooyaAction, DooyaDumper = declare_protocol( - "Dooya" -) +DooyaData, DooyaBinarySensor, DooyaTrigger, _, DooyaDumper = declare_protocol("Dooya") DOOYA_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.hex_int_range(0, 16777215), @@ -747,22 +797,19 @@ def dooya_dumper(var, config): pass -@register_action("dooya", DooyaAction, DOOYA_SCHEMA) -async def dooya_action(var, config, args): - template_ = await cg.templatable(config[CONF_ID], args, cg.uint32) - cg.add(var.set_id(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_BUTTON], args, cg.uint8) - cg.add(var.set_button(template_)) - template_ = await cg.templatable(config[CONF_CHECK], args, cg.uint8) - cg.add(var.set_check(template_)) +register_protocol_action( + "dooya", + "Dooya", + DOOYA_SCHEMA, + (CONF_ID, "id", cg.uint32), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_BUTTON, "button", cg.uint8), + (CONF_CHECK, "check", cg.uint8), +) # Dyson -DysonData, DysonBinarySensor, DysonTrigger, DysonAction, DysonDumper = declare_protocol( - "Dyson" -) +DysonData, DysonBinarySensor, DysonTrigger, _, DysonDumper = declare_protocol("Dyson") DYSON_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.hex_uint16_t, @@ -794,16 +841,17 @@ def dyson_dumper(var, config): pass -@register_action("dyson", DysonAction, DYSON_SCHEMA) -async def dyson_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint16) - cg.add(var.set_code(template_)) - template_ = await cg.templatable(config[CONF_INDEX], args, cg.uint8) - cg.add(var.set_index(template_)) +register_protocol_action( + "dyson", + "Dyson", + DYSON_SCHEMA, + (CONF_CODE, "code", cg.uint16), + (CONF_INDEX, "index", cg.uint8), +) # JVC -JVCData, JVCBinarySensor, JVCTrigger, JVCAction, JVCDumper = declare_protocol("JVC") +JVCData, JVCBinarySensor, JVCTrigger, _, JVCDumper = declare_protocol("JVC") JVC_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint32_t}) @@ -829,14 +877,16 @@ def jvc_dumper(var, config): pass -@register_action("jvc", JVCAction, JVC_SCHEMA) -async def jvc_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) +register_protocol_action( + "jvc", + "JVC", + JVC_SCHEMA, + (CONF_DATA, "data", cg.uint32), +) # LG -LGData, LGBinarySensor, LGTrigger, LGAction, LGDumper = declare_protocol("LG") +LGData, LGBinarySensor, LGTrigger, _, LGDumper = declare_protocol("LG") LG_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -868,12 +918,13 @@ def lg_dumper(var, config): pass -@register_action("lg", LGAction, LG_SCHEMA) -async def lg_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "lg", + "LG", + LG_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # MagiQuest @@ -881,7 +932,7 @@ async def lg_action(var, config, args): MagiQuestData, MagiQuestBinarySensor, MagiQuestTrigger, - MagiQuestAction, + _, MagiQuestDumper, ) = declare_protocol("MagiQuest") @@ -916,12 +967,13 @@ def magiquest_dumper(var, config): pass -@register_action("magiquest", MagiQuestAction, MAGIQUEST_SCHEMA) -async def magiquest_action(var, config, args): - template_ = await cg.templatable(config[CONF_WAND_ID], args, cg.uint32) - cg.add(var.set_wand_id(template_)) - template_ = await cg.templatable(config[CONF_MAGNITUDE], args, cg.uint16) - cg.add(var.set_magnitude(template_)) +register_protocol_action( + "magiquest", + "MagiQuest", + MAGIQUEST_SCHEMA, + (CONF_WAND_ID, "wand_id", cg.uint32), + (CONF_MAGNITUDE, "magnitude", cg.uint16), +) # Microchip HCS301 KeeLoq OOK @@ -929,7 +981,7 @@ async def magiquest_action(var, config, args): KeeloqData, KeeloqBinarySensor, KeeloqTrigger, - KeeloqAction, + _, KeeloqDumper, ) = declare_protocol("Keeloq") KEELOQ_SCHEMA = cv.Schema( @@ -947,8 +999,8 @@ KEELOQ_SCHEMA = cv.Schema( def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: # A lambda is only known on the device. A plain value has to fit the width. - suffix = config[CONF_SUFFIX] - bits = config[CONF_SUFFIX_BITS] + suffix = config.get(CONF_SUFFIX, 0) + bits = config.get(CONF_SUFFIX_BITS, 0) if isinstance(suffix, int) and isinstance(bits, int) and suffix >> bits: raise cv.Invalid( f"suffix 0x{suffix:X} does not fit in {bits} suffix_bits", [CONF_SUFFIX] @@ -958,10 +1010,9 @@ def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( { - cv.Optional(CONF_SUFFIX, default=0): cv.All( - cv.hex_int, cv.Range(min=0, max=0xFFFF) - ), - cv.Optional(CONF_SUFFIX_BITS, default=0): cv.int_range(min=0, max=16), + # No defaults: an absent key leaves the data at 0 and emits nothing. + cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), + cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), } ) @@ -989,28 +1040,22 @@ def keeloq_dumper(var, config): pass -@register_action( - "keeloq", KeeloqAction, KEELOQ_ACTION_SCHEMA, validate=_keeloq_suffix_fits +register_protocol_action( + "keeloq", + "Keeloq", + KEELOQ_ACTION_SCHEMA, + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CODE, "encrypted", cg.uint32), + (CONF_COMMAND, "command", cg.uint8), + (CONF_LEVEL, "vlow", cg.bool_), + (CONF_SUFFIX, "suffix", cg.uint16), + (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), + validate=_keeloq_suffix_fits, ) -async def keeloq_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint32) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_encrypted(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) - cg.add(var.set_vlow(template_)) - # Without suffix bits nothing of the suffix is sent, so both stay at their C++ default. - if (suffix_bits := config[CONF_SUFFIX_BITS]) != 0: - template_ = await cg.templatable(config[CONF_SUFFIX], args, cg.uint16) - cg.add(var.set_suffix(template_)) - template_ = await cg.templatable(suffix_bits, args, cg.uint8) - cg.add(var.set_suffix_bits(template_)) # NEC -NECData, NECBinarySensor, NECTrigger, NECAction, NECDumper = declare_protocol("NEC") +NECData, NECBinarySensor, NECTrigger, _, NECDumper = declare_protocol("NEC") NEC_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, @@ -1044,19 +1089,19 @@ def nec_dumper(var, config): pass -@register_action("nec", NECAction, NEC_SCHEMA) -async def nec_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint16) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint16) - cg.add(var.set_command_repeats(template_)) +register_protocol_action( + "nec", + "NEC", + NEC_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint16), + (CONF_COMMAND_REPEATS, "command_repeats", cg.uint16), +) # Onkyo RI -OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, OnkyoRIAction, OnkyoRIDumper = ( - declare_protocol("OnkyoRI") +OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, _, OnkyoRIDumper = declare_protocol( + "OnkyoRI" ) ONKYORI_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_int_range(0, 0xFFF)}) @@ -1083,12 +1128,12 @@ def onkyori_dumper(var: MockObj, config: ConfigType) -> None: """The dumper takes no options.""" -@register_action("onkyori", OnkyoRIAction, ONKYORI_SCHEMA) -async def onkyori_action( - var: MockObj, config: ConfigType, args: TemplateArgsType -) -> None: - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint16) - cg.add(var.set_data(template_)) +register_protocol_action( + "onkyori", + "OnkyoRI", + ONKYORI_SCHEMA, + (CONF_DATA, "data", cg.uint16), +) # Pioneer @@ -1096,7 +1141,7 @@ async def onkyori_action( PioneerData, PioneerBinarySensor, PioneerTrigger, - PioneerAction, + _, PioneerDumper, ) = declare_protocol("Pioneer") PIONEER_SCHEMA = cv.Schema( @@ -1130,12 +1175,13 @@ def pioneer_dumper(var, config): pass -@register_action("pioneer", PioneerAction, PIONEER_SCHEMA) -async def pioneer_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint16) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint16) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "pioneer", + "Pioneer", + PIONEER_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint16), + (CONF_RC_CODE_2, "rc_code_2", cg.uint16), +) # Pronto @@ -1188,7 +1234,7 @@ async def pronto_action(var, config, args): GoboxData, GoboxBinarySensor, GoboxTrigger, - GoboxAction, + _, GoboxDumper, ) = declare_protocol("Gobox") GOBOX_SCHEMA = cv.Schema( @@ -1220,10 +1266,12 @@ def gobox_dumper(var, config): pass -@register_action("gobox", GoboxAction, GOBOX_SCHEMA) -async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) - cg.add(var.set_code(template_)) +register_protocol_action( + "gobox", + "Gobox", + GOBOX_SCHEMA, + (CONF_CODE, "code", cg.uint64), +) # Roomba @@ -1231,7 +1279,7 @@ async def gobox_action(var, config, args): RoombaData, RoombaBinarySensor, RoombaTrigger, - RoombaAction, + _, RoombaDumper, ) = declare_protocol("Roomba") ROOMBA_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint8_t}) @@ -1259,16 +1307,16 @@ def roomba_dumper(var, config): pass -@register_action("roomba", RoombaAction, ROOMBA_SCHEMA) -async def roomba_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint8) - cg.add(var.set_data(template_)) +register_protocol_action( + "roomba", + "Roomba", + ROOMBA_SCHEMA, + (CONF_DATA, "data", cg.uint8), +) # Sony -SonyData, SonyBinarySensor, SonyTrigger, SonyAction, SonyDumper = declare_protocol( - "Sony" -) +SonyData, SonyBinarySensor, SonyTrigger, _, SonyDumper = declare_protocol("Sony") SONY_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -1300,16 +1348,17 @@ def sony_dumper(var, config): pass -@register_action("sony", SonyAction, SONY_SCHEMA) -async def sony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "sony", + "Sony", + SONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # Symphony -SymphonyData, SymphonyBinarySensor, SymphonyTrigger, SymphonyAction, SymphonyDumper = ( +SymphonyData, SymphonyBinarySensor, SymphonyTrigger, _, SymphonyDumper = ( declare_protocol("Symphony") ) SYMPHONY_SCHEMA = cv.Schema( @@ -1344,14 +1393,14 @@ def symphony_dumper(var, config): pass -@register_action("symphony", SymphonyAction, SYMPHONY_SCHEMA) -async def symphony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "symphony", + "Symphony", + SYMPHONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Raw @@ -1430,7 +1479,7 @@ async def raw_action(var, config, args): DraytonData, DraytonBinarySensor, DraytonTrigger, - DraytonAction, + _, DraytonDumper, ) = declare_protocol("Drayton") DRAYTON_SCHEMA = cv.Schema( @@ -1466,18 +1515,18 @@ def drayton_dumper(var, config): pass -@register_action("drayton", DraytonAction, DRAYTON_SCHEMA) -async def drayton_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "drayton", + "Drayton", + DRAYTON_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # RC5 -RC5Data, RC5BinarySensor, RC5Trigger, RC5Action, RC5Dumper = declare_protocol("RC5") +RC5Data, RC5BinarySensor, RC5Trigger, _, RC5Dumper = declare_protocol("RC5") RC5_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), @@ -1509,12 +1558,13 @@ def rc5_dumper(var, config): pass -@register_action("rc5", RC5Action, RC5_SCHEMA) -async def rc5_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc5", + "RC5", + RC5_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # RC6 @@ -1873,7 +1923,7 @@ def rc_switch_dumper(var, config): SamsungData, SamsungBinarySensor, SamsungTrigger, - SamsungAction, + _, SamsungDumper, ) = declare_protocol("Samsung") SAMSUNG_SCHEMA = cv.Schema( @@ -1907,12 +1957,13 @@ def samsung_dumper(var, config): pass -@register_action("samsung", SamsungAction, SAMSUNG_SCHEMA) -async def samsung_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint64) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "samsung", + "Samsung", + SAMSUNG_SCHEMA, + (CONF_DATA, "data", cg.uint64), + (CONF_NBITS, "nbits", cg.uint8), +) # Samsung36 @@ -1920,7 +1971,7 @@ async def samsung_action(var, config, args): Samsung36Data, Samsung36BinarySensor, Samsung36Trigger, - Samsung36Action, + _, Samsung36Dumper, ) = declare_protocol("Samsung36") SAMSUNG36_SCHEMA = cv.Schema( @@ -1954,12 +2005,13 @@ def samsung36_dumper(var, config): pass -@register_action("samsung36", Samsung36Action, SAMSUNG36_SCHEMA) -async def samsung36_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "samsung36", + "Samsung36", + SAMSUNG36_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint32), +) # Toshiba AC @@ -1967,7 +2019,7 @@ async def samsung36_action(var, config, args): ToshibaAcData, ToshibaAcBinarySensor, ToshibaAcTrigger, - ToshibaAcAction, + _, ToshibaAcDumper, ) = declare_protocol("ToshibaAc") TOSHIBAAC_SCHEMA = cv.Schema( @@ -2001,12 +2053,13 @@ def toshibaac_dumper(var, config): pass -@register_action("toshiba_ac", ToshibaAcAction, TOSHIBAAC_SCHEMA) -async def toshibaac_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint64) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint64) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "toshiba_ac", + "ToshibaAc", + TOSHIBAAC_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint64), + (CONF_RC_CODE_2, "rc_code_2", cg.uint64), +) # Panasonic @@ -2014,7 +2067,7 @@ async def toshibaac_action(var, config, args): PanasonicData, PanasonicBinarySensor, PanasonicTrigger, - PanasonicAction, + _, PanasonicDumper, ) = declare_protocol("Panasonic") PANASONIC_SCHEMA = cv.Schema( @@ -2048,18 +2101,17 @@ def panasonic_dumper(var, config): pass -@register_action("panasonic", PanasonicAction, PANASONIC_SCHEMA) -async def panasonic_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "panasonic", + "Panasonic", + PANASONIC_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint32), +) # Nexa -NexaData, NexaBinarySensor, NexaTrigger, NexaAction, NexaDumper = declare_protocol( - "Nexa" -) +NexaData, NexaBinarySensor, NexaTrigger, _, NexaDumper = declare_protocol("Nexa") NEXA_SCHEMA = cv.Schema( { cv.Required(CONF_DEVICE): cv.hex_uint32_t, @@ -2097,13 +2149,16 @@ def nexa_dumper(var, config): pass -@register_action("nexa", NexaAction, NEXA_SCHEMA) -async def nexa_action(var, config, args): - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint32))) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.uint8))) - cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_level(await cg.templatable(config[CONF_LEVEL], args, cg.uint8))) +register_protocol_action( + "nexa", + "Nexa", + NEXA_SCHEMA, + (CONF_DEVICE, "device", cg.uint32), + (CONF_GROUP, "group", cg.uint8), + (CONF_STATE, "state", cg.uint8), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_LEVEL, "level", cg.uint8), +) # Midea @@ -2210,7 +2265,7 @@ async def aeha_action(var, config, args): Hob2HoodData, Hob2HoodBinarySensor, Hob2HoodTrigger, - Hob2HoodAction, + _, Hob2HoodDumper, ) = declare_protocol("Hob2Hood") @@ -2252,12 +2307,12 @@ def hob2hood_dumper(var: MockObj, config: ConfigType) -> None: """The dumper takes no options.""" -@register_action("hob2hood", Hob2HoodAction, HOB2HOOD_SCHEMA) -async def hob2hood_action( - var: MockObj, config: ConfigType, args: TemplateArgsType -) -> None: - template_ = await cg.templatable(config[CONF_COMMAND], args, Hob2HoodCommand) - cg.add(var.set_command(template_)) +register_protocol_action( + "hob2hood", + "Hob2Hood", + HOB2HOOD_SCHEMA, + (CONF_COMMAND, "command", Hob2HoodCommand), +) # Haier @@ -2446,7 +2501,7 @@ async def mirage_action(var, config, args): TotoData, TotoBinarySensor, TotoTrigger, - TotoAction, + _, TotoDumper, ) = declare_protocol("Toto") @@ -2483,17 +2538,19 @@ def toto_dumper(var, config): pass -@register_action("toto", TotoAction, TOTO_SCHEMA) -async def Toto_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint8) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint8) - cg.add(var.set_rc_code_2(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - # Set toto-specific defaults (only if user didn't configure repeat) - if CONF_REPEAT not in config: - template_ = await cg.templatable(3, args, cg.uint32) - cg.add(var.set_send_times(template_)) - template_ = await cg.templatable(36000, args, cg.uint32) - cg.add(var.set_send_wait(template_)) +def _toto_default_repeat(config: ConfigType) -> ConfigType: + # Toto remotes send each frame 3 times, 36 ms apart + if CONF_REPEAT in config: + return config + return {**config, CONF_REPEAT: {CONF_TIMES: 3, CONF_WAIT_TIME: 36000}} + + +register_protocol_action( + "toto", + "Toto", + TOTO_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint8), + (CONF_RC_CODE_2, "rc_code_2", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + validate=_toto_default_repeat, +) diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index ed9d6aa6712..539aa0d75ba 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -23,19 +23,4 @@ class Beo4Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Beo4) -template class Beo4Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, source) - TEMPLATABLE_VALUE(uint8_t, command) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Beo4Data data{}; - data.source = this->source_.value(x...); - data.command = this->command_.value(x...); - data.repeats = this->repeats_.value(x...); - Beo4Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index bfea463b7d4..f85df953056 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -20,15 +20,4 @@ class BrennenstuhlProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Brennenstuhl) -template class BrennenstuhlAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - BrennenstuhlData data{}; - data.code = this->code_.value(x...); - BrennenstuhlProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index c71390c267c..67a2d5a06a1 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -28,17 +28,4 @@ class ByronSXProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ByronSX) -template class ByronSXAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ByronSXData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - ByronSXProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 09bead18b3c..31e4f5c47e5 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -41,36 +41,6 @@ class CanalSatLDProtocol : public CanalSatBaseProtocol { DECLARE_REMOTE_PROTOCOL(CanalSat) -template class CanalSatAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatProtocol().encode(dst, data); - } -}; - DECLARE_REMOTE_PROTOCOL(CanalSatLD) -template class CanalSatLDAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatLDProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index 29a306ce291..d5ce29d436a 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -28,12 +28,4 @@ class CoolixProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Coolix) -template class CoolixAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, first) - TEMPLATABLE_VALUE(uint32_t, second) - void encode(RemoteTransmitData *dst, Ts... x) override { - CoolixProtocol().encode(dst, {this->first_.value(x...), this->second_.value(x...)}); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index f319b55f432..85ea1faa38a 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -20,17 +20,4 @@ class DishProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dish) -template class DishAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DishData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - DishProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 954c3cf1d38..06f77f8050d 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -27,21 +27,4 @@ class DooyaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dooya) -template class DooyaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, id) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, button) - TEMPLATABLE_VALUE(uint8_t, check) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DooyaData data{}; - data.id = this->id_.value(x...); - data.channel = this->channel_.value(x...); - data.button = this->button_.value(x...); - data.check = this->check_.value(x...); - DooyaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 4e879f0f75b..ef9dc39fb3b 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -26,19 +26,4 @@ class DraytonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Drayton) -template class DraytonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DraytonData data{}; - data.address = this->address_.value(x...); - data.channel = this->channel_.value(x...); - data.command = this->command_.value(x...); - DraytonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 663e50fb4b5..5afa28e07be 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -28,17 +28,4 @@ class DysonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dyson) -template class DysonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, code) - TEMPLATABLE_VALUE(uint8_t, index) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DysonData data{}; - data.code = this->code_.value(x...); - data.index = this->index_.value(x...); - DysonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index 0c8797af70c..55e8137b752 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -38,15 +38,4 @@ class GoboxProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Gobox) -template class GoboxAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, code); - - void encode(RemoteTransmitData *dst, Ts... x) override { - GoboxData data{}; - data.code = this->code_.value(x...); - GoboxProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/hob2hood_protocol.h b/esphome/components/remote_base/hob2hood_protocol.h index 44bd873a65b..dd3288103c8 100644 --- a/esphome/components/remote_base/hob2hood_protocol.h +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -28,12 +28,4 @@ class Hob2HoodProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Hob2Hood) -template class Hob2HoodAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(Hob2HoodCommand, command) - void encode(RemoteTransmitData *dst, Ts... x) override { - Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index 5911664fc39..58991fc8f4a 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -21,15 +21,4 @@ class JVCProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(JVC) -template class JVCAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - JVCData data{}; - data.data = this->data_.value(x...); - JVCProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 093f2904aa2..e391212e42d 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -32,25 +32,4 @@ class KeeloqProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Keeloq) -template class KeeloqAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, address) - TEMPLATABLE_VALUE(uint32_t, encrypted) - TEMPLATABLE_VALUE(uint8_t, command) - TEMPLATABLE_VALUE(bool, vlow) - TEMPLATABLE_VALUE(uint16_t, suffix) - TEMPLATABLE_VALUE(uint8_t, suffix_bits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - KeeloqData data{}; - data.address = this->address_.value(x...); - data.encrypted = this->encrypted_.value(x...); - data.command = this->command_.value(x...); - data.vlow = this->vlow_.value(x...); - data.suffix = this->suffix_.value(x...); - data.suffix_bits = this->suffix_bits_.value(x...); - KeeloqProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 91dfbadb0c2..41ca3a2b040 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -23,17 +23,4 @@ class LGProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(LG) -template class LGAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - LGData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - LGProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index f0d2410fe27..98a5fe20819 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -34,17 +34,4 @@ class MagiQuestProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(MagiQuest) -template class MagiQuestAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, magnitude) - TEMPLATABLE_VALUE(uint32_t, wand_id) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MagiQuestData data{}; - data.magnitude = this->magnitude_.value(x...); - data.wand_id = this->wand_id_.value(x...); - MagiQuestProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 1337f7a8b32..849952c40f8 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -21,19 +21,4 @@ class NECProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(NEC) -template class NECAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint16_t, command) - TEMPLATABLE_VALUE(uint16_t, command_repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - NECData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - data.command_repeats = this->command_repeats_.value(x...); - NECProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebf85387b04..b4d2ec40304 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -31,22 +31,4 @@ class NexaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Nexa) -template class NexaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, device) - TEMPLATABLE_VALUE(uint8_t, group) - TEMPLATABLE_VALUE(uint8_t, state) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, level) - void encode(RemoteTransmitData *dst, Ts... x) override { - NexaData data{}; - data.device = this->device_.value(x...); - data.group = this->group_.value(x...); - data.state = this->state_.value(x...); - data.channel = this->channel_.value(x...); - data.level = this->level_.value(x...); - NexaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/onkyori_protocol.h b/esphome/components/remote_base/onkyori_protocol.h index c6f0f1dd97f..55efb1e9a3c 100644 --- a/esphome/components/remote_base/onkyori_protocol.h +++ b/esphome/components/remote_base/onkyori_protocol.h @@ -19,15 +19,4 @@ class OnkyoRIProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(OnkyoRI) -template class OnkyoRIAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - OnkyoRIData data{}; - data.data = this->data_.value(x...); - OnkyoRIProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index 84df3c08b72..da8e3a0ff7f 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -23,17 +23,4 @@ class PanasonicProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Panasonic) -template class PanasonicAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PanasonicData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - PanasonicProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index d02bd3451f2..b3d71d7ac9c 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -20,17 +20,4 @@ class PioneerProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pioneer) -template class PioneerAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, rc_code_1) - TEMPLATABLE_VALUE(uint16_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PioneerData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - PioneerProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index f6f0f33c6e2..d720fb11cc0 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -21,17 +21,4 @@ class RC5Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC5) -template class RC5Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RC5Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC5Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 7205fb6221a..38db90c97bc 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,6 +182,15 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ +void RemoteTransmitterBase::transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, + uint32_t send_wait) { + auto call = this->transmit(); + encode(call.get_data(), data); + call.set_send_times(send_times); + call.set_send_wait(send_wait); + call.perform(); +} + void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, [[maybe_unused]] uint16_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 36d2a44c509..be0c7a75c56 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -193,14 +193,34 @@ class RemoteTransmitterBase : public RemoteComponentBase { } template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { - auto call = this->transmit(); - Protocol().encode(call.get_data(), data); - call.set_send_times(send_times); - call.set_send_wait(send_wait); - call.perform(); + this->transmit_encoded_(&encode_thunk, &data, send_times, send_wait); + } + /// Protocol data filled field by field, then sent by perform(); the call a generated transmit action builds. + template class ProtocolCall { + public: + explicit ProtocolCall(RemoteTransmitterBase *parent) : parent_(parent) {} + void set_send_times(uint32_t send_times) { this->send_times_ = send_times; } + void set_send_wait(uint32_t send_wait) { this->send_wait_ = send_wait; } + void perform() { this->parent_->transmit(this->data, this->send_times_, this->send_wait_); } + + typename Protocol::ProtocolData data{}; + + protected: + RemoteTransmitterBase *parent_; + uint32_t send_times_{1}; + uint32_t send_wait_{0}; + }; + template ProtocolCall protocol_call() { + return ProtocolCall(this); } protected: + using EncodeFn = void (*)(RemoteTransmitData *dst, const void *data); + // One out-of-line send path, so each transmit

() call site only fills its data. + void transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, uint32_t send_wait); + template static void encode_thunk(RemoteTransmitData *dst, const void *data) { + Protocol().encode(dst, *static_cast(data)); + } void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; /// Platforms that report completion later wait out the previous frame here, before send_() diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 8db025f812a..a8fab145802 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -19,15 +19,4 @@ class RoombaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Roomba) -template class RoombaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RoombaData data{}; - data.data = this->data_.value(x...); - RoombaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index df4e1af8d8c..c70a98b1121 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -23,17 +23,4 @@ class Samsung36Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung36) -template class Samsung36Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Samsung36Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - Samsung36Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index dfa22ff85ce..47aaa08e3ab 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -21,17 +21,4 @@ class SamsungProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung) -template class SamsungAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SamsungData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SamsungProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index f83b2908b61..9bf488099bc 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -23,17 +23,4 @@ class SonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Sony) -template class SonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 40a5c2daec9..56a4d7c6d3b 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -24,19 +24,4 @@ class SymphonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Symphony) -template class SymphonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SymphonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - data.repeats = this->repeats_.value(x...); - SymphonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 35d5af314cb..482b338cdad 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -21,17 +21,4 @@ class ToshibaAcProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ToshibaAc) -template class ToshibaAcAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, rc_code_1) - TEMPLATABLE_VALUE(uint64_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ToshibaAcData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - ToshibaAcProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 8e965a5c739..c845087684d 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -23,19 +23,4 @@ class TotoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Toto) -template class TotoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, rc_code_1) - TEMPLATABLE_VALUE(uint8_t, rc_code_2) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - TotoData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - data.command = this->command_.value(x...); - TotoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/tests/component_tests/remote_transmitter/__init__.py b/tests/component_tests/remote_transmitter/__init__.py index e69de29bb2d..7b2d567ec7f 100644 --- a/tests/component_tests/remote_transmitter/__init__.py +++ b/tests/component_tests/remote_transmitter/__init__.py @@ -0,0 +1,13 @@ +"""Helpers for the remote_transmitter component tests.""" + +import re + +_PROTOCOL_CALL_BODY = re.compile( + r"auto apply_call = \S+->protocol_call\(\);\n(.*?)apply_call\.perform\(\);", + re.DOTALL, +) + + +def protocol_call_bodies(main_cpp: str) -> list[str]: + """The statements of each generated transmit action, in config order.""" + return _PROTOCOL_CALL_BODY.findall(main_cpp) diff --git a/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml b/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml new file mode 100644 index 00000000000..5cd9227e6db --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml @@ -0,0 +1,41 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: Toto default repeat + on_press: + - remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + - platform: template + name: Toto explicit repeat + on_press: + - remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + repeat: + times: 2 + wait_time: 20ms + - platform: template + name: NEC default wait + on_press: + - remote_transmitter.transmit_nec: + address: 0x4242 + command: 0x8484 + repeat: 4 + - platform: template + name: NEC once + on_press: + - remote_transmitter.transmit_nec: + address: 0x4242 + command: 0x8484 diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index aafab1f2c8e..cbc1efb0a69 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -1,18 +1,23 @@ -"""transmit_keeloq sets the suffix only when suffix bits are sent.""" +"""transmit_keeloq sets the suffix only when the config names it.""" from collections.abc import Callable from pathlib import Path +from tests.component_tests.remote_transmitter import protocol_call_bodies -def test_keeloq_suffix_is_set_only_with_suffix_bits( + +def test_keeloq_suffix_is_set_only_when_configured( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) - - # The plain word gets the same setters as before the suffix existed. - assert "remote_base_keeloqaction_id->set_suffix" not in main_cpp - # A constant and a lambda width both set the suffix. - for action in ("remote_base_keeloqaction_id_2", "remote_base_keeloqaction_id_3"): - assert f"{action}->set_suffix([]() -> uint16_t" in main_cpp - assert f"{action}->set_suffix_bits([]() -> uint8_t" in main_cpp + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 3 + plain, constant, lambda_ = bodies + # The plain word fills only the HCS301 fields. + assert "data.suffix" not in plain + assert "apply_call.data.suffix = 0x2A;" in constant + assert "apply_call.data.suffix_bits = 6;" in constant + # A lambda is called inline. + assert "apply_call.data.suffix = static_cast(0x2A);" in lambda_ + assert "apply_call.data.suffix_bits = static_cast(6);" in lambda_ diff --git a/tests/component_tests/remote_transmitter/test_transmit_repeat.py b/tests/component_tests/remote_transmitter/test_transmit_repeat.py new file mode 100644 index 00000000000..691c9e6b4e2 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_transmit_repeat.py @@ -0,0 +1,27 @@ +"""repeat on a generated transmit action, and toto's default.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_transmit_repeat( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("transmit_repeat.yaml")) + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 4 + toto_default, toto_explicit, nec_repeat, nec_once = bodies + # toto sends 3 times 36 ms apart unless the config says otherwise. + assert "apply_call.set_send_times(3);" in toto_default + assert "apply_call.set_send_wait(36000);" in toto_default + assert "apply_call.set_send_times(2);" in toto_explicit + assert "apply_call.set_send_wait(20000);" in toto_explicit + # A bare count gets the 25 ms default wait, in microseconds. + assert "apply_call.set_send_times(4);" in nec_repeat + assert "apply_call.set_send_wait(25000);" in nec_repeat + # Without repeat neither setter is emitted; the call sends once. + assert "set_send_times" not in nec_once + assert "set_send_wait" not in nec_once From 32f416109e69a6d5a71a2ce41f4745a359527d77 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 334/435] 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 612bda6b1aa..a9dbaa3b8d7 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 4688df3b94aee0f794c9ed3a93ad4ae8bb19474f Mon Sep 17 00:00:00 2001 From: Harald Nagel Date: Sun, 11 Oct 2026 20:06:46 -0700 Subject: [PATCH 335/435] [mcp9808] Remove k7hpn as codeowner (#20561) 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/mcp9808/sensor.py | 1 - 2 files changed, 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4264479349c..c75693d2d3f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,7 +347,6 @@ esphome/components/mcp4461/* @p1ngb4ck esphome/components/mcp4728/* @berfenger esphome/components/mcp47a1/* @jesserockz esphome/components/mcp9600/* @mreditor97 -esphome/components/mcp9808/* @k7hpn esphome/components/md5/* @esphome/core esphome/components/mdns/* @esphome/core esphome/components/media_player/* @jesserockz diff --git a/esphome/components/mcp9808/sensor.py b/esphome/components/mcp9808/sensor.py index 1daaa9c131a..2238e3d8530 100644 --- a/esphome/components/mcp9808/sensor.py +++ b/esphome/components/mcp9808/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -CODEOWNERS = ["@k7hpn"] DEPENDENCIES = ["i2c"] mcp9808_ns = cg.esphome_ns.namespace("mcp9808") From 0c6543c4a61d8685d9d6eafb905e051f8a5bf4a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 17:38:38 -1000 Subject: [PATCH 336/435] [remote_base] Name transmit as a template in ProtocolCall::perform (#20566) --- esphome/components/remote_base/remote_base.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index be0c7a75c56..fc2f3a71301 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -201,7 +201,7 @@ class RemoteTransmitterBase : public RemoteComponentBase { explicit ProtocolCall(RemoteTransmitterBase *parent) : parent_(parent) {} void set_send_times(uint32_t send_times) { this->send_times_ = send_times; } void set_send_wait(uint32_t send_wait) { this->send_wait_ = send_wait; } - void perform() { this->parent_->transmit(this->data, this->send_times_, this->send_wait_); } + void perform() { this->parent_->template transmit(this->data, this->send_times_, this->send_wait_); } typename Protocol::ProtocolData data{}; From 698935c3213eae0900394744707d8d47b80a6fea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 17:38:41 -1000 Subject: [PATCH 337/435] [seeed_mr24hpc1] Remove the duplicate startup delay constant left by the beta merge (#20569) --- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 832c9dc1a32..8d5103cbbaf 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -11,8 +11,6 @@ ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); static constexpr uint32_t STARTUP_DELAY_MS = 1000; -static constexpr uint32_t STARTUP_DELAY_MS = 1000; - // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. void MR24HPC1Component::dump_config() { From 5c1c946e883549e9b14600e1d7fc3f9760ac4a64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 18:09:11 -1000 Subject: [PATCH 338/435] [wake_on_lan] Use the socket UDP API on ESP8266 and RP2040 (#20556) --- esphome/components/wake_on_lan/button.py | 15 ++++------ .../components/wake_on_lan/wake_on_lan.cpp | 28 ++++--------------- esphome/components/wake_on_lan/wake_on_lan.h | 12 ++------ 3 files changed, 12 insertions(+), 43 deletions(-) diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index 5e756d0057a..f5d15f3cfa4 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -1,33 +1,28 @@ import esphome.codegen as cg -from esphome.components import button +from esphome.components import button, socket import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE from esphome.types import ConfigType +AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] -def AUTO_LOAD() -> list[str]: - if CORE.is_esp8266 or CORE.is_rp2: - return [] - return ["socket"] - - CONF_TARGET_MAC_ADDRESS = "target_mac_address" wake_on_lan_ns = cg.esphome_ns.namespace("wake_on_lan") WakeOnLanButton = wake_on_lan_ns.class_("WakeOnLanButton", button.Button, cg.Component) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( button.button_schema(WakeOnLanButton) .extend(cv.COMPONENT_SCHEMA) .extend( { cv.Required(CONF_TARGET_MAC_ADDRESS): cv.mac_address, } - ) + ), + socket.consume_sockets(1, "wake_on_lan", socket.SocketType.UDP), ) diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index 52dfe98aad7..3815312c68f 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -1,7 +1,6 @@ #include "wake_on_lan.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/log.h" -#include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" namespace esphome::wake_on_lan { @@ -25,12 +24,15 @@ void WakeOnLanButton::dump_config() { } void WakeOnLanButton::press_action() { + if (this->broadcast_socket_ == nullptr) { + ESP_LOGW(TAG, "Socket not available"); + return; + } if (!network::is_connected()) { ESP_LOGW(TAG, "Network not connected"); return; } ESP_LOGI(TAG, "Sending Wake-on-LAN Packet"); -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) struct sockaddr_storage saddr {}; auto addr_len = socket::set_sockaddr(reinterpret_cast(&saddr), sizeof(saddr), "255.255.255.255", this->port_); @@ -47,29 +49,10 @@ void WakeOnLanButton::press_action() { addr_len) <= 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } -#else - IPAddress broadcast = IPAddress(255, 255, 255, 255); - for (auto ip : esphome::network::get_ip_addresses()) { - if (ip.is_ip4()) { - if (this->udp_client_.beginPacketMulticast(broadcast, 9, ip, 128) != 0) { - this->udp_client_.write(PREFIX, 6); - for (size_t i = 0; i < 16; i++) { - this->udp_client_.write(macaddr_, 6); - } - if (this->udp_client_.endPacket() != 0) - return; - ESP_LOGW(TAG, "WOL broadcast failed"); - return; - } - } - } - ESP_LOGW(TAG, "No ip4 addresses to broadcast to"); -#endif } void WakeOnLanButton::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -85,7 +68,6 @@ void WakeOnLanButton::setup() { if (err != 0) { this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } -#endif } } // namespace esphome::wake_on_lan diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index cef60c54f86..6f89754f56b 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -2,13 +2,9 @@ #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" +#include "esphome/components/socket/socket.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) -#include "esphome/components/socket/socket.h" -#else -#include "WiFiUdp.h" -#endif namespace esphome::wake_on_lan { @@ -21,11 +17,7 @@ class WakeOnLanButton final : public button::Button, public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_{}; -#else - WiFiUDP udp_client_{}; -#endif + std::unique_ptr broadcast_socket_{}; void press_action() override; uint16_t port_{9}; uint8_t macaddr_[MAC_ADDRESS_SIZE]; From 3f21ddf6ffce29d041ce8973184b75dbe745bf08 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:12:22 +1100 Subject: [PATCH 339/435] [lvgl] Add a color picker widget (#17883) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston --- esphome/components/lvgl/color_picker.h | 625 +++++++++++++++++ esphome/components/lvgl/defines.py | 5 +- esphome/components/lvgl/gradient.py | 4 +- esphome/components/lvgl/styles.py | 26 +- esphome/components/lvgl/widgets/__init__.py | 128 +++- .../components/lvgl/widgets/color_picker.py | 240 +++++++ esphome/core/defines.h | 11 + .../lvgl/config/color_picker.yaml | 133 ++++ .../lvgl/config/color_picker_theme.yaml | 48 ++ .../lvgl/config/named_styles.yaml | 47 ++ .../component_tests/lvgl/test_color_picker.py | 628 ++++++++++++++++++ .../component_tests/lvgl/test_named_styles.py | 57 ++ tests/components/lvgl/lvgl-package.yaml | 52 ++ tests/components/lvgl/test.host.yaml | 5 + .../fixtures/lvgl_color_picker_render.yaml | 82 +++ .../test_lvgl_color_picker_render.py | 105 +++ 16 files changed, 2171 insertions(+), 25 deletions(-) create mode 100644 esphome/components/lvgl/color_picker.h create mode 100644 esphome/components/lvgl/widgets/color_picker.py create mode 100644 tests/component_tests/lvgl/config/color_picker.yaml create mode 100644 tests/component_tests/lvgl/config/color_picker_theme.yaml create mode 100644 tests/component_tests/lvgl/config/named_styles.yaml create mode 100644 tests/component_tests/lvgl/test_color_picker.py create mode 100644 tests/component_tests/lvgl/test_named_styles.py create mode 100644 tests/integration/fixtures/lvgl_color_picker_render.yaml create mode 100644 tests/integration/test_lvgl_color_picker_render.py diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h new file mode 100644 index 00000000000..e2392694f07 --- /dev/null +++ b/esphome/components/lvgl/color_picker.h @@ -0,0 +1,625 @@ +#pragma once + +#include "lvgl_esphome.h" +#include "esphome/core/color.h" + +#include +#include +#include +#include +#include + +#ifdef USE_LVGL_COLOR_PICKER + +namespace esphome::lvgl { + +// The hue gradients need seven stops; gradient.py raises the limit when a colour picker is in use. +static_assert(LV_GRADIENT_MAX_STOPS >= 7, "color_picker needs LV_GRADIENT_MAX_STOPS >= 7"); + +class LvColorPickerType : public LvCompound { + public: + enum SliderIndex : size_t { + SLIDER_HUE, + SLIDER_SATURATION, + SLIDER_BRIGHTNESS, + SLIDER_RED, + SLIDER_GREEN, + SLIDER_BLUE, + SLIDER_COUNT, + }; + + enum SliderFlag : uint8_t { + SLIDER_FLAG_HUE = 1 << SLIDER_HUE, + SLIDER_FLAG_SATURATION = 1 << SLIDER_SATURATION, + SLIDER_FLAG_BRIGHTNESS = 1 << SLIDER_BRIGHTNESS, + SLIDER_FLAG_RED = 1 << SLIDER_RED, + SLIDER_FLAG_GREEN = 1 << SLIDER_GREEN, + SLIDER_FLAG_BLUE = 1 << SLIDER_BLUE, + }; + + explicit LvColorPickerType(uint8_t sliders, bool use_wheel) : sliders_mask_(sliders), use_wheel_(use_wheel) {} + + // Returns null for a slider the widget was not built with. + lv_obj_t *get_slider(size_t index) const { return this->sliders_[index]; } + + void set_tint_knobs(bool tint_knobs) { this->tint_knobs_ = tint_knobs; } + + void set_color(lv_color_t color) { + lv_color32_t c32 = lv_color_to_32(color, LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + if (this->obj != nullptr) + this->update_color_(); + } + Color get_color() const { return this->state_; } + + void set_obj(lv_obj_t *outer) override { + LvCompound::set_obj(outer); + init_shared(); + // The far end of these two follows the current hue. + init_bar(&this->saturation_bar_, LV_GRAD_DIR_HOR, lv_color_white(), lv_color_hex(0xFF0000)); + init_bar(&this->brightness_bar_, LV_GRAD_DIR_HOR, lv_color_black(), lv_color_white()); + + auto channels = static_cast(this->enabled_(SLIDER_RED)) + + static_cast(this->enabled_(SLIDER_GREEN)) + + static_cast(this->enabled_(SLIDER_BLUE)); + + // No layout on the outer object, so LV_SIZE_CONTENT uses the size from self_size_cb. + lv_obj_add_event_cb(outer, self_size_cb, LV_EVENT_GET_SELF_SIZE, nullptr); + lv_obj_add_event_cb(outer, size_changed_cb, LV_EVENT_SIZE_CHANGED, this); + lv_obj_set_style_pad_all(outer, KNOB_OVERHANG, LV_PART_MAIN); + + int32_t left_width = 0; + lv_obj_t *left = nullptr; + if (this->enabled_(SLIDER_HUE)) { + left_width = HUE_WIDTH_PCT; + left = this->create_slider_(outer, SLIDER_HUE, "Hue", true, &hue_bar, 360); + lv_obj_set_size(left, lv_pct(HUE_WIDTH_PCT), lv_pct(100)); + } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + else if (this->use_wheel_) { + left_width = WHEEL_WIDTH_PCT; + left = this->create_wheel_(outer); + lv_obj_set_size(left, lv_pct(WHEEL_WIDTH_PCT), lv_pct(WHEEL_WIDTH_PCT)); + } +#endif + 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); + } + + // The text rows keep their natural height and the bars between them take what is left, + // so a small widget gets thinner bars rather than clipped text. + auto *middle = create_flex(outer, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START); + lv_obj_set_style_align(middle, LV_ALIGN_RIGHT_MID, LV_PART_MAIN); + lv_obj_set_style_pad_row(middle, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_size(middle, lv_pct(100 - left_width), lv_pct(100)); + + // A dark bar needs light text, unlike every other value label. + if (this->enabled_(SLIDER_BRIGHTNESS)) + this->create_row_slider_(middle, SLIDER_BRIGHTNESS, "Brightness", &this->brightness_bar_, lv_color_white()); + + auto *inner = create_flex(middle, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_START); + lv_obj_set_width(inner, lv_pct(100)); + lv_obj_set_flex_grow(inner, 1); + + // The channel bars keep a fixed width each, so dropping one widens the swatch. + auto channels_width = CHANNEL_WIDTH_PCT * channels; + auto *indicator_container = create_flex(inner, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + lv_obj_set_size(indicator_container, lv_pct(CONTENT_WIDTH_PCT - channels_width), lv_pct(100)); + lv_obj_set_style_pad_ver(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + + this->color_text_ = lv_label_create(indicator_container); + this->color_indicator_ = lv_obj_create(indicator_container); + lv_obj_set_style_border_color(this->color_indicator_, lv_color_hex(0x808080), LV_PART_MAIN); + lv_obj_set_style_border_width(this->color_indicator_, 1, LV_PART_MAIN); + lv_obj_set_style_radius(this->color_indicator_, 0, LV_PART_MAIN); + lv_obj_set_width(this->color_indicator_, lv_pct(90)); + lv_obj_set_flex_grow(this->color_indicator_, 1); + + if (channels != 0) { + auto *rgb_container = create_flex(inner, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_SPACE_BETWEEN); + lv_obj_set_size(rgb_container, lv_pct(channels_width), lv_pct(100)); + struct ChannelInfo { + SliderIndex index; + const char *name; + const lv_grad_dsc_t *grad; + }; + const std::array channel_info{{ + {SLIDER_RED, "R", &red_bar}, + {SLIDER_GREEN, "G", &green_bar}, + {SLIDER_BLUE, "B", &blue_bar}, + }}; + for (const auto &channel : channel_info) { + if (!this->enabled_(channel.index)) + continue; + auto *container = this->create_slider_(rgb_container, channel.index, channel.name, true, channel.grad, 255); + lv_obj_set_size(container, lv_pct(100 / channels), lv_pct(100)); + this->add_value_label_(channel.index, lv_color_black()); + } + } + + if (this->enabled_(SLIDER_SATURATION)) + this->create_row_slider_(middle, SLIDER_SATURATION, "Saturation", &this->saturation_bar_, lv_color_black()); + + this->update_color_(); + + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->sliders_[index] == nullptr) + continue; + lv_obj_add_event_cb(this->sliders_[index], index <= SLIDER_BRIGHTNESS ? hsv_changed_cb : rgb_changed_cb, + LV_EVENT_VALUE_CHANGED, this); + } + + bubble_events(outer); + } + + protected: + // LVGL keeps only pointers to gradients and styles, so one copy serves every colour picker. + static void init_shared() { + if (shared_ready) + return; + shared_ready = true; + + // Clockwise from red at 3 o'clock, matching how lv_color_hsv_to_rgb() reads the angle. + std::array hues{lv_color_hex(0xFF0000), lv_color_hex(0xFFFF00), lv_color_hex(0x00FF00), + lv_color_hex(0x00FFFF), lv_color_hex(0x0000FF), lv_color_hex(0xFF00FF), + lv_color_hex(0xFF0000)}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + lv_grad_conical_init(&wheel_hue_grad, lv_pct(50), lv_pct(50), 0, 360, LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_hue_grad, hues.data(), nullptr, nullptr, static_cast(hues.size())); + // White at the centre fading out at the rim, giving saturation over the hue wheel. + const lv_color_t whites[] = {lv_color_white(), lv_color_white()}; + const lv_opa_t white_opas[] = {LV_OPA_COVER, LV_OPA_TRANSP}; + lv_grad_radial_init(&wheel_white_grad, lv_pct(50), lv_pct(50), lv_pct(100), lv_pct(50), LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_white_grad, whites, white_opas, nullptr, 2); +#endif + // A vertical slider has its minimum at the bottom, so the hue bar runs the other way. + std::reverse(hues.begin(), hues.end()); + lv_grad_vertical_init(&hue_bar); + lv_grad_init_stops(&hue_bar, hues.data(), nullptr, nullptr, static_cast(hues.size())); + + init_bar(&red_bar, LV_GRAD_DIR_VER, lv_color_hex(0xFF0000), lv_color_white()); + init_bar(&green_bar, LV_GRAD_DIR_VER, lv_color_hex(0x00FF00), lv_color_white()); + init_bar(&blue_bar, LV_GRAD_DIR_VER, lv_color_hex(0x0000FF), lv_color_white()); + + lv_style_init(&plain_box); + lv_style_set_bg_opa(&plain_box, LV_OPA_TRANSP); + lv_style_set_border_width(&plain_box, 0); + lv_style_set_pad_all(&plain_box, 0); + lv_style_set_pad_row(&plain_box, 0); + lv_style_set_pad_column(&plain_box, 0); + + init_slider_styles(&slider_vert, &slider_vert_knob, true); + init_slider_styles(&slider_horz, &slider_horz_knob, false); + } + + static void init_bar(lv_grad_dsc_t *grad, lv_grad_dir_t dir, lv_color_t from, lv_color_t to) { + const lv_color_t colors[] = {from, to}; + grad->dir = dir; + lv_grad_init_stops(grad, colors, nullptr, nullptr, 2); + } + + static void init_slider_styles(lv_style_t *bar, lv_style_t *knob, bool vertical) { + lv_style_init(bar); + lv_style_set_align(bar, LV_ALIGN_CENTER); + lv_style_set_bg_opa(bar, LV_OPA_COVER); + lv_style_set_pad_all(bar, 0); + lv_style_set_radius(bar, 0); + // A horizontal bar's thickness is set by update_bar_thickness_(). + if (vertical) { + lv_style_set_width(bar, lv_pct(50)); + lv_style_set_height(bar, lv_pct(90)); + } else { + lv_style_set_width(bar, lv_pct(90)); + } + + lv_style_init(knob); + lv_style_set_bg_color(knob, lv_color_white()); + lv_style_set_border_width(knob, 1); + lv_style_set_outline_color(knob, lv_color_black()); + lv_style_set_radius(knob, 0); + // Both axes are set, or the theme picks a knob size the layout has not left room for. + int32_t along = -KNOB_TRIM; + int32_t across = KNOB_OVERHANG; + lv_style_set_pad_top(knob, vertical ? along : across); + lv_style_set_pad_bottom(knob, vertical ? along : across); + lv_style_set_pad_left(knob, vertical ? across : along); + lv_style_set_pad_right(knob, vertical ? across : along); + } + + // A borderless, unpadded container. + 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); + return obj; + } + + static lv_obj_t *create_flex(lv_obj_t *parent, lv_flex_flow_t flow, lv_flex_align_t main_place, + lv_flex_align_t track_place = LV_FLEX_ALIGN_START) { + auto *obj = create_box(parent); + lv_obj_set_layout(obj, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(obj, flow); + lv_obj_set_flex_align(obj, main_place, LV_FLEX_ALIGN_CENTER, track_place); + return obj; + } + + // Preferred size, in lines of the text font. The widget is square. + static constexpr int SELF_SIZE_FONT_MULTIPLE = 16; + + // At either end of its travel half the knob lies outside the bar. KNOB_TRIM pulls it back + // along the bar, and KNOB_OVERHANG makes it stand out across the bar. + static constexpr int KNOB_TRIM = 4; + static constexpr int KNOB_OVERHANG = 4; + + // Layout proportions, as percentages. + static constexpr int32_t HUE_WIDTH_PCT = 15; + static constexpr int32_t CHANNEL_WIDTH_PCT = 19; + static constexpr int32_t CONTENT_WIDTH_PCT = 99; + static constexpr int32_t WHEEL_WIDTH_PCT = 45; + static constexpr int32_t WHEEL_HANDLE_SIZE = 14; + + bool enabled_(SliderIndex index) const { return (this->sliders_mask_ & (1 << index)) != 0; } + bool has_(SliderIndex index) const { return this->sliders_[index] != nullptr; } + bool can_change_saturation_() const { + return this->use_wheel_ || (this->sliders_mask_ & + (SLIDER_FLAG_SATURATION | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != 0; + } + bool can_change_brightness_() const { + return (this->sliders_mask_ & (SLIDER_FLAG_BRIGHTNESS | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != + 0; + } + + // A slider the widget was built without keeps the value held internally. + int32_t slider_value_(SliderIndex index, int32_t fallback) const { + return this->has_(index) ? lv_slider_get_value(this->sliders_[index]) : fallback; + } + + void set_slider_value_(SliderIndex index, int32_t value) { + if (this->has_(index)) + lv_slider_set_value(this->sliders_[index], value, LV_ANIM_OFF); + } + + // Builds a slider with a name label above it, and returns its container. + lv_obj_t *create_slider_(lv_obj_t *parent, SliderIndex index, const char *name, bool vertical, + const lv_grad_dsc_t *grad, int32_t max) { + // A column keeps the label on its own row however large the font is. + auto *container = create_flex(parent, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + if (vertical) { + // How far the knob reaches past the ends depends on the font, so follow style changes. + update_knob_clearance(container); + lv_obj_add_event_cb(container, knob_clearance_cb, LV_EVENT_STYLE_CHANGED, nullptr); + } else { + lv_obj_set_style_pad_bottom(container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, KNOB_OVERHANG, LV_PART_MAIN); + } + lv_label_set_text(lv_label_create(container), name); + + auto *slider = lv_slider_create(container); + this->sliders_[index] = slider; + lv_obj_add_style(slider, vertical ? &slider_vert : &slider_horz, LV_PART_MAIN); + lv_obj_set_style_bg_grad(slider, grad, LV_PART_MAIN); + lv_obj_set_style_bg_opa(slider, LV_OPA_TRANSP, LV_PART_INDICATOR); + lv_obj_add_style(slider, vertical ? &slider_vert_knob : &slider_horz_knob, LV_PART_KNOB); + if (vertical) + lv_obj_set_flex_grow(slider, 1); + lv_slider_set_range(slider, 0, max); + lv_slider_set_mode(slider, LV_SLIDER_MODE_NORMAL); + return container; + } + + void create_row_slider_(lv_obj_t *parent, SliderIndex index, const char *name, const lv_grad_dsc_t *grad, + lv_color_t text_color) { + auto *container = this->create_slider_(parent, index, name, false, grad, 100); + lv_obj_set_size(container, lv_pct(100), LV_SIZE_CONTENT); + this->add_value_label_(index, text_color); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // A full-size circle filled with a gradient. + static lv_obj_t *create_disc(lv_obj_t *parent, const lv_grad_dsc_t *grad) { + auto *disc = create_box(parent); + lv_obj_set_size(disc, lv_pct(100), lv_pct(100)); + lv_obj_set_style_align(disc, LV_ALIGN_CENTER, LV_PART_MAIN); + 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); + return disc; + } + + // Builds the colour wheel that replaces the hue and saturation sliders, and returns its container. + lv_obj_t *create_wheel_(lv_obj_t *parent) { + auto *container = create_box(parent); + 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); + + this->wheel_handle_ = lv_obj_create(this->wheel_); + lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); + lv_obj_set_style_align(this->wheel_handle_, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_radius(this->wheel_handle_, LV_RADIUS_CIRCLE, LV_PART_MAIN); + 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); + + // 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); + lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSING, this); + // The handle can only be placed once the wheel has been laid out. + lv_obj_add_event_cb(this->wheel_, wheel_size_cb, LV_EVENT_SIZE_CHANGED, this); + return container; + } +#endif + + 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); + auto *label = lv_label_create(slider); + this->values_[index] = label; + lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_color(label, color, LV_PART_MAIN); + } + + // Leaves room at the ends of a vertical slider for the overhanging knob: half a line of text. + static void update_knob_clearance(lv_obj_t *container) { + const auto *font = lv_obj_get_style_text_font(container, LV_PART_MAIN); + auto clearance = static_cast(lv_font_get_line_height(font)) / 2; + if (clearance == lv_obj_get_style_pad_bottom(container, LV_PART_MAIN)) + return; + lv_obj_set_style_pad_bottom(container, clearance, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, clearance, LV_PART_MAIN); + } + + static void knob_clearance_cb(lv_event_t *event) { update_knob_clearance(lv_event_get_current_target_obj(event)); } + + static LvColorPickerType *from_event(lv_event_t *event) { + return static_cast(lv_event_get_user_data(event)); + } + static void hsv_changed_cb(lv_event_t *event) { from_event(event)->update_hsv_(); } + static void rgb_changed_cb(lv_event_t *event) { from_event(event)->update_rgb_(); } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + static void wheel_touch_cb(lv_event_t *event) { from_event(event)->update_from_wheel_touch_(); } + static void wheel_size_cb(lv_event_t *event) { from_event(event)->update_wheel_handle_(); } +#endif + static void size_changed_cb(lv_event_t *event) { from_event(event)->update_bar_thickness_(); } + + // Gives the horizontal bars the thickness of the vertical ones, half a hue column, so every + // knob is the same size. A knob is sized from the thickness of its bar. + void update_bar_thickness_() { + auto thickness = lv_obj_get_content_width(this->obj) * HUE_WIDTH_PCT / 200; + for (auto index : {SLIDER_SATURATION, SLIDER_BRIGHTNESS}) { + if (this->has_(index) && lv_obj_get_style_height(this->sliders_[index], LV_PART_MAIN) != thickness) + lv_obj_set_style_height(this->sliders_[index], thickness, LV_PART_MAIN); + } + } + + // LVGL also counts this size when working out the scroll area, so a widget given a fixed + // width must not claim it, or it would scroll whenever it is smaller. + static void self_size_cb(lv_event_t *event) { + auto *obj = lv_event_get_current_target_obj(event); + if (lv_obj_get_style_width(obj, LV_PART_MAIN) != LV_SIZE_CONTENT) + return; + const auto *font = lv_obj_get_style_text_font(obj, LV_PART_MAIN); + auto self_size = static_cast(lv_font_get_line_height(font)) * SELF_SIZE_FONT_MULTIPLE; + auto *point = static_cast(lv_event_get_param(event)); + point->x = self_size; + point->y = self_size; + } + + void update_text_() const { + // "#" + 3x "%02X" + null + char buf[8]; + snprintf(buf, sizeof(buf), "#%02X%02X%02X", static_cast(this->state_.r), + static_cast(this->state_.g), static_cast(this->state_.b)); + lv_label_set_text(this->color_text_, buf); + } + + void update_hue_bar_(SliderIndex index, lv_grad_dsc_t *grad, lv_color_t color) { + if (!this->has_(index)) + return; + grad->stops[1].color = color; + lv_obj_invalidate(this->sliders_[index]); + } + + void update_values_() const { + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->values_[index] == nullptr) + continue; + // up to 3 digits + null + char buf[4]; + snprintf(buf, sizeof(buf), "%d", static_cast(lv_slider_get_value(this->sliders_[index]))); + lv_label_set_text(this->values_[index], buf); + } + } + + void tint_knob_(SliderIndex index, lv_color_t color) const { + if (this->has_(index)) + lv_obj_set_style_bg_color(this->sliders_[index], color, LV_PART_KNOB); + } + + // Tints each knob with the colour of its bar at the current value. The brightness knob + // shows the selected colour instead, since its bar leaves out saturation. + void update_knobs_(lv_color_t full_hue) const { + if (!this->tint_knobs_) + return; + this->tint_knob_(SLIDER_HUE, full_hue); + this->tint_knob_(SLIDER_SATURATION, lv_color_hsv_to_rgb(this->hue_, this->saturation_, 100)); + this->tint_knob_(SLIDER_BRIGHTNESS, this->state_); + this->tint_knob_(SLIDER_RED, lv_color_make(255, 255 - this->state_.r, 255 - this->state_.r)); + this->tint_knob_(SLIDER_GREEN, lv_color_make(255 - this->state_.g, 255, 255 - this->state_.g)); + this->tint_knob_(SLIDER_BLUE, lv_color_make(255 - this->state_.b, 255 - this->state_.b, 255)); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Returns the wheel's radius, which is zero until it has been laid out. + double wheel_radius_(lv_area_t *area) const { + lv_obj_get_coords(this->wheel_, area); + return static_cast(area->x2 - area->x1) / 2.0; + } + + void update_wheel_handle_() { + if (this->wheel_handle_ == nullptr) + return; + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto angle_rad = static_cast(this->hue_) * (std::numbers::pi / 180.0); + auto distance = (static_cast(this->saturation_) / 100.0) * radius; + lv_obj_set_style_translate_x(this->wheel_handle_, static_cast(std::lround(distance * std::cos(angle_rad))), + LV_PART_MAIN); + lv_obj_set_style_translate_y(this->wheel_handle_, static_cast(std::lround(distance * std::sin(angle_rad))), + LV_PART_MAIN); + } + + // The angle from the centre gives the hue, the distance the saturation. Brightness is unchanged. + void update_from_wheel_touch_() { + lv_indev_t *indev = lv_indev_active(); + if (indev == nullptr) + return; + lv_point_t point; + lv_indev_get_point(indev, &point); + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto dx = point.x - (area.x1 + area.x2) / 2.0; + auto dy = point.y - (area.y1 + area.y2) / 2.0; + auto angle = std::atan2(dy, dx) * (180.0 / std::numbers::pi); + if (angle < 0) + angle += 360.0; + this->hue_ = static_cast(std::lround(angle)) % 360; + auto distance = std::sqrt(dx * dx + dy * dy); + this->saturation_ = static_cast(std::lround(std::min(100.0, distance * 100.0 / radius))); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + // Only sliders send this themselves; without it on_value would not fire for the wheel. + lv_obj_send_event(this->wheel_, LV_EVENT_VALUE_CHANGED, nullptr); + } +#endif + + // Lets events from the internal sliders reach the widget, where the triggers listen. Every + // object in between needs the flag; the widget itself does not, so events stop there. + 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); + bubble_events(child); + } + } + + void refresh_() { + lv_obj_set_style_bg_color(this->color_indicator_, this->state_, LV_PART_MAIN); + auto full_hue = lv_color_hsv_to_rgb(this->hue_, 100, 100); + this->update_hue_bar_(SLIDER_SATURATION, &this->saturation_bar_, full_hue); + this->update_hue_bar_(SLIDER_BRIGHTNESS, &this->brightness_bar_, full_hue); + this->update_text_(); + this->update_values_(); + this->update_knobs_(full_hue); +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + this->update_wheel_handle_(); +#endif + } + + // Sets the colour from the hue, saturation and brightness, and moves the RGB sliders to match. + void apply_hsv_() { + lv_color32_t c32 = + lv_color_to_32(lv_color_hsv_to_rgb(this->hue_, this->saturation_, this->brightness_), LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + this->set_slider_value_(SLIDER_RED, c32.red); + this->set_slider_value_(SLIDER_GREEN, c32.green); + this->set_slider_value_(SLIDER_BLUE, c32.blue); + this->refresh_(); + } + + void update_hsv_() { + this->hue_ = static_cast(this->slider_value_(SLIDER_HUE, this->hue_)); + this->saturation_ = static_cast(this->slider_value_(SLIDER_SATURATION, this->saturation_)); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + } + + void update_rgb_() { + this->state_ = Color(static_cast(this->slider_value_(SLIDER_RED, this->state_.r)), + static_cast(this->slider_value_(SLIDER_GREEN, this->state_.g)), + static_cast(this->slider_value_(SLIDER_BLUE, this->state_.b))); + auto hsv = lv_color_rgb_to_hsv(this->state_.r, this->state_.g, this->state_.b); + this->hue_ = hsv.h; + this->saturation_ = hsv.s; + this->brightness_ = hsv.v; + // A grey, white or black colour leaves a picker stuck there if nothing it has can move the + // saturation, or the brightness, away from zero. + bool stuck = false; + if (this->saturation_ == 0 && !this->can_change_saturation_()) { + this->saturation_ = 100; + stuck = true; + } + if (this->brightness_ == 0 && !this->can_change_brightness_()) { + this->brightness_ = 100; + stuck = true; + } + this->set_slider_value_(SLIDER_HUE, this->hue_); + this->set_slider_value_(SLIDER_SATURATION, this->saturation_); + this->set_slider_value_(SLIDER_BRIGHTNESS, this->brightness_); + if (stuck) { + this->apply_hsv_(); + } else { + this->refresh_(); + } + } + + void update_color_() { + this->set_slider_value_(SLIDER_RED, this->state_.r); + this->set_slider_value_(SLIDER_GREEN, this->state_.g); + this->set_slider_value_(SLIDER_BLUE, this->state_.b); + this->update_rgb_(); + lv_obj_invalidate(this->obj); + } + + // Shared by every colour picker; set up once by init_shared(). + inline static bool shared_ready{false}; + inline static lv_grad_dsc_t hue_bar{}; + inline static lv_grad_dsc_t red_bar{}; + inline static lv_grad_dsc_t green_bar{}; + inline static lv_grad_dsc_t blue_bar{}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + inline static lv_grad_dsc_t wheel_hue_grad{}; + inline static lv_grad_dsc_t wheel_white_grad{}; +#endif + inline static lv_style_t plain_box{}; + inline static lv_style_t slider_vert{}; + inline static lv_style_t slider_vert_knob{}; + inline static lv_style_t slider_horz{}; + inline static lv_style_t slider_horz_knob{}; + + // Per widget, since their far end follows the chosen hue. + lv_grad_dsc_t saturation_bar_{}; + lv_grad_dsc_t brightness_bar_{}; + Color state_{0x80, 0x80, 0x80}; + std::array sliders_{}; + std::array values_{}; + lv_obj_t *color_indicator_{}; + lv_obj_t *color_text_{}; + // Kept alongside state_ so a slider left out of the widget still has a value. + uint16_t hue_{0}; + uint8_t saturation_{0}; + uint8_t brightness_{50}; + const uint8_t sliders_mask_; + const bool use_wheel_; + bool tint_knobs_{true}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Null unless use_wheel_ is set. + lv_obj_t *wheel_{}; + lv_obj_t *wheel_handle_{}; +#endif +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_COLOR_PICKER diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 2858b41f719..69e15bdeca6 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -143,8 +143,8 @@ def get_theme_styles() -> dict[str, MockObj]: return _get_data(KEY_THEME_STYLES, {}) -def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]: - """Get the map of widget type names to the list of (style variable, part/state name)""" +def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, str, str]]]: + """Get the map of widget type names to the list of (style variable, part, state)""" return _get_data(KEY_WIDGET_THEME_STYLES, {}) @@ -723,6 +723,7 @@ CONF_CHANGE_RATE = "change_rate" CONF_CLICKABLE = "clickable" CONF_CLOSE_BUTTON = "close_button" CONF_COLOR_END = "color_end" +CONF_COLOR_PICKER = "color_picker" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index 8db183fabe2..df62e30f8a1 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -13,6 +13,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import ( + CONF_COLOR_PICKER, CONF_END_ANGLE, CONF_GRADIENTS, CONF_OPA, @@ -22,6 +23,7 @@ from .defines import ( add_define, add_lv_use, add_warning, + get_lv_uses, ) from .lv_validation import ( lv_angle_degrees, @@ -164,7 +166,7 @@ GRADIENT_SCHEMA = cv.ensure_list( async def gradients_to_code(config): add_lv_use("gradient") - max_stops = 2 + max_stops = 7 if CONF_COLOR_PICKER in get_lv_uses() else 2 if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())): add_warning( "The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored" diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 1fef37e2ee8..8231cd3b845 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -12,7 +12,7 @@ from .defines import ( STATES, LValidator, add_lv_use, - get_part_state_selector, + add_warning, get_styles_used, get_theme_styles, get_theme_update_requests, @@ -28,7 +28,7 @@ from .schemas import ( theme_update_schema, ) from .types import ObjUpdateAction, lv_style_t -from .widgets import collect_parts +from .widgets import collect_parts, misplaced_obj_properties def has_style_props(config) -> bool: @@ -77,9 +77,20 @@ class LVStyle: return cls.named_styles.setdefault(id_name, LVStyle(id_name)) +def _warn_misplaced_obj_properties(config: dict, owner: str) -> None: + """Warn about widget properties in a style or theme, where they have no effect.""" + for where, prop in misplaced_obj_properties(config, include_top=True): + place = f"{owner} under '{where}'" if where else owner + add_warning( + f"'{prop}' applies to a widget, not to a style, so setting it in {place} has " + f"no effect. Set it on each widget instead." + ) + + async def styles_to_code(config): """Convert styles to C__ code.""" for style in config.get(CONF_STYLE_DEFINITIONS, ()): + _warn_misplaced_obj_properties(style, f"style '{style[CONF_ID].id}'") await create_style(style[CONF_ID].id, style) @@ -109,8 +120,8 @@ def _get_theme_style_name(w_name: str, part: str, state: str) -> str: return f"_lv_theme_style_{w_name}_{part}_{state}" -def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: - """Return a list of (style variable, part/state name) for all theme styles used by the given widget type.""" +def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, str, str]]: + """Return a list of (style variable, part, state) for all theme styles used by the given widget type.""" widget_styles = get_widget_theme_style_data() if w_name in widget_styles: return widget_styles[w_name] @@ -120,9 +131,7 @@ def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: for state in STATES + (CONF_DEFAULT,): style_name = _get_theme_style_name(w_name, part, state) if style_name in theme_styles: - style_list.append( - (theme_styles[style_name], get_part_state_selector(part, state)) - ) + style_list.append((theme_styles[style_name], part, state)) widget_styles[w_name] = style_list return style_list @@ -137,6 +146,9 @@ async def theme_to_code(config): :return: """ theme = config.get(CONF_THEME) or {} + for w_name, w_theme in theme.items(): + if isinstance(w_theme, dict): + _warn_misplaced_obj_properties(w_theme, f"the theme for '{w_name}'") requests = get_theme_update_requests() widget_names = [ w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index b48123b9022..210314103ec 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,5 @@ -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import ExitStack import sys from typing import Any @@ -45,6 +46,7 @@ from ..defines import ( STATES, LValidator, add_lv_use, + add_warning, get_options, get_part_state_selector, get_styles_used, @@ -54,6 +56,7 @@ from ..defines import ( ) from ..lv_validation import lv_int from ..lvcode import ( + LocalVariable, add_line_marks, lv, lv_add, @@ -182,7 +185,7 @@ class WidgetType: wid = config[CONF_ID] add_line_marks(wid) if self.is_compound(): - var = cg.new_Pvariable(wid) + var = cg.new_Pvariable(wid, *await self.get_ctor_args(config)) lv_add(var.set_obj(creator)) await self.on_create(var.obj, config) else: @@ -229,6 +232,37 @@ class WidgetType: :param config: Its configuration """ + async def get_ctor_args(self, config: dict) -> list: + """ + Get extra arguments for a compound widget's constructor. Anything the widget needs + before it builds its parts belongs here, since a setter would be called too late. + :param config: Its configuration + :return: + """ + return [] + + def obj_targets(self, w: "Widget", prop: str) -> list["Widget"]: + """ + Get the widgets one object property, as opposed to a style, applies to. A widget + built out of other widgets can point a property at the ones it really concerns, such + as sending a touch margin to the parts that are actually pressed. + :param w: The widget being configured + :param prop: The property name, one of OBJ_PROPERTIES + :return: + """ + return [w] + + def part_targets(self, w: "Widget", part: str) -> list[tuple["Widget", str]]: + """ + Get the widgets a part's styles should be applied to, each paired with the part to + use on it. A widget built out of other widgets can point a part at those, so that + styling it works the same as it does for a plain widget. + :param w: The widget being styled + :param part: The configured part name + :return: + """ + return [(w, part)] + def get_uses(self) -> tuple: """ Get a list of other widgets used by this one @@ -270,8 +304,9 @@ def apply_theme_styles(w: "Widget") -> None: """Apply the current theme's styles for this widget's type""" from ..styles import get_widget_theme_styles - for style, lv_state in get_widget_theme_styles(w.type.name): - w.add_style(style, lv_state) + for style, part, state in get_widget_theme_styles(w.type.name): + for target, target_part in w.type.part_targets(w, part): + target.add_style(style, get_part_state_selector(target_part, state)) def apply_debug_outline(w: "Widget") -> None: @@ -539,6 +574,38 @@ def collect_states(config): return states +def misplaced_obj_properties( + config: dict, include_top: bool = False +) -> Iterator[tuple[str, str]]: + """ + Find object properties set where LVGL has no way to apply them. They belong to a widget + as a whole, taking neither a part nor a state, so anywhere else they are quietly dropped. + :param config: A widget, style or theme configuration + :param include_top: Also report the top level, as for a style, which never holds them + :return: Each place one was found, as a part/state path ("" for the top level), paired + with the property name + """ + from ..schemas import OBJ_PROPERTIES + + def scan(sub: Any, where: str) -> Iterator[tuple[str, str]]: + if isinstance(sub, dict): + for prop in sorted(OBJ_PROPERTIES.intersection(sub)): + yield where, prop + + if include_top: + yield from scan(config, "") + for state in STATES: + if state in config: + yield from scan(config[state], state) + for part in PARTS: + if part not in config: + continue + yield from scan(config[part], part) + for state in STATES: + if state in config[part]: + yield from scan(config[part][state], f"{part}/{state}") + + def collect_parts(config): """ Collect properties and states for all widget parts @@ -610,6 +677,13 @@ def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: w.set_style("flex_track_place", literal(track)) +def _widget_name(w: Widget) -> str: + """The id a widget was given, for naming it in a warning.""" + if isinstance(w.config, dict) and (wid := w.config.get(CONF_ID)) is not None: + return str(wid) + return str(w.var) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" @@ -628,19 +702,42 @@ async def set_obj_properties(w: Widget, config): else: base_name = None _set_layout_options(w, layout, base_name) + for where, prop in misplaced_obj_properties(config): + add_warning( + f"'{prop}' applies to a widget as a whole, not to one part or state of it, so " + f"setting it under '{where}' of widget '{_widget_name(w)}' has no effect. Set it " + f"on the widget itself instead." + ) parts = collect_parts(config) for part, states in parts.items(): + targets = w.type.part_targets(w, part) for state, props in states.items(): - lv_state = get_part_state_selector(part, state) - for style_id in props.get(CONF_STYLES, ()): - w.add_style(style_id, lv_state) - for prop, value in { - k: v for k, v in props.items() if k in ALL_STYLES - }.items(): - if isinstance(ALL_STYLES[prop], LValidator): - value = await ALL_STYLES[prop].process(value) - prop_r = remap_property(prop) - w.set_style(prop_r, value, lv_state) + style_ids = props.get(CONF_STYLES, ()) + # Each value is worked out once and then given to every target. A widget built + # out of others has one target per part, and a lambda is written out in full + # wherever it appears, so with more than one target it goes into a variable + # rather than being repeated. The blocks holding those variables stay open + # until every target has been dealt with. + with ExitStack() as stack: + values = [] + for prop, value in { + k: v for k, v in props.items() if k in ALL_STYLES + }.items(): + validator = ALL_STYLES[prop] + if isinstance(validator, LValidator): + is_lambda = isinstance(value, cv.Lambda) + value = await validator.process(value) + if is_lambda and len(targets) > 1: + value = stack.enter_context( + LocalVariable(prop, validator.rtype, value, modifier="") + ) + values.append((remap_property(prop), value)) + for target, target_part in targets: + lv_state = get_part_state_selector(target_part, state) + for style_id in style_ids: + target.add_style(style_id, lv_state) + for prop_r, value in values: + target.set_style(prop_r, value, lv_state) if group := config.get(CONF_GROUP): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) @@ -663,7 +760,8 @@ async def set_obj_properties(w: Widget, config): w.set_state(state, value) for property in OBJ_PROPERTIES: - await w.set_property(property, config, lv_name="obj") + for target in w.type.obj_targets(w, property): + await target.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): diff --git a/esphome/components/lvgl/widgets/color_picker.py b/esphome/components/lvgl/widgets/color_picker.py new file mode 100644 index 00000000000..4f501ba0ee5 --- /dev/null +++ b/esphome/components/lvgl/widgets/color_picker.py @@ -0,0 +1,240 @@ +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COLOR, CONF_HEIGHT, CONF_ITEMS, CONF_WIDTH +from esphome.core import CORE + +from ..defines import ( + CONF_COLOR_PICKER, + CONF_EXT_CLICK_AREA, + CONF_KNOB, + CONF_MAIN, + CONF_STYLES, + CONF_THEME, + DOMAIN, + add_define, + add_lv_use, + literal, +) +from ..lv_validation import lv_color, size +from ..lvcode import lv_add +from ..types import LvCompound, LvType +from . import Widget, WidgetType +from .label import CONF_LABEL +from .lv_bar import CONF_BAR +from .slider import CONF_SLIDER, slider_spec + +# esphome::Color, not lv_color_t: it carries the same value but exposes the components as +# `x.r`/`x.g`/`x.b`, and converts to lv_color_t on its own where LVGL needs one. +Color = cg.esphome_ns.class_("Color") + +lv_color_picker_t = LvType( + "LvColorPickerType", + parents=(LvCompound,), + largs=[(Color, "x")], + lvalue=lambda w: w.var.get_color(), + has_on_value=True, +) + +# Makes `lvgl.widget.update` with a `color:` report the change through `on_value`, the same +# as setting the value of any other widget does. +lv_color_picker_t.value_property = CONF_COLOR + +CONF_SLIDERS = "sliders" +CONF_WHEEL = "wheel" + +CONF_BG_COLOR = "bg_color" + +# The sliders the widget can be built from. The order must match +# LvColorPickerType::SliderIndex, since it decides which bit of the mask each one gets. +SLIDER_NAMES = ( + "hue", + "saturation", + "brightness", + "red", + "green", + "blue", +) + +# Shorthand for the two sets of sliders that are usually wanted together. +SLIDER_GROUPS = { + "hsv": ("hue", "saturation", "brightness"), + "hsb": ("hue", "saturation", "brightness"), + "rgb": ("red", "green", "blue"), +} + +# The wheel draws hue and saturation itself, and the layout drops the RGB sliders to make +# room for it, leaving brightness as the only slider that still fits. +WHEEL_EXCLUDED_SLIDERS = ("hue", "saturation", "red", "green", "blue") + + +def validate_sliders(value: Any) -> list[str]: + """Expand any group names and drop repeats, keeping the widget's own slider order.""" + value = cv.ensure_list(cv.one_of(*SLIDER_NAMES, *SLIDER_GROUPS, lower=True))(value) + chosen = {name for item in value for name in SLIDER_GROUPS.get(item, (item,))} + if not chosen: + raise cv.Invalid("At least one slider is required") + return [name for name in SLIDER_NAMES if name in chosen] + + +COLOR_PICKER_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_COLOR): lv_color, + } +) + +COLOR_PICKER_SCHEMA = COLOR_PICKER_MODIFY_SCHEMA.extend( + { + cv.Optional(CONF_WIDTH, default="SIZE_CONTENT"): size, + cv.Optional(CONF_HEIGHT): cv.invalid("Height will be set to the same as width"), + cv.Optional(CONF_SLIDERS, default=list(SLIDER_NAMES)): validate_sliders, + cv.Optional(CONF_WHEEL, default=False): cv.boolean, + } +) + + +# Starting colour for a picker with no way to change saturation, which would otherwise be stuck +# at the grey it starts with. Saturation changes with its own slider, the wheel, or any of the +# red, green and blue sliders, so only these two sliders on their own leave it fixed. +SATURATED_DEFAULT_COLOR = 0xFF0000 +HUE_AND_BRIGHTNESS = {"hue", "brightness"} + + +def _knob_color_is_set(knob: dict) -> bool: + """Whether a knob is given a background colour, directly or through named styles.""" + return CONF_BG_COLOR in knob or bool(knob.get(CONF_STYLES)) + + +def _theme_sets_knob_color() -> bool: + """Whether any LVGL theme gives the color picker's knobs a background colour.""" + configs = CORE.config.get(DOMAIN) or [] + if isinstance(configs, dict): + configs = [configs] + return any( + _knob_color_is_set( + ((config.get(CONF_THEME) or {}).get(CONF_COLOR_PICKER) or {}).get( + CONF_KNOB, {} + ) + ) + for config in configs + ) + + +def _sliders(config: dict) -> list[str]: + """Get the sliders a widget was created with, in the widget's own order.""" + return config.get(CONF_SLIDERS) or list(SLIDER_NAMES) + + +class ColorPickerType(WidgetType): + def __init__(self) -> None: + super().__init__( + CONF_COLOR_PICKER, + lv_color_picker_t, + parts=(CONF_MAIN, CONF_ITEMS, CONF_KNOB), + schema=COLOR_PICKER_SCHEMA, + modify_schema=COLOR_PICKER_MODIFY_SCHEMA, + lv_name="obj", + ) + + def validate(self, value: Any) -> Any: + add_lv_use(CONF_COLOR_PICKER) + value = dict(value) + if cv.boolean(value.get(CONF_WHEEL, False)): + # The wheel is a conical hue gradient under a radial white-to-transparent one; + # LVGL's software renderer only draws either kind when this is enabled. + add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS") + if CONF_SLIDERS in value: + sliders = validate_sliders(value[CONF_SLIDERS]) + if any(name in sliders for name in WHEEL_EXCLUDED_SLIDERS): + raise cv.Invalid( + "the color wheel already shows hue and saturation, and replaces " + "the red, green and blue sliders with it; remove them from " + "'sliders' or set 'wheel: false'" + ) + else: + # Otherwise the schema default would build every slider including these. + value[CONF_SLIDERS] = [ + name for name in SLIDER_NAMES if name not in WHEEL_EXCLUDED_SLIDERS + ] + return super().validate(value) + + async def get_ctor_args(self, config: dict) -> list: + # The layout is worked out from the sliders, so they are needed before the widget is + # built rather than set afterwards. + return [ + literal( + " | ".join( + f"{lv_color_picker_t}::SLIDER_FLAG_{name.upper()}" + for name in _sliders(config) + ) + ), + config[CONF_WHEEL], + ] + + @staticmethod + def _sliders_of(w: Widget) -> list[Widget]: + """Wrap each slider the widget was created with, so it can be configured directly.""" + return [ + Widget( + w.var.get_slider( + literal(f"{lv_color_picker_t}::SLIDER_{name.upper()}") + ), + slider_spec, + ) + for name in _sliders(w.config or {}) + ] + + def obj_targets(self, w: Widget, prop: str) -> list[Widget]: + # Nothing is ever pressed on its own object, so the touch margin belongs on the + # sliders. Everything else, scrolling in particular, is a matter for the container + # holding them and so stays where it is. + if prop == CONF_EXT_CLICK_AREA: + return self._sliders_of(w) + return [w] + + def part_targets(self, w: Widget, part: str) -> list[tuple[Widget, str]]: + # The widget is made of sliders, so `items` and `knob` are the main and knob parts of + # each of those. Its own object is a plain container with neither. Only the sliders + # the widget was created with exist, so only those are styled. + if part == CONF_MAIN: + return [(w, part)] + target_part = CONF_MAIN if part == CONF_ITEMS else part + return [(slider, target_part) for slider in self._sliders_of(w)] + + async def to_code(self, w: Widget, config: dict) -> None: + # Only the configuration that creates the widget has `sliders`; an update does not. + creating = CONF_SLIDERS in config + if creating and config[CONF_WHEEL]: + # Without a wheel anywhere, its code and gradients are left out of the build. + cg.add_define("USE_LVGL_COLOR_PICKER_WHEEL") + color = config.get(CONF_COLOR) + if ( + color is None + and creating + and not config[CONF_WHEEL] + and set(config[CONF_SLIDERS]) <= HUE_AND_BRIGHTNESS + ): + color = SATURATED_DEFAULT_COLOR + # A hex colour validates to a plain int, so black must not be mistaken for absent. + if color is not None: + lv_add(w.var.set_color(await lv_color.process(color))) + # Each knob is normally tinted with the colour its own slider shows. That is applied + # as a local style, which outranks any style the knob has been given, so a background + # colour configured for the knob, here or in the theme, would never show; tinting + # gives way to it instead. + if _knob_color_is_set(config.get(CONF_KNOB, {})) or ( + creating and _theme_sets_knob_color() + ): + lv_add(w.var.set_tint_knobs(False)) + # The widget is square, so the height simply follows the configured width. + # SIZE_CONTENT works too: the widget reports a size based on its text font. + # Width is required when creating the widget but absent when updating one. + if (width := config.get(CONF_WIDTH)) is not None: + w.set_style(CONF_HEIGHT, await size.process(width), 0) + + def get_uses(self) -> tuple[str, ...]: + return ("flex", CONF_SLIDER, CONF_BAR, CONF_LABEL) + + +color_picker_spec = ColorPickerType() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 28abdb2218c..00e53765c24 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -111,6 +111,8 @@ #define USE_LVGL_CANVAS #define USE_LVGL_CHART #define USE_LVGL_CHECKBOX +#define USE_LVGL_COLOR_PICKER +#define USE_LVGL_COLOR_PICKER_WHEEL #define USE_LVGL_DROPDOWN #define USE_LVGL_FONT #define USE_LVGL_GRADIENT @@ -718,3 +720,12 @@ #define ESPHOME_ENTITY_VALVE_COUNT 1 #define ESPHOME_ENTITY_WATER_HEATER_COUNT 1 #define ESPHOME_MAX_USB_CDC_INSTANCES 1 + +// Keep clang-tidy happy. + +// The real value comes from lvgl/gradient.py, which works it out from the configuration. The +// largest it uses is set here so that static analysis sees every gradient in the component, +// but only where a generated lv_conf.h has not already given it a value. +#ifndef LV_GRADIENT_MAX_STOPS +#define LV_GRADIENT_MAX_STOPS 7 +#endif diff --git a/tests/component_tests/lvgl/config/color_picker.yaml b/tests/component_tests/lvgl/config/color_picker.yaml new file mode 100644 index 00000000000..edff4942347 --- /dev/null +++ b/tests/component_tests/lvgl/config/color_picker.yaml @@ -0,0 +1,133 @@ +esphome: + name: test-color-picker + +esp32: + board: lolin_c3_mini + +logger: + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +color: + - id: picker_colour + hex: "3366CC" + +lvgl: + style_definitions: + - id: picker_style + bg_opa: cover + + widgets: + # Sized from the text font + - color_picker: + id: picker_content + width: SIZE_CONTENT + + # Fixed width, colour given as a hex literal + - color_picker: + id: picker_fixed + width: 120 + color: 0xFF8800 + + # Styling the sliders the widget is built from + - color_picker: + id: picker_styled + width: 100 + items: + bg_opa: 80% + pressed: + border_width: 2 + knob: + bg_color: 0x112233 + radius: 4 + styles: [picker_style] + # One value for six sliders: it must not be generated once per slider. The body + # is more than an expression so that it stays a lambda rather than being folded + # into a constant. + border_width: !lambda |- + int width = 3; + return width; + pressed: + styles: [picker_style] + # Belongs to the widget as a whole, so it is reported and ignored here + ext_click_area: 8 + + # Only the red, green and blue sliders, with a margin making them easier to press. + # The scrolling options belong to the widget's own object, not to the sliders. + - color_picker: + id: picker_rgb + width: 100 + sliders: rgb + ext_click_area: 12 + scrollbar_mode: "OFF" + + # Black must not be mistaken for no colour at all + - color_picker: + id: picker_black + width: 100 + color: 0x000000 + + # The knob background arrives through a named style rather than directly + - color_picker: + id: picker_knob_style + width: 100 + knob: + styles: [picker_style] + + # Only the hue, saturation and brightness sliders, styled + - color_picker: + id: picker_hsv + width: 100 + sliders: [hsv] + knob: + radius: 2 + + # A single slider, for picking a hue with the brightness controlled elsewhere + - color_picker: + id: picker_hue + width: 100 + sliders: hue + + # A colour wheel in place of the hue and saturation sliders + - color_picker: + id: picker_wheel + width: 100 + wheel: true + + # Colour given as the id of a colour defined above + - color_picker: + id: picker_colour_id + width: 50% + color: picker_colour + on_value: + - logger.log: + format: "picked %d,%d,%d" + args: [x.r, x.g, x.b] + on_release: + - logger.log: released + on_update: + - logger.log: updated + +script: + - id: update_picker + then: + - lvgl.color_picker.update: + id: picker_fixed + color: 0x00FF00 + - lvgl.color_picker.update: + id: picker_hue + color: !lambda return lv_color_hex(0x123456); diff --git a/tests/component_tests/lvgl/config/color_picker_theme.yaml b/tests/component_tests/lvgl/config/color_picker_theme.yaml new file mode 100644 index 00000000000..47ae267caeb --- /dev/null +++ b/tests/component_tests/lvgl/config/color_picker_theme.yaml @@ -0,0 +1,48 @@ +esphome: + name: test-color-picker + +esp32: + board: lolin_c3_mini + +logger: + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + style_definitions: + # A widget property in a style is dropped, so it is reported + - id: clickable_style + ext_click_area: 10 + + theme: + # A themed knob colour must not be covered by the knob tinting + color_picker: + knob: + bg_color: 0x112233 + # Widget properties in a theme are dropped too, at any level + button: + scrollbar_mode: "OFF" + pressed: + ext_click_area: 4 + + widgets: + - color_picker: + id: themed_picker + width: 100 + - button: + id: themed_button + styles: clickable_style diff --git a/tests/component_tests/lvgl/config/named_styles.yaml b/tests/component_tests/lvgl/config/named_styles.yaml new file mode 100644 index 00000000000..2adc16ddf4a --- /dev/null +++ b/tests/component_tests/lvgl/config/named_styles.yaml @@ -0,0 +1,47 @@ +esphome: + name: test-named-styles + +esp32: + board: lolin_c3_mini + +logger: + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + style_definitions: + - id: chunky + bg_opa: cover + border_width: 2 + + widgets: + # A plain widget, so the selector each named style is added with is the one LVGL is + # given directly, with no compound widget in the way. + - slider: + id: plain_slider + width: 100 + # No part and no state: the selector is a bare zero. + styles: [chunky] + knob: + # A part on its own. + styles: [chunky] + pressed: + # A part combined with a state, which has to survive the cast. + styles: [chunky] + pressed: + # A state on its own. + styles: [chunky] diff --git a/tests/component_tests/lvgl/test_color_picker.py b/tests/component_tests/lvgl/test_color_picker.py new file mode 100644 index 00000000000..1d094a62dc4 --- /dev/null +++ b/tests/component_tests/lvgl/test_color_picker.py @@ -0,0 +1,628 @@ +"""Tests for the LVGL color picker widget schema and code generation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest +from voluptuous import Schema + +from esphome.components.lvgl.schemas import container_schema +from esphome.components.lvgl.widgets import _build_update_schema +from esphome.components.lvgl.widgets.color_picker import color_picker_spec +from esphome.config_validation import Invalid +from esphome.const import CONF_WIDTH + + +@pytest.fixture() +def update_schema() -> Schema: + """The schema behind `lvgl.color_picker.update`.""" + return _build_update_schema(color_picker_spec) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestColorPickerValidation: + """Test the widget's own schema options.""" + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + return container_schema(color_picker_spec) + + @pytest.mark.parametrize("width", [100, "50%", "SIZE_CONTENT"]) + def test_width_accepted( + self, schema: Callable[[dict], dict], width: int | str + ) -> None: + assert schema({"width": width}) is not None + + def test_width_defaults_to_size_content( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({})[CONF_WIDTH] == "LV_SIZE_CONTENT" + + def test_height_is_rejected(self, schema: Callable[[dict], dict]) -> None: + """The widget is square, so a height of its own makes no sense.""" + with pytest.raises(Invalid, match="Height will be set to the same as width"): + schema({"width": 100, "height": 100}) + + def test_color_is_optional(self, schema: Callable[[dict], dict]) -> None: + assert "color" not in schema({"width": 100}) + + def test_on_value_is_accepted(self, schema: Callable[[dict], dict]) -> None: + """The trigger is only offered if the widget declares it has a value.""" + result = schema({"width": 100, "on_value": [{"logger.log": "picked"}]}) + + assert "on_value" in result + + def test_value_argument_is_a_color(self) -> None: + """`x` must be an esphome Color, so that lambdas can use x.r/x.g/x.b. + + esphome::Color converts to lv_color_t implicitly, so declaring the wrong + one of the two still compiles but gives lambdas the LVGL struct, whose + members are named differently. + """ + (arg_type,) = color_picker_spec.w_type.get_arg_type() + + assert str(arg_type) == "Color" + + def test_update_action_sets_the_colour(self, update_schema: Schema) -> None: + """The colour is the one thing an update action is for.""" + assert update_schema({"id": "picker", "color": 0xFF8800}) is not None + + def test_update_action_does_not_require_width(self, update_schema: Schema) -> None: + """Width is only needed when the widget is created, unlike in the widget schema.""" + assert CONF_WIDTH not in update_schema({"id": "picker", "color": 0xFF8800}) + + def test_slider_parts_are_declared(self) -> None: + """`items` and `knob` stand for the sliders the widget is built from.""" + assert color_picker_spec.parts == ("main", "items", "knob") + + @pytest.mark.parametrize("part", ["items", "knob"]) + def test_slider_parts_are_accepted( + self, schema: Callable[[dict], dict], part: str + ) -> None: + assert schema({"width": 100, part: {"radius": 4}}) is not None + + +class TestColorPickerSliderChoice: + """Test choosing which of the six sliders the widget is built from.""" + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + return container_schema(color_picker_spec) + + def test_all_sliders_by_default(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100})["sliders"] == [ + "hue", + "saturation", + "brightness", + "red", + "green", + "blue", + ] + + @pytest.mark.parametrize( + ("configured", "expected"), + [ + ("rgb", ["red", "green", "blue"]), + ("hsv", ["hue", "saturation", "brightness"]), + # Both spellings of the third HSV component are accepted. + ("hsb", ["hue", "saturation", "brightness"]), + (["hue", "saturation"], ["hue", "saturation"]), + (["hue"], ["hue"]), + # A group can be mixed with single sliders. + (["rgb", "hue"], ["hue", "red", "green", "blue"]), + ], + ) + def test_groups_are_expanded( + self, + schema: Callable[[dict], dict], + configured: str | list[str], + expected: list[str], + ) -> None: + assert schema({"width": 100, "sliders": configured})["sliders"] == expected + + def test_order_follows_the_widget_not_the_config( + self, schema: Callable[[dict], dict] + ) -> None: + """The layout is fixed, so the order they are listed in makes no difference.""" + assert schema({"width": 100, "sliders": ["blue", "red"]})["sliders"] == [ + "red", + "blue", + ] + + def test_repeats_are_dropped(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100, "sliders": ["red", "rgb"]})["sliders"] == [ + "red", + "green", + "blue", + ] + + def test_empty_list_is_rejected(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="At least one slider is required"): + schema({"width": 100, "sliders": []}) + + def test_unknown_slider_is_rejected(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="Unknown value 'cyan'"): + schema({"width": 100, "sliders": ["cyan"]}) + + def test_sliders_cannot_be_updated(self, update_schema: Schema) -> None: + """The layout is built from them, so they cannot change after creation.""" + with pytest.raises(Invalid, match="sliders"): + update_schema({"id": "picker", "sliders": "rgb"}) + + +class TestColorPickerWheel: + """Test the `wheel` option, which replaces the hue and saturation sliders, and the RGB + sliders besides, with a circular hue/saturation picker. + + The exclusion of these sliders happens in `ColorPickerType.validate()`, which runs + before the schema itself (see `WIDGET_TYPES` validation in schemas.py), so these tests + reproduce that order rather than using the `container_schema` fixture alone. + """ + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + container = container_schema(color_picker_spec) + + def _validate(value: dict) -> dict: + return container(color_picker_spec.validate(value)) + + return _validate + + def test_wheel_defaults_to_false(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100})["wheel"] is False + + def test_wheel_must_be_a_boolean(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="boolean"): + schema({"width": 100, "wheel": "round"}) + + def test_wheel_leaves_only_brightness_by_default( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({"width": 100, "wheel": True})["sliders"] == ["brightness"] + + def test_wheel_is_compatible_with_an_explicit_slider_list( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({"width": 100, "wheel": True, "sliders": "brightness"})[ + "sliders" + ] == ["brightness"] + + @pytest.mark.parametrize( + "sliders", + ["hue", "saturation", "red", "green", "blue", "rgb", ["hue", "red"], "hsv"], + ) + def test_wheel_conflicts_with_sliders_it_replaces( + self, schema: Callable[[dict], dict], sliders: str | list[str] + ) -> None: + with pytest.raises(Invalid, match="color wheel"): + schema({"width": 100, "wheel": True, "sliders": sliders}) + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + +_SET_STYLE_RE = re.compile(r"lv_obj_set_style_(\w+)\((\w+)->obj, (.+?), \w+\);") + + +def _styles_for(main_cpp: str, var: str) -> dict[str, str]: + """Return {property: value} for every style set directly on a widget.""" + return { + m.group(1): m.group(3) + for m in _SET_STYLE_RE.finditer(main_cpp) + if m.group(2) == var + } + + +class TestColorPickerCodeGeneration: + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + def test_height_follows_width(self, main_cpp: str) -> None: + """The widget is square, so height is generated to match the given width.""" + for var, expected in ( + ("picker_content", "LV_SIZE_CONTENT"), + ("picker_fixed", "120"), + ("picker_colour_id", "lv_pct(50)"), + ): + styles = _styles_for(main_cpp, var) + assert styles["width"] == expected + assert styles["height"] == expected + + def test_color_is_applied(self, main_cpp: str) -> None: + assert "picker_fixed->set_color(" in main_cpp + assert "picker_colour_id->set_color(" in main_cpp + + @pytest.mark.parametrize("var", ["picker_content", "picker_rgb", "picker_wheel"]) + def test_color_is_omitted_when_not_configured( + self, main_cpp: str, var: str + ) -> None: + """Each of these can change saturation, so the grey it starts with is no trap.""" + assert f"{var}->set_color(" not in main_cpp + + def test_wheel_code_is_built_for_a_wheel(self, main_cpp: str) -> None: + from esphome.core import CORE + + assert "USE_LVGL_COLOR_PICKER_WHEEL" in {d.name for d in CORE.defines} + + def test_wheel_code_is_left_out_without_a_wheel( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Its gradients would otherwise take RAM in every build with a picker.""" + from esphome.core import CORE + + generate_main(component_config_path("color_picker_theme.yaml")) + + assert "USE_LVGL_COLOR_PICKER_WHEEL" not in {d.name for d in CORE.defines} + + def test_hue_only_picker_starts_saturated(self, main_cpp: str) -> None: + """With no way to change saturation, a grey start would leave it stuck at grey.""" + assert "picker_hue->set_color(lv_color_make(255, 0, 0));" in main_cpp + + def test_black_is_applied(self, main_cpp: str) -> None: + """A hex colour validates to a plain int, and black must not read as absent.""" + assert "picker_black->set_color(lv_color_make(0, 0, 0))" in main_cpp + + def test_trigger_uses_color_type(self, main_cpp: str) -> None: + """The on_value trigger is declared with the Color the widget reports.""" + assert "Trigger" in main_cpp + + def test_trigger_reads_the_current_color(self, main_cpp: str) -> None: + assert "picker_colour_id->get_color()" in main_cpp + + def test_update_action_generates_set_color(self, main_cpp: str) -> None: + """`lvgl.color_picker.update` used to fail outright on the missing width.""" + assert main_cpp.count("picker_fixed->set_color(") == 2 + + def test_update_action_accepts_a_lambda(self, main_cpp: str) -> None: + # A lambda that only returns an expression is generated as that expression. + assert "picker_hue->set_color(lv_color_hex(0x123456));" in main_cpp + + @pytest.mark.parametrize("message", ["released", "updated"]) + def test_other_triggers_are_generated(self, main_cpp: str, message: str) -> None: + assert f'"{message}"' in main_cpp + + def test_gradient_stops_are_raised_for_the_hue_bar(self, main_cpp: str) -> None: + """The hue bar fills seven stops of a fixed-size array. + + `gradient.py` raises the limit when a colour picker is in use, and the C++ + `static_assert` relies on that having happened. It works because the widget + records the use during validation, which runs first, but nothing in either + file says so, hence this. + """ + from esphome.components.lvgl.defines import get_defines + + assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "7" + + def test_gradient_stops_are_left_alone_without_a_picker( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """The other half of the same invariant: only a picker pays for the extra stops.""" + from esphome.components.lvgl.defines import get_defines + + # Any configuration without a colour picker in it will do. + generate_main(component_config_path("named_styles.yaml")) + + assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "2" + + def test_complex_gradients_are_enabled_for_a_wheel(self, main_cpp: str) -> None: + """The wheel is a conical and a radial gradient, which need this to render at all.""" + from esphome.components.lvgl.defines import get_defines + + assert get_defines()["LV_USE_DRAW_SW_COMPLEX_GRADIENTS"] == "1" + + def test_complex_gradients_are_left_alone_without_a_wheel( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + from esphome.components.lvgl.defines import get_defines + + generate_main(component_config_path("named_styles.yaml")) + + assert "LV_USE_DRAW_SW_COMPLEX_GRADIENTS" not in get_defines() + + +# The widget's own object is a plain container, so styles for the parts a slider has are +# applied to each of its sliders instead. +_SLIDER_STYLE_RE = re.compile( + r"lv_obj_set_style_(\w+)\((\w+)->get_slider\(LvColorPickerType::SLIDER_(\w+)\), " + r"(.+?), (\w+)\);" +) + +ALL_SLIDERS = {"HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"} + + +def _slider_style_values(main_cpp: str, var: str, prop: str) -> set[str]: + """Return every value one style property was set to across a widget's sliders.""" + return { + m.group(4) + for m in _SLIDER_STYLE_RE.finditer(main_cpp) + if m.group(2) == var and m.group(1) == prop and m.group(5) == "LV_PART_KNOB" + } + + +def _slider_styles(main_cpp: str, var: str) -> dict[tuple[str, str], set[str]]: + """Return {(property, selector): sliders it was applied to} for one widget.""" + found: dict[tuple[str, str], set[str]] = {} + for m in _SLIDER_STYLE_RE.finditer(main_cpp): + if m.group(2) == var: + found.setdefault((m.group(1), m.group(5)), set()).add(m.group(3)) + return found + + +class TestColorPickerSliderStyles: + @pytest.fixture() + def styles( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> dict[tuple[str, str], set[str]]: + main_cpp = generate_main(component_config_path("color_picker.yaml")) + return _slider_styles(main_cpp, "picker_styled") + + @pytest.mark.parametrize( + ("prop", "selector"), + [ + # `knob` keeps its name on the slider... + ("bg_color", "LV_PART_KNOB"), + ("radius", "LV_PART_KNOB"), + # ...while `items` becomes the slider's own body. + ("bg_opa", "LV_PART_MAIN"), + # A state without a part names only the state, as both are zero. + ("border_width", "LV_STATE_PRESSED"), + ], + ) + def test_style_reaches_every_slider( + self, styles: dict[tuple[str, str], set[str]], prop: str, selector: str + ) -> None: + assert styles[prop, selector] == ALL_SLIDERS + + def test_style_skips_sliders_the_widget_lacks( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """A style must not be applied to a slider that was never created.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + styles = _slider_styles(main_cpp, "picker_hsv") + + assert styles["radius", "LV_PART_KNOB"] == {"HUE", "SATURATION", "BRIGHTNESS"} + + @pytest.mark.parametrize( + "selector", + [ + # A part on its own needs no cast... + "LV_PART_KNOB", + # ...while a part combined with a state is cast to the type LVGL takes, which is + # wide enough for both. A state alone would not be. + ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ), + ], + ) + def test_named_style_reaches_every_slider( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + selector: str, + ) -> None: + """A style listed under `styles:` is added to each slider, not to the container.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + added = re.findall( + r"lv_obj_add_style\(picker_styled->get_slider\(" + r"LvColorPickerType::SLIDER_(\w+)\), picker_style, (.+?)\);", + main_cpp, + ) + + assert {name for name, sel in added if sel == selector} == ALL_SLIDERS + + def test_a_lambda_is_generated_once_for_all_the_sliders( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """One YAML value must not become one C++ function per slider.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + # The source also appears once in the comment codegen writes above the lambda. + assert main_cpp.count("int border_width_VAR_ = []() -> int {") == 1 + + def test_the_same_lambda_reaches_every_slider( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Generating it once must not mean applying it once.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + styles = _slider_styles(main_cpp, "picker_styled") + + assert styles["border_width", "LV_PART_KNOB"] == ALL_SLIDERS + assert _slider_style_values(main_cpp, "picker_styled", "border_width") == { + "border_width_VAR_" + } + + def test_no_styles_are_left_on_the_widget_itself( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Setting these on the container would have no visible effect.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + own = _styles_for(main_cpp, "picker_styled") + + assert set(own) == {"width", "height"} + + @pytest.mark.parametrize( + "var", + [ + # The background is set on the knob directly... + "picker_styled", + # ...or through a named style, which the tint would still outrank. + "picker_knob_style", + ], + ) + def test_knob_tinting_is_disabled_by_a_knob_background( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + var: str, + ) -> None: + """The tint is a local style, so it would overwrite the configured colour.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + assert f"{var}->set_tint_knobs(false)" in main_cpp + + @pytest.mark.parametrize( + "var", ["picker_content", "picker_fixed", "picker_colour_id"] + ) + def test_knob_tinting_is_left_on_otherwise( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + var: str, + ) -> None: + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + assert f"{var}->set_tint_knobs(" not in main_cpp + + +class TestColorPickerObjectProperties: + """The widget's own object is never pressed, so touch settings go to the sliders.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + def test_ext_click_area_reaches_the_sliders(self, main_cpp: str) -> None: + """This is what makes the knobs easier to grab: it widens each slider's touch area.""" + found = re.findall( + r"lv_obj_set_ext_click_area\(picker_rgb->get_slider\(" + r"LvColorPickerType::SLIDER_(\w+)\), 12\);", + main_cpp, + ) + + assert set(found) == {"RED", "GREEN", "BLUE"} + + def test_ext_click_area_is_not_left_on_the_widget_itself( + self, main_cpp: str + ) -> None: + """The container has nothing clickable in it, so setting it there does nothing.""" + assert "lv_obj_set_ext_click_area(picker_rgb->obj" not in main_cpp + + def test_ext_click_area_under_a_part_is_reported(self, main_cpp: str) -> None: + """It takes no part, so LVGL cannot apply it to one; that must not pass silently.""" + from esphome.components.lvgl.defines import get_warnings + + assert any( + "'ext_click_area'" in w and "'knob'" in w and "'picker_styled'" in w + for w in get_warnings() + ), get_warnings() + + def test_scrollbar_mode_stays_on_the_widget_itself(self, main_cpp: str) -> None: + """Only the touch margin moves: a slider never scrolls, but the container can.""" + assert "lv_obj_set_scrollbar_mode(picker_rgb->obj, LV_SCROLLBAR_MODE_OFF)" in ( + main_cpp + ) + + def test_scrollbar_mode_does_not_reach_the_sliders(self, main_cpp: str) -> None: + assert "lv_obj_set_scrollbar_mode(picker_rgb->get_slider(" not in main_cpp + + +class TestColorPickerSliderConstruction: + """The chosen sliders are passed to the constructor, before the layout is built.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + @pytest.mark.parametrize( + ("var", "expected"), + [ + ("picker_rgb", ["RED", "GREEN", "BLUE"]), + ("picker_hsv", ["HUE", "SATURATION", "BRIGHTNESS"]), + ("picker_hue", ["HUE"]), + ( + "picker_fixed", + ["HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"], + ), + ], + ) + def test_constructor_names_the_chosen_sliders( + self, main_cpp: str, var: str, expected: list[str] + ) -> None: + flags = " | ".join( + f"LvColorPickerType::SLIDER_FLAG_{name}" for name in expected + ) + + assert f"new({var}) LvColorPickerType({flags}, false);" in main_cpp + + def test_constructor_passes_wheel_true(self, main_cpp: str) -> None: + assert ( + "new(picker_wheel) LvColorPickerType(" + "LvColorPickerType::SLIDER_FLAG_BRIGHTNESS, true);" + ) in main_cpp + + def test_constructor_passes_wheel_false_by_default(self, main_cpp: str) -> None: + assert "new(picker_hue) LvColorPickerType(" in main_cpp + assert "LvColorPickerType::SLIDER_FLAG_HUE, false);" in main_cpp + + +class TestColorPickerTheme: + """Settings that come from the theme or a style definition rather than the widget.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker_theme.yaml")) + + def test_theme_knob_color_turns_tinting_off(self, main_cpp: str) -> None: + """The tint is a local style, so it would overwrite the themed colour.""" + assert "themed_picker->set_tint_knobs(false)" in main_cpp + + @pytest.mark.parametrize( + "expected", + [ + ("'ext_click_area'", "style 'clickable_style'"), + ("'scrollbar_mode'", "the theme for 'button'"), + ("'ext_click_area'", "the theme for 'button' under 'pressed'"), + ], + ) + def test_widget_properties_in_styles_are_reported( + self, main_cpp: str, expected: tuple[str, str] + ) -> None: + """A style only holds style properties, so these would be dropped silently.""" + from esphome.components.lvgl.defines import get_warnings + + prop, place = expected + assert any(prop in w and f"in {place} " in w for w in get_warnings()), ( + get_warnings() + ) diff --git a/tests/component_tests/lvgl/test_named_styles.py b/tests/component_tests/lvgl/test_named_styles.py new file mode 100644 index 00000000000..0fba00cd3a1 --- /dev/null +++ b/tests/component_tests/lvgl/test_named_styles.py @@ -0,0 +1,57 @@ +"""Tests for the selector a named style is added to a widget with. + +A part and a state are combined into one value, but they live in different halves of it: +a state fits in 16 bits, while a part starts at bit 16. Casting the pair to the state type +would throw the part away and quietly move the style to the main part, so these pin the +type each selector is generated with. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + + +def _selectors(main_cpp: str, var: str, style: str) -> set[str]: + """Return every selector a named style was added to a widget with.""" + return set( + re.findall( + rf"lv_obj_add_style\({var}, {style}, (.+?)\);", + main_cpp, + ) + ) + + +class TestNamedStyleSelectors: + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("named_styles.yaml")) + + def test_every_selector_is_generated(self, main_cpp: str) -> None: + assert _selectors(main_cpp, "plain_slider", "chunky") == { + # Neither a part nor a state, which names the main part. + "LV_PART_MAIN", + # A state on its own, which needs no cast as it is the narrower half. + "LV_STATE_PRESSED", + # A part on its own. + "LV_PART_KNOB", + # A part with a state, cast to the type LVGL takes, which is wide enough for + # both. The state type alone would drop the part. + ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ), + } + + def test_a_combined_selector_is_not_narrowed_to_a_state( + self, main_cpp: str + ) -> None: + """`lv_state_t` is 16 bits, so it cannot hold a part.""" + assert "(lv_state_t)((int)LV_PART_KNOB" not in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 681eb41b5b5..952dd5f50f1 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1777,6 +1777,58 @@ lvgl: - label: text: "2,2" + # Sized from its text font, with a colour given as an id + - color_picker: + id: lv_color_picker_1 + width: SIZE_CONTENT + text_font: space16 + color: light_blue + on_value: + - logger.log: + format: "color picker is now %d,%d,%d" + args: [x.r, x.g, x.b] + on_release: + - lvgl.color_picker.update: + id: lv_color_picker_2 + color: !lambda return x; + + # Fixed size, with a colour given as a hex literal + - color_picker: + id: lv_color_picker_2 + width: 200 + color: 0x3366CC + # Styles for the sliders the widget is built from + items: + border_color: 0x808080 + pressed: + border_width: 2 + knob: + radius: 4 + # A named style, added for both a part on its own and a part with a state + styles: [style_test] + pressed: + styles: [style_test] + on_update: + - logger.log: color picker updated + + # Only the red, green and blue sliders + - color_picker: + id: lv_color_picker_3 + width: 150 + sliders: rgb + + # A hue and saturation pair, as used where brightness is set elsewhere + - color_picker: + id: lv_color_picker_4 + width: 150 + sliders: [hue, saturation] + # Widens the area each slider can be pressed on, so the knobs are easier to move + ext_click_area: 10 + # Applies to the widget as a whole, unlike the setting above + scrollbar_mode: "OFF" + knob: + bg_color: 0xFFFFFF + font: - file: "gfonts://Roboto" id: space16 diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 1df27d82f73..3c4825b067b 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -79,6 +79,11 @@ lvgl: - lvgl.animation.start: id: anim_slide lvgl_id: lvgl_0 + # Sliders only and no complex gradients, so the wheel's gradient set-up must still build + - color_picker: + id: host_hue_picker + width: 200 + sliders: hue font: - file: "gfonts://Roboto" diff --git a/tests/integration/fixtures/lvgl_color_picker_render.yaml b/tests/integration/fixtures/lvgl_color_picker_render.yaml new file mode 100644 index 00000000000..320b7578602 --- /dev/null +++ b/tests/integration/fixtures/lvgl_color_picker_render.yaml @@ -0,0 +1,82 @@ +esphome: + name: lvgl-color-picker-render + +host: + +api: + actions: + # A snapshot never writes over an existing file, so the test supplies a new name each time. + - action: take_screenshot + variables: + name: string + then: + - snapshot.take: + id: picker_display + filename: !lambda return name; + - action: set_orange + then: + - lvgl.color_picker.update: + id: picker_sliders + color: 0xFF8800 + - lvgl.color_picker.update: + id: picker_wheel + color: 0xFF8800 + # White has no saturation, which a hue-only picker could never change again + - lvgl.color_picker.update: + id: picker_hue + color: 0xFFFFFF + +logger: + level: DEBUG + +display: + - platform: snapshot + id: picker_display + auto_clear_enabled: false + dimensions: + width: 680 + height: 480 + +lvgl: + displays: picker_display + default_font: montserrat_14 + widgets: + - obj: + width: 100% + height: 100% + bg_color: 0x404040 + bg_opa: cover + border_width: 0 + radius: 0 + pad_all: 4 + layout: + type: flex + flex_flow: row_wrap + pad_row: 4 + pad_column: 4 + widgets: + - color_picker: + id: picker_sliders + width: 196 + color: 0x3366CC + - color_picker: + id: picker_wheel + width: 196 + wheel: true + color: 0x3366CC + # Sized from the text font + - color_picker: + id: picker_content + color: 0x3366CC + # No red, green and blue bars beside the swatch + - color_picker: + id: picker_hsv + width: 196 + sliders: hsv + color: 0x3366CC + # Only the hue bar + - color_picker: + id: picker_hue + width: 196 + sliders: hue + color: 0x3366CC diff --git a/tests/integration/test_lvgl_color_picker_render.py b/tests/integration/test_lvgl_color_picker_render.py new file mode 100644 index 00000000000..0204f29312c --- /dev/null +++ b/tests/integration/test_lvgl_color_picker_render.py @@ -0,0 +1,105 @@ +"""Golden-image test for the LVGL colour picker, drawn on a display with no screen. + +Pickers are drawn with every slider, with a colour wheel, at the size taken from the font, +and with only some of the sliders. The first two are then given a new colour by +`lvgl.color_picker.update`, and the hue-only one is given white, and they are drawn again. Each picture is compared against a hash, as in +test_lvgl_headless_render.py. If a change alters the output, the test fails, keeps the image it +saw and prints its hash; update the hash once the image has been looked at and found to be +correct. +""" + +from __future__ import annotations + +import asyncio +import hashlib +from pathlib import Path + +import pytest + +from .artifact_utils import keep_artifact +from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp +from .types import APIClientConnectedFactory, RunCompiledFunction + +WIDTH = 680 +HEIGHT = 480 + +# Every picker showing 0x3366CC. +EXPECTED_INITIAL_SHA256 = ( + "5d93f4bd98fcbe418ef8d34572e3b0912e0421cfd87e61e8cc3c49778a0e0e47" +) +# The first two pickers showing 0xFF8800, and the hue-only one red rather than stuck at white, +# after the update action. +EXPECTED_UPDATED_SHA256 = ( + "8300a6d95f38e60ea3d175aa413e71b99de6ac7ad78c9adf5248bfb350cb35d3" +) +# The LVGL version the hashes were generated against; a version change can move them. +EXPECTED_LVGL_VERSION = "9.6.0" + +REDRAW_TIMEOUT = 5.0 + + +def _check(image: Bmp, capture: Path, expected: str, name: str) -> None: + assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + digest = hashlib.sha256(image.pixels).hexdigest() + if digest == expected: + return + kept = keep_artifact(f"lvgl_color_picker_{name}_actual.bmp", capture.read_bytes()) + + from esphome.components.lvgl import LVGL_VERSION + + version_hint = "" + if LVGL_VERSION != EXPECTED_LVGL_VERSION: + version_hint = ( + f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> " + f"{LVGL_VERSION}), which is the likely cause\n" + ) + pytest.fail( + f"{name} screen does not match the expected hash\n" + f"{version_hint}" + f" expected: {expected}\n" + f" actual: {digest}\n" + f"the image that was rendered has been kept at {kept}" + ) + + +@pytest.mark.asyncio +async def test_lvgl_color_picker_render( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The colour picker draws as expected, before and after its colour is changed.""" + snapshot_dir = tmp_path / "snapshots" + monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir)) + + async with run_compiled(yaml_config), api_client_connected() as client: + _, services = await client.list_entities_services() + take_screenshot = next(s for s in services if s.name == "take_screenshot") + set_orange = next(s for s in services if s.name == "set_orange") + + async def take(name: str) -> None: + await client.execute_service(take_screenshot, {"name": name}) + + image, capture = await capture_when_drawn(take, snapshot_dir, prefix="initial") + _check(image, capture, EXPECTED_INITIAL_SHA256, "initial") + + await client.execute_service(set_orange, {}) + # The redraw follows the update on a later LVGL tick, so capture until the picture changes. + loop = asyncio.get_running_loop() + deadline = loop.time() + REDRAW_TIMEOUT + attempt = 0 + while True: + attempt += 1 + capture = snapshot_dir / f"updated-{attempt}.bmp" + await take(capture.name) + updated = await wait_for_bmp(capture) + if updated.pixels != image.pixels: + break + if loop.time() >= deadline: + pytest.fail( + f"the screen did not change within {REDRAW_TIMEOUT}s of the update" + ) + await asyncio.sleep(0.2) + _check(updated, capture, EXPECTED_UPDATED_SHA256, "updated") From 19eeab0385d08ec54c40889c241ebb3b83ef6300 Mon Sep 17 00:00:00 2001 From: Chris Boot Date: Mon, 12 Oct 2026 05:48:13 +0100 Subject: [PATCH 340/435] [wifi] Add DHCP lease cache (#17767) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 42 ++++ esphome/components/wifi/wifi_component.cpp | 41 +++- esphome/components/wifi/wifi_component.h | 42 ++++ .../wifi/wifi_component_esp_idf.cpp | 206 ++++++++++++++++-- esphome/core/defines.h | 2 + .../component_tests/wifi/test_lease_cache.py | 41 ++++ .../wifi/test-lease-cache.esp32-idf.yaml | 8 + .../validate-lease-cache-flash.esp32-idf.yaml | 13 ++ 8 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 tests/component_tests/wifi/test_lease_cache.py create mode 100644 tests/components/wifi/test-lease-cache.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index aa6d113e168..61f242d8caf 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( CONF_USE_ADDRESS, CONF_USERNAME, PLACEHOLDER_WIFI_SSID, + Framework, Platform, PlatformFramework, ) @@ -490,6 +491,38 @@ def _fast_connect_schema(value: Any) -> ConfigType: return FAST_CONNECT_SCHEMA(value) +CONF_LEASE_CACHE = "lease_cache" +CONF_MAX_AGE = "max_age" + +LEASE_CACHE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + # Defaults to RTC in _lease_cache_schema; flash wears and must be opted into. + cv.Optional(CONF_STORAGE): preferences.validate_storage, + cv.Optional(CONF_MAX_AGE): cv.positive_time_period_seconds, + } +) + + +def _lease_cache_schema(value: Any) -> ConfigType: + """Accept a plain boolean shorthand or a dict with enabled/storage/max_age keys.""" + if not isinstance(value, dict): + value = {CONF_ENABLED: value} + value = LEASE_CACHE_SCHEMA(value) + if value[CONF_ENABLED]: + cv.only_on_esp32(value) + cv.only_with_framework(Framework.ESP_IDF)(value) + if CONF_STORAGE not in value: + if not preferences.rtc_supported(): + raise cv.Invalid( + "This ESP32 variant has no RTC memory; set 'storage: flash' to " + "cache the lease in flash", + path=[CONF_STORAGE], + ) + value[CONF_STORAGE] = preferences.STORAGE_RTC + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -518,6 +551,7 @@ CONFIG_SCHEMA = cv.All( ln882x="none", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, + cv.Optional(CONF_LEASE_CACHE, default=False): _lease_cache_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, @@ -723,6 +757,14 @@ async def to_code(config): # used flash (the in_flash flag was previously ignored outside ESP8266). if preferences.is_in_flash(fast_connect[CONF_STORAGE]): cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") + lease_cache = config[CONF_LEASE_CACHE] + if lease_cache[CONF_ENABLED]: + cg.add_define("USE_WIFI_LEASE_CACHE") + # is_in_flash() also emits USE_ESP32_RTC_PREFERENCES when rtc is chosen. + if preferences.is_in_flash(lease_cache[CONF_STORAGE]): + cg.add_define("USE_WIFI_LEASE_CACHE_IN_FLASH") + if (max_age := lease_cache.get(CONF_MAX_AGE)) is not None: + cg.add(var.set_lease_cache_max_age(int(max_age.total_seconds))) # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 001dae98b83..9a9ca2cbb2a 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -398,7 +398,6 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; /// disconnect event cannot keep the networks from being tried for good. static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; -static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; #endif /// Cooldown duration when fallback AP is active and captive portal may be running @@ -702,6 +701,15 @@ void WiFiComponent::start() { this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif +#ifdef USE_WIFI_LEASE_CACHE +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + const bool lease_cache_in_flash = true; +#else + const bool lease_cache_in_flash = false; +#endif + this->lease_cache_pref_ = + global_preferences->make_preference(hash + 2, lease_cache_in_flash); +#endif SavedWifiSettings save{}; if (this->pref_.load(&save)) { @@ -712,6 +720,10 @@ void WiFiComponent::start() { sta.set_password(save.password); this->set_sta(sta); } +#ifdef USE_WIFI_LEASE_CACHE + // After the saved credentials: set_sta() clears the cache, and the lease belongs to that same network + this->load_lease_settings_(); +#endif if (this->has_sta()) { this->wifi_sta_pre_setup_(); @@ -1149,6 +1161,10 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->selected_sta_index_ = 0; // When new credentials are set (e.g., from improv), skip cooldown to retry immediately this->skip_cooldown_next_cycle_ = true; +#ifdef USE_WIFI_LEASE_CACHE + // Index 0 is a different network now, so its cached lease must not be re-applied + this->clear_lease_settings_(); +#endif } WiFiAP WiFiComponent::build_params_for_current_phase_() { @@ -1448,6 +1464,9 @@ void WiFiComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = WIFI_COMPONENT_STATE_DISABLED; +#ifdef USE_WIFI_LEASE_CACHE + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif this->wifi_disconnect_(); this->wifi_mode_(false, false); } @@ -2440,6 +2459,26 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch } #endif +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::load_lease_settings_() { + if (!this->lease_cache_pref_.load(&this->cached_lease_) || this->cached_lease_.version != SAVED_WIFI_LEASE_VERSION) { + this->cached_lease_ = {}; + return; + } + const uint8_t *ip = reinterpret_cast(&this->cached_lease_.ip); // network byte order + ESP_LOGD(TAG, "Loaded cached lease %u.%u.%u.%u (network %d, lease %us)", ip[0], ip[1], ip[2], ip[3], + this->cached_lease_.ap_index, this->cached_lease_.lease_secs); +} + +void WiFiComponent::clear_lease_settings_() { + if (this->cached_lease_.version == 0) + return; + this->cached_lease_ = {}; + this->lease_cache_pref_.save(&this->cached_lease_); + ESP_LOGD(TAG, "Lease cache: cleared"); +} +#endif + WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 9700e36e6f6..73344f66fa6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -86,6 +86,25 @@ struct SavedWifiFastConnectSettings { int8_t ap_index; } PACKED; // NOLINT +#ifdef USE_WIFI_LEASE_CACHE +// Checked on load; a zeroed record (version 0) means no cached lease. +static constexpr uint8_t SAVED_WIFI_LEASE_VERSION = 1; + +// Cached DHCP lease. IPv4 fields in network byte order, times in seconds. +struct SavedWifiLeaseSettings { + uint8_t version; + int8_t ap_index; + uint32_t ip; + uint32_t netmask; + uint32_t gateway; + uint32_t dns1; + uint32_t dns2; + uint32_t lease_secs; + uint32_t t1_renew; + uint32_t saved_at; // RTC-domain clock at capture +} PACKED; // NOLINT +#endif + enum WiFiComponentState : uint8_t { /** Nothing has been initialized yet. Internal AP, if configured, is disabled at this point. */ WIFI_COMPONENT_STATE_OFF = 0, @@ -429,6 +448,10 @@ class WiFiPowerSaveListener { virtual void on_wifi_power_save(WiFiPowerSaveMode mode) = 0; }; +// Scheduler ids +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; +static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent final : public Component { public: @@ -565,6 +588,9 @@ class WiFiComponent final : public Component { int8_t wifi_rssi(); void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } +#ifdef USE_WIFI_LEASE_CACHE + void set_lease_cache_max_age(uint32_t seconds) { this->lease_cache_max_age_ = seconds; } +#endif void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; } @@ -813,6 +839,15 @@ class WiFiComponent final : public Component { void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel); #endif +#ifdef USE_WIFI_LEASE_CACHE + void load_lease_settings_(); + void save_lease_settings_(); + void clear_lease_settings_(); + // Cached lease belongs to the selected network and is still provably valid. + bool lease_valid_for_apply_() const; + void renew_cached_lease_(); +#endif + // Post-connect roaming methods void check_roaming_(uint32_t now); void process_roaming_scan_(); @@ -899,6 +934,13 @@ class WiFiComponent final : public Component { #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; #endif +#ifdef USE_WIFI_LEASE_CACHE + ESPPreferenceObject lease_cache_pref_; + SavedWifiLeaseSettings cached_lease_{}; // version 0 = none + // Current IP came from the cache (DHCP not run); suppresses saving until a real lease arrives. + bool applied_cached_lease_{false}; + uint32_t lease_cache_max_age_{0}; // 0 = no cap +#endif #ifdef USE_WIFI_CONNECT_TRIGGER Trigger<> connect_trigger_; #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 3769e26831b..388e3edb98d 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -35,6 +35,12 @@ #include "lwip/apps/sntp.h" #include "lwip/dns.h" #include "lwip/err.h" +#ifdef USE_WIFI_LEASE_CACHE +#include // esp_netif_get_netif_impl() +#include // esp_rtc_get_time_us() -- RTC-domain clock for lease age +#include "lwip/dhcp.h" +#include "lwip/netif.h" +#endif #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -553,6 +559,68 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return true; } +// sntp_servermode_dhcp() is an empty macro unless lwIP takes NTP servers from DHCP; that build needs the core lock +static void disable_dhcp_sntp() { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); +} + +#ifdef USE_WIFI_LEASE_CACHE +// Minimum lease time that must remain before a cached lease is applied. +static constexpr uint32_t LEASE_APPLY_MARGIN_SECS = 60; + +// Seconds since capture on the RTC-domain clock; UINT32_MAX if the clock was reset (age unknown). +static uint32_t lease_cache_age_secs(const SavedWifiLeaseSettings &lease) { + uint32_t now = static_cast(esp_rtc_get_time_us() / 1000000ULL); + return now >= lease.saved_at ? now - lease.saved_at : UINT32_MAX; +} + +// Renewal deadline (T1) from capture; half the lease when T1 is missing or invalid (RFC 2131). +static uint32_t lease_renew_at_secs(const SavedWifiLeaseSettings &lease) { + uint32_t renew_at = lease.t1_renew; + if (renew_at == 0 || renew_at >= lease.lease_secs) + renew_at = lease.lease_secs / 2; + return renew_at; +} + +// Starting the DHCP client drops the static address and DNS servers, so open connections reset +void WiFiComponent::renew_cached_lease_() { + ESP_LOGI(TAG, "Cached lease renew deadline reached; re-acquiring the address over DHCP, connections may drop"); + this->applied_cached_lease_ = false; + // Same SNTP leak guard as the DHCP path: https://github.com/esphome/issues/issues/2299 + disable_dhcp_sntp(); + esp_err_t err = esp_netif_dhcpc_start(s_sta_netif); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Lease renew: starting DHCP client failed: %s, retrying in 60 s", esp_err_to_name(err)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, 60000, [this]() { this->renew_cached_lease_(); }); + } +} + +bool WiFiComponent::lease_valid_for_apply_() const { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + if (lease.version != SAVED_WIFI_LEASE_VERSION || this->selected_sta_index_ < 0 || + lease.ap_index != this->selected_sta_index_) + return false; + // The age is only known when the RTC clock survived the reset (deep sleep or software reboot). + esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP && reason != ESP_RST_SW) + return false; + uint32_t age = lease_cache_age_secs(lease); + if (age == UINT32_MAX) + return false; + if (this->lease_cache_max_age_ != 0 && age > this->lease_cache_max_age_) + return false; + // Past T1 the lease needs DHCP renewal anyway. + if (age >= lease_renew_at_secs(lease)) + return false; + // Scale the margin with the lease: the RTC RC oscillator drifts a few percent. + uint32_t margin = std::max(LEASE_APPLY_MARGIN_SECS, lease.lease_secs / 16); + return lease.lease_secs > age + margin; +} +#endif + bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) @@ -571,18 +639,43 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return false; } - if (!manual_ip.has_value()) { +#ifdef USE_WIFI_LEASE_CACHE + // Without a manual IP, a still-valid cached lease is applied as a static IP to skip DHCP. + optional effective_ip = manual_ip; + this->applied_cached_lease_ = false; + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); + if (!effective_ip.has_value() && this->lease_valid_for_apply_()) { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + esp_ip4_addr_t ip4{lease.ip}, mask4{lease.netmask}, gw4{lease.gateway}, dns1_4{lease.dns1}, dns2_4{lease.dns2}; + ManualIP cached; + cached.static_ip = network::IPAddress(&ip4); + cached.gateway = network::IPAddress(&gw4); + cached.subnet = network::IPAddress(&mask4); + cached.dns1 = network::IPAddress(&dns1_4); + cached.dns2 = network::IPAddress(&dns2_4); + effective_ip = cached; + this->applied_cached_lease_ = true; + + uint32_t age = lease_cache_age_secs(lease); + uint32_t remaining = lease.lease_secs > age ? lease.lease_secs - age : 0; + ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%us left, skipping DHCP", IP2STR(&ip4), remaining); + + // Still awake at T1: switch to real DHCP. lease_valid_for_apply_() guarantees age < T1. + uint32_t renew_in = lease_renew_at_secs(lease) - age; + // Clamp below UINT32_MAX, which the scheduler treats as "don't run". + uint32_t renew_ms = + static_cast(std::min(static_cast(renew_in) * 1000ULL, UINT32_MAX - 1)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, renew_ms, [this]() { this->renew_cached_lease_(); }); + } +#else + const optional &effective_ip = manual_ip; +#endif + + if (!effective_ip.has_value()) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - { -#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 - // sntp_servermode_dhcp() is an empty macro unless lwIP is built with - // DHCP-supplied NTP servers, so only that build needs the core lock. - LwIPLock lock; -#endif - sntp_servermode_dhcp(false); - } + disable_dhcp_sntp(); // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { @@ -596,9 +689,9 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_ip_info_t info; // struct of ip4_addr_t with ip, netmask, gw - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; + info.ip = effective_ip->static_ip; + info.gw = effective_ip->gateway; + info.netmask = effective_ip->subnet; err = esp_netif_dhcpc_stop(s_sta_netif); if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { ESP_LOGV(TAG, "Stopping DHCP client failed: %s", esp_err_to_name(err)); @@ -610,18 +703,82 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_dns_info_t dns; - if (manual_ip->dns1.is_set()) { - dns.ip = manual_ip->dns1; + if (effective_ip->dns1.is_set()) { + dns.ip = effective_ip->dns1; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns); } - if (manual_ip->dns2.is_set()) { - dns.ip = manual_ip->dns2; + if (effective_ip->dns2.is_set()) { + dns.ip = effective_ip->dns2; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns); } return true; } +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::save_lease_settings_() { + // A cached lease applied as a static IP has no DHCP timing to capture. + if (s_sta_netif == nullptr || this->applied_cached_lease_) + return; + +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) + return; +#endif + + esp_netif_ip_info_t info; + if (esp_netif_get_ip_info(s_sta_netif, &info) != ESP_OK || info.ip.addr == 0) + return; + + SavedWifiLeaseSettings lease{}; + lease.version = SAVED_WIFI_LEASE_VERSION; + lease.ap_index = this->selected_sta_index_; + lease.ip = info.ip.addr; + lease.netmask = info.netmask.addr; + lease.gateway = info.gw.addr; + + esp_netif_dns_info_t dns; + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns1 = dns.ip.u_addr.ip4.addr; + } + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns2 = dns.ip.u_addr.ip4.addr; + } + + // Lease timing is only in lwIP's internal DHCP state; without it the lease can't be aged. + bool have_timing = false; + if (auto *impl = static_cast(esp_netif_get_netif_impl(s_sta_netif))) { + LwIPLock lock; + if (struct dhcp *d = netif_dhcp_data(impl)) { + lease.lease_secs = d->offered_t0_lease; + lease.t1_renew = d->offered_t1_renew; + have_timing = true; + } + } + if (!have_timing) + return; + + lease.saved_at = static_cast(esp_rtc_get_time_us() / 1000000ULL); + + // On flash, skip a write that would only bump saved_at (an older one only overstates the age), + // unless it is from a previous RTC clock period (power cycle). +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + constexpr size_t compare_len = offsetof(SavedWifiLeaseSettings, saved_at); +#else + constexpr size_t compare_len = sizeof(SavedWifiLeaseSettings); +#endif + const SavedWifiLeaseSettings &previous = this->cached_lease_; + if (previous.saved_at <= lease.saved_at && memcmp(&previous, &lease, compare_len) == 0) + return; + + this->lease_cache_pref_.save(&lease); + this->cached_lease_ = lease; + + ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %us)", IP2STR(&info.ip), lease.ap_index, + lease.lease_secs); +} +#endif + esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { @@ -853,9 +1010,17 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { // This ensures wifi.connected condition returns true in listener automations this->pending_.connect_state = true; #endif - // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here -#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + // For static IP configurations (manual or cached lease), GOT_IP event may not fire, so notify IP listeners here +#if defined(USE_WIFI_IP_STATE_LISTENERS) && (defined(USE_WIFI_MANUAL_IP) || defined(USE_WIFI_LEASE_CACHE)) + bool static_ip = false; +#ifdef USE_WIFI_MANUAL_IP + const WiFiAP *config = this->get_selected_sta_(); + static_ip = config && config->get_manual_ip().has_value(); +#endif +#ifdef USE_WIFI_LEASE_CACHE + static_ip = static_ip || this->applied_cached_lease_; +#endif + if (static_ip) { this->notify_ip_state_listeners_(); } #endif @@ -889,6 +1054,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip; ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#ifdef USE_WIFI_LEASE_CACHE + this->save_lease_settings_(); // events are drained on the main loop +#endif #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 00e53765c24..7ff80cbbd15 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -464,6 +464,8 @@ #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH +#define USE_WIFI_LEASE_CACHE +#define USE_WIFI_LEASE_CACHE_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/tests/component_tests/wifi/test_lease_cache.py b/tests/component_tests/wifi/test_lease_cache.py new file mode 100644 index 00000000000..56a9f1b66bb --- /dev/null +++ b/tests/component_tests/wifi/test_lease_cache.py @@ -0,0 +1,41 @@ +"""Tests for the lease_cache storage validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32C2 +from esphome.components.wifi import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(lease_cache: dict) -> dict: + return CONFIG_SCHEMA( + { + "ssid": "net", + "password": "secret12", + "use_address": "test.local", + "lease_cache": lease_cache, + } + ) + + +def test_storage_defaults_to_rtc(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + config = _validate({"max_age": "12h"}) + assert config["lease_cache"]["storage"] == "rtc" + + +def test_no_rtc_variant_requires_flash(set_core_config: SetCoreConfigCallable) -> None: + """A variant without RTC memory must say storage: flash; the error points at that key.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C2} + ) + with pytest.raises(cv.Invalid, match="no RTC memory") as exc: + _validate({"max_age": "12h"}) + assert exc.value.path == ["lease_cache", "storage"] + config = _validate({"storage": "flash"}) + assert config["lease_cache"]["storage"] == "flash" diff --git a/tests/components/wifi/test-lease-cache.esp32-idf.yaml b/tests/components/wifi/test-lease-cache.esp32-idf.yaml new file mode 100644 index 00000000000..47073b91062 --- /dev/null +++ b/tests/components/wifi/test-lease-cache.esp32-idf.yaml @@ -0,0 +1,8 @@ +# DHCP lease cache with the default RTC storage and max_age. +wifi: + lease_cache: + enabled: true + max_age: 12h + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml b/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml new file mode 100644 index 00000000000..da74ce50ae6 --- /dev/null +++ b/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml @@ -0,0 +1,13 @@ +# lease_cache with storage: flash, alongside manual_ip. +wifi: + lease_cache: + enabled: true + storage: flash + manual_ip: + static_ip: 192.168.1.100 + gateway: 192.168.1.1 + subnet: 255.255.255.0 + dns1: 1.1.1.1 + +packages: + wifi: !include common.yaml From f8076fe90b166234b770b23346882147325a444a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 18:48:41 -1000 Subject: [PATCH 341/435] [core] Add UnavoidableBlockingScope for blocking that cannot be shortened (#19020) --- esphome/core/application.h | 33 ++++++ esphome/core/millis_internal.h | 1 + tests/components/core/test_blocking_scope.cpp | 103 ++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 tests/components/core/test_blocking_scope.cpp diff --git a/esphome/core/application.h b/esphome/core/application.h index b91bc187e6f..f9f98a67070 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -390,6 +390,7 @@ class Application { friend Component; friend class Scheduler; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -632,6 +633,38 @@ class LoopBlockingGuard { static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); }; +/// Leaves a stretch of the current loop pass out of the blocking warning. +/// +/// Only for work that cannot be made shorter or split across passes: radio +/// bring-up, a first network connect, a key generation. Work that could be +/// cached, deferred, polled or turned into a state machine stays outside, so +/// the warning keeps finding it. +/// +/// Affects only work timed by LoopBlockingGuard: loop() and scheduler +/// callbacks, not setup(). Main loop task only. The watchdog is not fed, so +/// pair long work with watchdog::WatchdogManager. Scopes may nest. +/// Afterwards App.get_loop_component_start_time() can be earlier than a +/// millis() read taken before the scope; time the work with millis() at both ends. +/// +/// UnavoidableBlockingScope scope; +/// this->generate_key_(); +class UnavoidableBlockingScope { + public: + UnavoidableBlockingScope() : started_(MillisInternal::get()), pass_start_(App.get_loop_component_start_time()) {} + ~UnavoidableBlockingScope() { + // Move the pass start seen at entry forward by the time spent here, clamped to now + const uint32_t now = MillisInternal::get(); + const uint32_t moved = this->pass_start_ + (now - this->started_); + App.set_loop_component_start_time_(static_cast(now - moved) < 0 ? now : moved); + } + UnavoidableBlockingScope(const UnavoidableBlockingScope &) = delete; + UnavoidableBlockingScope &operator=(const UnavoidableBlockingScope &) = delete; + + private: + uint32_t started_; + uint32_t pass_start_; +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 7297d223572..c9b84540780 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -51,6 +51,7 @@ class MillisInternal { } friend class Application; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; }; } // namespace esphome diff --git a/tests/components/core/test_blocking_scope.cpp b/tests/components/core/test_blocking_scope.cpp new file mode 100644 index 00000000000..8bdeec19ea2 --- /dev/null +++ b/tests/components/core/test_blocking_scope.cpp @@ -0,0 +1,103 @@ +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome { + +// The scope must push the pass start forward by the time it covers and by +// nothing else, so the blocking guard sees only the work outside it +TEST(UnavoidableBlockingScope, ExcludesItsDurationFromThePass) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + ASSERT_EQ(App.get_loop_component_start_time(), pass_start); + + const uint32_t before = millis(); + { + UnavoidableBlockingScope scope; + delay(30); + } + const uint32_t excused = millis() - before; + + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 30u); + EXPECT_LE(moved, excused); +} + +TEST(UnavoidableBlockingScope, ZeroLengthScopeLeavesTheStartAlone) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); + { UnavoidableBlockingScope scope; } + EXPECT_LE(App.get_loop_component_start_time() - pass_start, millis() - before); +} + +// Nested scopes leave out the outer span exactly once, and never move the +// start past now +TEST(UnavoidableBlockingScope, NestedScopesExcuseTheOuterSpanOnce) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); + { + UnavoidableBlockingScope outer; + { + UnavoidableBlockingScope inner; + delay(30); + } + delay(5); + } + const uint32_t excused = millis() - before; + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 35u); + EXPECT_LE(moved, excused); + EXPECT_GE(static_cast(millis() - App.get_loop_component_start_time()), 0); +} + +namespace { +// Static: the guard publishes the component to App and nothing clears it. +// One instance per test, since a ratcheted threshold is permanent +class DummyComponent : public Component {}; +DummyComponent &blocking_test_component(size_t index) { + static DummyComponent components[2]; + return components[index]; +} +} // namespace + +// The excused stretch must neither warn nor ratchet the component's threshold +TEST(UnavoidableBlockingScope, ExcusedStretchDoesNotRatchetTheThreshold) { + DummyComponent &component = blocking_test_component(0); + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + } + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_EQ(threshold_after, threshold_before); +} + +// Work outside the scope is still measured and still ratchets +TEST(UnavoidableBlockingScope, WorkOutsideTheScopeStillRatchetsTheThreshold) { + DummyComponent &component = blocking_test_component(1); + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(20); + } + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_GT(threshold_after, threshold_before); +} + +} // namespace esphome From 1c1b4d13adf04c93cfb96393070e5293949d8d67 Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 00:58:41 -0400 Subject: [PATCH 342/435] [core] Mask integer-valued credentials without changing code generation (#20430) --- .../components/fingerprint_grow/__init__.py | 4 +- esphome/components/openthread/__init__.py | 8 ++- esphome/config_validation.py | 22 +++++-- esphome/yaml_util.py | 34 ++++++++--- .../fingerprint_grow/__init__.py | 1 + .../fingerprint_grow/test_init.py | 19 ++++++ tests/component_tests/openthread/__init__.py | 1 + tests/component_tests/openthread/test_init.py | 39 ++++++++++++ tests/unit_tests/test_config_validation.py | 47 +++++++++++---- tests/unit_tests/test_yaml_util.py | 60 ++++++++++++++----- 10 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 tests/component_tests/fingerprint_grow/__init__.py create mode 100644 tests/component_tests/fingerprint_grow/test_init.py create mode 100644 tests/component_tests/openthread/__init__.py create mode 100644 tests/component_tests/openthread/test_init.py diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 0e522ced258..e496e9c635e 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -81,8 +81,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_IDLE_PERIOD_TO_SLEEP ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_PASSWORD): cv.uint32_t, - cv.Optional(CONF_NEW_PASSWORD): cv.uint32_t, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.uint32_t), + cv.Optional(CONF_NEW_PASSWORD): cv.sensitive(cv.uint32_t), cv.Optional(CONF_ON_FINGER_SCAN_START): automation.validate_automation({}), cv.Optional(CONF_ON_FINGER_SCAN_MATCHED): automation.validate_automation( {} diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 25c7a78bebe..6fca8bfdd9a 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -166,10 +166,10 @@ _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, cv.Optional(CONF_CHANNEL): cv.int_range(min=11, max=26), - cv.Optional(CONF_NETWORK_KEY): cv.hex_int, + cv.Optional(CONF_NETWORK_KEY): cv.sensitive(cv.hex_int), cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, cv.Optional(CONF_NETWORK_NAME): cv.string_strict, - cv.Optional(CONF_PSKC): cv.hex_int, + cv.Optional(CONF_PSKC): cv.sensitive(cv.hex_int), cv.Optional(CONF_MESH_LOCAL_PREFIX): cv.ipv6network, } ) @@ -243,7 +243,9 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), + cv.Optional(CONF_TLV): cv.sensitive( + cv.All(cv.string_strict, _validate_tlv_hex) + ), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.decibel, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 64d22f9a143..e7d2501e5df 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -114,7 +114,13 @@ from esphome.schema_extractors import ( # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import KeyInvalid, _Schema -from esphome.yaml_util import SensitiveStr, make_data_base +from esphome.yaml_util import ( + Sensitive, + SensitiveHexInt, + SensitiveInt, + SensitiveStr, + make_data_base, +) if typing.TYPE_CHECKING: from esphome.types import ConfigType @@ -562,11 +568,17 @@ class SensitiveValidator: def __call__(self, value: typing.Any) -> typing.Any: validated = self.inner(value) - # Tag string results so yaml_util.dump can mask them. Non-string - # results pass through unchanged; already-tagged values are not - # re-wrapped to keep nested cv.sensitive applications idempotent. - if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + # Already-tagged values are returned as-is so nested cv.sensitive is + # idempotent; bool is an int subclass and must stay bool. Other types + # (e.g. lambdas) pass through unmasked. + if isinstance(validated, (Sensitive, bool)): + return validated + if isinstance(validated, str): return SensitiveStr(validated) + if isinstance(validated, int): + return (SensitiveHexInt if isinstance(validated, HexInt) else SensitiveInt)( + validated + ) return validated def __repr__(self) -> str: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e892664724c..f550bb543b3 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -81,7 +81,13 @@ def _raise_invalid(message: str) -> None: raise Invalid(message) -class SensitiveStr(str): +class Sensitive: + """Marker base for validated values masked in user-visible YAML output.""" + + __slots__ = () + + +class SensitiveStr(Sensitive, str): """Marker subclass for validated strings that should be masked in user-visible YAML output. ``cv.sensitive`` wraps validated values in this type so ``dump()`` can render them with ANSI conceal codes without @@ -91,6 +97,16 @@ class SensitiveStr(str): __slots__ = () +class SensitiveInt(Sensitive, int): + """Marker subclass for integer credentials masked in user-visible YAML.""" + + __slots__ = () + + +class SensitiveHexInt(core.HexInt, SensitiveInt): + """Keep hexadecimal formatting and code generation for sensitive integers.""" + + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: """Context manager that records every file loaded by the YAML loader. @@ -1361,11 +1377,11 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_stringify(path.as_posix()) return self.represent_stringify(value) - def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: + def represent_sensitive(self, value: Sensitive) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive - # values; otherwise let ``represent_stringify`` handle ``!secret`` - # precedence and the plain-str fallthrough. Conceal sequence is - # emitted as literal ``\033`` text (not actual ESC bytes) so the + # values; otherwise use the plain string/integer representer to + # preserve ``!secret`` references and scalar types. Conceal sequence + # is emitted as literal ``\033`` text (not actual ESC bytes) so the # output matches the prior regex format and device-builder's # ``\033[8m...\033[28m`` parser keeps working. if self._redact_sensitive and not is_secret(value): @@ -1373,6 +1389,10 @@ class ESPHomeDumper(yaml.SafeDumper): tag="tag:yaml.org,2002:str", value=f"\\033[8m{value}\\033[28m", ) + # MRO dispatch picks this representer over represent_int, so + # integers have to be forwarded explicitly. + if isinstance(value, int): + return self.represent_int(value) return self.represent_stringify(value) # pylint: disable=arguments-renamed @@ -1452,8 +1472,8 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) -# MRO-walked dispatch; SensitiveStr's own entry wins over the str one. -ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) +# MRO-walked dispatch; Sensitive precedes str and int in every marker's MRO. +ESPHomeDumper.add_multi_representer(Sensitive, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify) diff --git a/tests/component_tests/fingerprint_grow/__init__.py b/tests/component_tests/fingerprint_grow/__init__.py new file mode 100644 index 00000000000..42c55115985 --- /dev/null +++ b/tests/component_tests/fingerprint_grow/__init__.py @@ -0,0 +1 @@ +"""Tests for the fingerprint_grow component.""" diff --git a/tests/component_tests/fingerprint_grow/test_init.py b/tests/component_tests/fingerprint_grow/test_init.py new file mode 100644 index 00000000000..3df54f88476 --- /dev/null +++ b/tests/component_tests/fingerprint_grow/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the fingerprint_grow component schema.""" + +import pytest + +from esphome.components.fingerprint_grow import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.yaml_util import Sensitive +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("field", ["password", "new_password"]) +def test_password_is_sensitive( + field: str, set_core_config: SetCoreConfigCallable +) -> None: + """Numeric sensor passwords are tagged for redaction and keep their value.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({field: 305419896}) + assert isinstance(config[field], Sensitive) + assert config[field] == 305419896 diff --git a/tests/component_tests/openthread/__init__.py b/tests/component_tests/openthread/__init__.py new file mode 100644 index 00000000000..d786022c123 --- /dev/null +++ b/tests/component_tests/openthread/__init__.py @@ -0,0 +1 @@ +"""Tests for the openthread component.""" diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py new file mode 100644 index 00000000000..7ba9bdd4bba --- /dev/null +++ b/tests/component_tests/openthread/test_init.py @@ -0,0 +1,39 @@ +"""Tests for the openthread component schema.""" + +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.yaml_util import Sensitive +from tests.component_tests.types import SetCoreConfigCallable + +NETWORK_KEY = 0xDFD34F0F05CAD978EC4E32B0413038FF +PSKC = 0xC23A76E98F1A6483639B1AC1271E2E27 +TLV = "0e080000000000010000000300001435060004001fffe0" + + +@pytest.mark.parametrize( + ("config", "field", "expected"), + [ + ({"network_key": hex(NETWORK_KEY)}, "network_key", NETWORK_KEY), + ({"network_key": hex(NETWORK_KEY), "pskc": hex(PSKC)}, "pskc", PSKC), + ({"tlv": TLV}, "tlv", TLV), + ], +) +def test_credentials_are_sensitive( + config: dict[str, str], + field: str, + expected: int | str, + set_core_config: SetCoreConfigCallable, +) -> None: + """Thread credentials are tagged for redaction without changing their value + or the hexadecimal integer type used to build sdkconfig options.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + validated = CONFIG_SCHEMA(config) + assert isinstance(validated[field], Sensitive) + assert validated[field] == expected + assert isinstance(validated[field], HexInt) == isinstance(expected, int) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 742f07c8dec..0f68ccb34ab 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import importlib import io import json @@ -11,7 +12,7 @@ from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol -from esphome import config_validation as cv +from esphome import config_validation as cv, cpp_generator from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -69,6 +70,8 @@ from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry from esphome.yaml_util import ( ESPHomeDataBase, + SensitiveHexInt, + SensitiveInt, SensitiveStr, load_yaml, make_data_base, @@ -201,12 +204,13 @@ def test_sensitive__wraps_string_result_in_sensitive_str() -> None: assert result == "hunter2" -def test_sensitive__does_not_double_tag_already_sensitive() -> None: - # If the inner validator already returns a SensitiveStr (e.g., nested +@pytest.mark.parametrize( + "pre_tagged", [SensitiveStr("hunter2"), SensitiveInt(5), SensitiveHexInt(0x12)] +) +def test_sensitive__does_not_double_tag_already_sensitive(pre_tagged: object) -> None: + # If the inner validator already returns a tagged value (e.g., nested # cv.sensitive wrappers), re-tagging is a no-op rather than a new - # SensitiveStr around the same value. - pre_tagged = SensitiveStr("hunter2") - + # wrapper around the same value. def inner(_value): return pre_tagged @@ -216,16 +220,33 @@ def test_sensitive__does_not_double_tag_already_sensitive() -> None: assert result is pre_tagged -def test_sensitive__non_string_result_passes_through() -> None: - # If an inner validator returns something other than a string (e.g., a - # Lambda template), the sensitive wrapper must not coerce it. - sentinel = object() - +@pytest.mark.parametrize("passthrough", [object(), True, False]) +def test_sensitive__non_string_result_passes_through(passthrough: object) -> None: + # If an inner validator returns something other than a string or integer + # (e.g., a Lambda template or a bool), the sensitive wrapper must not + # coerce it. def inner(_value): - return sentinel + return passthrough validator = cv.sensitive(inner) - assert validator("anything") is sentinel + assert validator("anything") is passthrough + + +@pytest.mark.parametrize("inner", [cv.uint32_t, cv.hex_int]) +def test_sensitive__integer_preserves_codegen(inner: Callable[[str], int]) -> None: + """Tag credentials without changing their firmware expressions.""" + original = inner("0x12345678") + tagged = cv.sensitive(inner)("0x12345678") + assert isinstance(tagged, SensitiveInt) + assert tagged == original + assert str(cpp_generator.safe_exp(tagged)) == str(cpp_generator.safe_exp(original)) + assert isinstance(tagged, HexInt) == isinstance(original, HexInt) + + +def test_sensitive__integer_validation_still_rejects_out_of_range() -> None: + """Keep the inner validator's range checks.""" + with pytest.raises(Invalid): + cv.sensitive(cv.uint32_t)(1 << 32) def test_sensitive__is_detectable_via_isinstance() -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e1c3080e291..60b05b0b2bf 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import io from pathlib import Path import shutil @@ -1901,9 +1902,39 @@ def test_dump__redacts_sensitive_str_by_default() -> None: ) # the raw value is only present inside the wrap -def test_dump__show_secrets_emits_sensitive_str_raw() -> None: - out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) - assert "hunter2" in out +@pytest.mark.parametrize( + ("validator", "value", "rendered"), + [ + (cv.uint32_t, 305419896, "305419896"), + ( + cv.hex_int, + 0x00112233445566778899AABBCCDDEEFF, + "0x112233445566778899AABBCCDDEEFF", + ), + (cv.int_, -42, "-42"), + ], +) +def test_dump__redacts_sensitive_integer( + validator: Callable[[int], int], value: int, rendered: str +) -> None: + """Mask integer credentials without changing their numeric value or format.""" + sensitive = cv.sensitive(validator)(value) + assert sensitive == value + assert str(sensitive) == rendered + out = yaml_util.dump({"credential": sensitive}) + assert f"\\033[8m{rendered}\\033[28m" in out + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (SensitiveStr("hunter2"), "password: hunter2"), + (cv.sensitive(cv.hex_int)(0x12345678), "password: 0x12345678"), + ], +) +def test_dump__show_secrets_emits_sensitive_raw(value: object, expected: str) -> None: + out = yaml_util.dump({"password": value}, show_secrets=True) + assert out.strip() == expected assert "\\033[8m" not in out assert "\\033[28m" not in out @@ -1914,19 +1945,20 @@ def test_dump__plain_str_is_not_redacted() -> None: assert "\\033[8m" not in out -def test_dump__secret_reference_wins_over_redaction() -> None: +@pytest.mark.parametrize( + "value", [SensitiveStr("hunter2"), cv.sensitive(cv.uint32_t)(305419896)] +) +def test_dump__secret_reference_wins_over_redaction( + value: object, monkeypatch: pytest.MonkeyPatch +) -> None: # If the value also has an entry in _SECRET_VALUES (i.e., it was loaded # via !secret), the dump should render it as !secret , not as a - # redacted scalar. SensitiveStr layered on top must not change that. - value = SensitiveStr("hunter2") - yaml_util._SECRET_VALUES[str(value)] = "my_secret_name" - try: - out = yaml_util.dump({"password": value}) - assert "!secret" in out - assert "my_secret_name" in out - assert "\\033[8m" not in out - finally: - yaml_util._SECRET_VALUES.clear() + # redacted scalar. The sensitive marker layered on top must not change that. + monkeypatch.setitem(yaml_util._SECRET_VALUES, str(value), "my_secret_name") + out = yaml_util.dump({"password": value}) + assert "!secret" in out + assert "my_secret_name" in out + assert "\\033[8m" not in out def test_dump__redaction_flag_does_not_leak_between_calls() -> None: From 29db7b3f51b80cb794a9844d092664e9b7cd6268 Mon Sep 17 00:00:00 2001 From: Ivan Kruglov <4630547+ikruglov@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:33:44 +0200 Subject: [PATCH 343/435] [bl0939] Add optional internal temperature sensor (#19415) Co-authored-by: J. Nick Koston --- esphome/components/bl0939/bl0939.cpp | 8 ++++++++ esphome/components/bl0939/bl0939.h | 2 ++ esphome/components/bl0939/sensor.py | 12 ++++++++++++ tests/components/bl0939/common.yaml | 2 ++ 4 files changed, 24 insertions(+) diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 0e2da59ab1a..3a34fad1400 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -116,6 +116,13 @@ void BL0939::received_package_(const DataPacket *data) const { energy_sensor_sum_->publish_state(total_energy_consumption); } + if (this->internal_temperature_sensor_ != nullptr) { + const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF; + // Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45. + const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f; + this->internal_temperature_sensor_->publish_state(temperature); + } + ESP_LOGV(TAG, "BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %" PRId32 ", CntB %" PRId32 ", ∫P1 %fkWh, ∫P2 %fkWh", v_rms, ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption); @@ -131,6 +138,7 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit LOG_SENSOR("", "Energy 1", this->energy_sensor_1_); LOG_SENSOR("", "Energy 2", this->energy_sensor_2_); LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_); + LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_); } uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index 333bca37152..a22d96ad406 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -66,6 +66,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; } void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; } void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; } + void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; } void loop() override; @@ -84,6 +85,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { sensor::Sensor *energy_sensor_1_{nullptr}; sensor::Sensor *energy_sensor_2_{nullptr}; sensor::Sensor *energy_sensor_sum_{nullptr}; + sensor::Sensor *internal_temperature_sensor_{nullptr}; // Divide by this to turn into Watt float power_reference_ = BL0939_PREF; diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 9f6bd7814e4..401fafedd5e 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -3,14 +3,18 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_INTERNAL_TEMPERATURE, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_CELSIUS, UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_WATT, @@ -82,6 +86,13 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -103,3 +114,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_ENERGY_1, var.set_energy_sensor_1) await sensors(CONF_ENERGY_2, var.set_energy_sensor_2) await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) diff --git a/tests/components/bl0939/common.yaml b/tests/components/bl0939/common.yaml index a47aa056068..0785fae029a 100644 --- a/tests/components/bl0939/common.yaml +++ b/tests/components/bl0939/common.yaml @@ -16,3 +16,5 @@ sensor: name: BL0939 Energy 2 energy_total: name: BL0939 Total energy + internal_temperature: + name: BL0939 Internal Temperature From 2692e0f189bb56b2f28cd9312f234fc4b84b1083 Mon Sep 17 00:00:00 2001 From: Phil Date: Mon, 12 Oct 2026 06:36:07 +0100 Subject: [PATCH 344/435] [remote_base] Add Nexus RF protocol support (#13577) Co-authored-by: zry98 Co-authored-by: Citizen Co-authored-by: Citizen07 <34106434+Citizen07@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Signed-off-by: zry98 --- esphome/components/remote_base/__init__.py | 60 +++++++++++++ .../components/remote_base/nexus_protocol.cpp | 83 ++++++++++++++++++ .../components/remote_base/nexus_protocol.h | 30 +++++++ tests/components/remote_base/__init__.py | 2 +- tests/components/remote_base/nexus_test.cpp | 84 +++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_receiver/esp32-common.yaml | 5 ++ .../remote_transmitter/common-buttons.yaml | 10 +++ 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/nexus_protocol.cpp create mode 100644 esphome/components/remote_base/nexus_protocol.h create mode 100644 tests/components/remote_base/nexus_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 01e25714e7b..af043c0ea82 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -9,6 +9,7 @@ from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_BATTERY_LEVEL, CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, @@ -20,7 +21,9 @@ from esphome.const import ( CONF_DELTA, CONF_DEVICE, CONF_FAMILY, + CONF_FORCE_UPDATE, CONF_GROUP, + CONF_HUMIDITY, CONF_ID, CONF_INDEX, CONF_INVERTED, @@ -37,6 +40,7 @@ from esphome.const import ( CONF_SOURCE, CONF_STATE, CONF_SYNC, + CONF_TEMPERATURE, CONF_TIMES, CONF_TRIGGER_ID, CONF_TYPE_ID, @@ -2161,6 +2165,62 @@ register_protocol_action( ) +# Nexus +NexusData, NexusBinarySensor, NexusTrigger, _, NexusDumper = declare_protocol("Nexus") +NEXUS_SCHEMA = cv.Schema( + { + cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4), + cv.Required(CONF_ADDRESS): cv.hex_uint8_t, + } +) +NEXUS_ACTION_SCHEMA = NEXUS_SCHEMA.extend( + { + cv.Required(CONF_TEMPERATURE): cv.All( + cv.float_, cv.Range(min=-204.8, max=204.7) + ), + cv.Required(CONF_HUMIDITY): cv.uint8_t, + cv.Optional(CONF_BATTERY_LEVEL, default=True): cv.boolean, + cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, + } +) + + +@register_binary_sensor("nexus", NexusBinarySensor, NEXUS_SCHEMA) +def nexus_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + NexusData, + ("address", config[CONF_ADDRESS]), + ("channel", config[CONF_CHANNEL]), + ) + ) + ) + + +@register_trigger("nexus", NexusTrigger, NexusData) +def nexus_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("nexus", NexusDumper) +def nexus_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "nexus", + "Nexus", + NEXUS_ACTION_SCHEMA, + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_TEMPERATURE, "temperature", cg.float_), + (CONF_HUMIDITY, "humidity", cg.uint8), + (CONF_BATTERY_LEVEL, "battery_level", cg.bool_), + (CONF_FORCE_UPDATE, "force_update", cg.bool_), +) + + # Midea MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol( "Midea" diff --git a/esphome/components/remote_base/nexus_protocol.cpp b/esphome/components/remote_base/nexus_protocol.cpp new file mode 100644 index 00000000000..6d7ddbb7ebd --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.cpp @@ -0,0 +1,83 @@ +#include "nexus_protocol.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.nexus"); + +/** + * Example Nexus (Digoo) temperature & humidity sensor packet (36 data bits, each a mark and a space): + * 10000101 | 1 | 0 | 01 | 000010001010 | 1111 | 01000101 + * address (sensor ID) | battery | force update | channel | temperature | - | humidity + * + * The channel is sent 0-based and shown 1-based, temperature is 12 bit signed scaled by 10, + * `-`s are constant bits. + * + * Ref: https://manual.pilight.org/protocols/433.92/weather/nexus.html + */ + +static constexpr uint8_t NBITS = 36; + +static constexpr uint32_t SYNC_HIGH_US = 500; +static constexpr uint32_t SYNC_LOW_US = 3900; +static constexpr uint32_t BIT_HIGH_US = 500; +static constexpr uint32_t BIT_ZERO_LOW_US = 980; +static constexpr uint32_t BIT_ONE_LOW_US = 1950; + +void NexusProtocol::encode(RemoteTransmitData *dst, const NexusData &data) { + // A templated value may be out of range or NaN (an unavailable sensor); send 0 for NaN + const float temperature = std::isnan(data.temperature) ? 0.0f : std::clamp(data.temperature, -204.8f, 204.7f); + const auto t = static_cast(lroundf(temperature * 10.0f)); + const uint64_t packet = uint64_t(data.address) << 28 | uint64_t(data.battery_level) << 27 | + uint64_t(data.force_update) << 26 | uint64_t((data.channel - 1) & 0b11) << 24 | + uint64_t(t & 0x0FFF) << 12 | 0b1111 << 8 | data.humidity; + + dst->reserve(NBITS * 2 + 2); + for (int8_t i = NBITS - 1; i >= 0; i--) + dst->item(BIT_HIGH_US, (packet >> i) & 1 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + // The sync follows the bits, as the sensors send it: its mark ends the last space, its gap separates repeats + dst->item(SYNC_HIGH_US, SYNC_LOW_US); +} + +optional NexusProtocol::decode(RemoteReceiveData src) { + // A buffer may start with the previous frame's sync when the receiver did not split on it + src.expect_item(SYNC_HIGH_US, SYNC_LOW_US); + if (src.size() < NBITS * 2) + return {}; + + uint64_t packet = 0; + for (uint8_t i = 0; i < NBITS; i++) { + packet <<= 1; + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + packet |= 1; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return {}; + } + } + if (((packet >> 8) & 0xF) != 0xF) + return {}; + ESP_LOGV(TAG, "Nexus packet: 0x%09" PRIX64, packet); + + return NexusData{ + // The 12 bit field sits at bits 12-23; shift it to the top of an int16_t and back to sign extend. + .temperature = (static_cast((packet >> 8) & 0xFFF0) >> 4) / 10.0f, + .address = static_cast(packet >> 28), + .battery_level = static_cast((packet >> 27) & 1), + .force_update = static_cast((packet >> 26) & 1), + .channel = static_cast(((packet >> 24) & 0b11) + 1), + .humidity = static_cast(packet), + }; +} + +void NexusProtocol::dump(const NexusData &data) { + ESP_LOGI(TAG, + "Received Nexus: address=0x%02X, channel=%u, temperature=%.1f, humidity=%u, battery_level=%d, " + "force_update=%d", + data.address, data.channel, data.temperature, data.humidity, data.battery_level, data.force_update); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexus_protocol.h b/esphome/components/remote_base/nexus_protocol.h new file mode 100644 index 00000000000..de3f55777de --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.h @@ -0,0 +1,30 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct NexusData { + float temperature; // °C, sent as 12 bit signed tenths + uint8_t address; + bool battery_level; + bool force_update; + uint8_t channel; // sent 0-based, shown 1-based + uint8_t humidity; + + bool operator==(const NexusData &rhs) const { + // The binary sensor matches the transmitter, not the reading. + return channel == rhs.channel && address == rhs.address; + } +}; + +class NexusProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const NexusData &data); + optional decode(RemoteReceiveData src); + void dump(const NexusData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Nexus) + +} // namespace esphome::remote_base diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 90734b72f28..572505b01a7 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq"): + for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq", "nexus"): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/nexus_test.cpp b/tests/components/remote_base/nexus_test.cpp new file mode 100644 index 00000000000..b29bd00943a --- /dev/null +++ b/tests/components/remote_base/nexus_test.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include "esphome/components/remote_base/nexus_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +RawTimings encode(const NexusData &data) { + RemoteTransmitData tx; + NexusProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return NexusProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(float temperature, bool drop_sync) { + const NexusData sent{.temperature = temperature, + .address = 0x85, + .battery_level = true, + .force_update = false, + .channel = 2, + .humidity = 69}; + RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), 2u + 36u * 2u); + if (drop_sync) { + timings.resize(timings.size() - 2); + } else { + // The receiver kept the previous frame's sync in front of this one + timings.insert(timings.begin(), timings.end() - 2, timings.end()); + } + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << temperature; + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, temperature); + EXPECT_EQ(decoded->address, 0x85); + EXPECT_TRUE(decoded->battery_level); + EXPECT_FALSE(decoded->force_update); + EXPECT_EQ(decoded->channel, 2); + EXPECT_EQ(decoded->humidity, 69); +} + +} // namespace + +TEST(NexusProtocolTest, RoundTripsWithLeadingSync) { + expect_round_trip(13.8f, false); + expect_round_trip(-12.3f, false); + expect_round_trip(-204.8f, false); + expect_round_trip(204.7f, false); +} + +TEST(NexusProtocolTest, RoundTripsWithoutTrailingSync) { + expect_round_trip(0.0f, true); + expect_round_trip(-0.1f, true); +} + +TEST(NexusProtocolTest, ClampsOutOfRangeTemperature) { + auto decoded = decode(encode(NexusData{.temperature = 300.0f, .channel = 1})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, 204.7f); + decoded = decode(encode(NexusData{.temperature = NAN, .channel = 1})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, 0.0f); +} + +TEST(NexusProtocolTest, RejectsWrongConstantBits) { + RawTimings timings = encode(NexusData{.temperature = 20.0f, .channel = 1}); + // Constant bits are data bits 24-27; each bit is a mark and a space, the sync comes last. + timings[24 * 2 + 1] = -980; + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 55f489505cb..9eb21f30fc8 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -103,6 +103,11 @@ on_nexa: - logger.log: format: "on_nexa: %lu %u %u %u %u" args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"] +on_nexus: + then: + - logger.log: + format: "on_nexus: %u %u %.1f %u %d %d" + args: ["x.channel", "x.address", "x.temperature", "x.humidity", "x.battery_level", "x.force_update"] on_onkyori: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 956cfdc3233..ad5eb72b040 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -19,3 +19,8 @@ binary_sensor: name: Hob2Hood Light On hob2hood: command: light_on + - platform: remote_receiver + name: Nexus Sensor + nexus: + channel: 1 + address: 0x42 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 261f78b2623..c81a8687d6b 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -52,6 +52,16 @@ button: remote_transmitter.transmit_nec: address: 0x4242 command: 0x8484 + - platform: template + name: Nexus + on_press: + remote_transmitter.transmit_nexus: + channel: 1 + address: 0x42 + temperature: -12.3 + humidity: 69 + battery_level: false + force_update: true - platform: template name: LG on_press: From 8191cd18d27678705881522313153bec19a8fe9e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:02:07 +0000 Subject: [PATCH 345/435] [ci] Refresh integration test durations (#20578) --- .../integration_test_durations.json | 345 +++++++++--------- 1 file changed, 178 insertions(+), 167 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 060799862f7..75bf0b48427 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,169 +1,180 @@ { - "tests/integration/test_action_concurrent_reentry.py": 12.49, - "tests/integration/test_addressable_light_transition.py": 17.27, - "tests/integration/test_alarm_control_panel_state_transitions.py": 15.05, - "tests/integration/test_api_action_metadata.py": 10.78, - "tests/integration/test_api_action_responses.py": 17.68, - "tests/integration/test_api_action_timeout.py": 17.48, - "tests/integration/test_api_conditional_memory.py": 11.82, - "tests/integration/test_api_custom_services.py": 8.17, - "tests/integration/test_api_decode_wire_types.py": 17.82, - "tests/integration/test_api_empty_message_roundtrip.py": 12.6, - "tests/integration/test_api_encode_boundaries.py": 14.83, - "tests/integration/test_api_get_time_response_timezone.py": 12.93, - "tests/integration/test_api_homeassistant.py": 19.79, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 12.45, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 7.67, - "tests/integration/test_api_list_entities_backpressure.py": 12.69, - "tests/integration/test_api_message_size_batching.py": 15.39, - "tests/integration/test_api_reboot_timeout.py": 12.25, - "tests/integration/test_api_string_lambda.py": 13.84, - "tests/integration/test_api_vv_logging.py": 12.98, - "tests/integration/test_api_zero_psk_provisioning.py": 19.36, - "tests/integration/test_areas_and_devices.py": 8.09, - "tests/integration/test_automation_wait_actions.py": 14.26, - "tests/integration/test_automations.py": 20.27, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.02, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.18, - "tests/integration/test_binary_sensor_invalidate_state.py": 13.04, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 10.44, - "tests/integration/test_build_info.py": 12.48, - "tests/integration/test_camera_mock.py": 12.53, - "tests/integration/test_climate_control_action.py": 9.7, - "tests/integration/test_climate_custom_modes.py": 14.68, - "tests/integration/test_continuation_actions.py": 12.16, - "tests/integration/test_cover_control_action.py": 11.76, - "tests/integration/test_crc8_helper.py": 8.21, - "tests/integration/test_device_id_in_state.py": 26.48, - "tests/integration/test_duplicate_entities.py": 14.89, - "tests/integration/test_entity_icon.py": 15.75, - "tests/integration/test_entity_missing_state.py": 9.8, - "tests/integration/test_fan_turn_on_action.py": 12.96, - "tests/integration/test_fnv1_hash_object_id.py": 2.55, - "tests/integration/test_fnv1a_hash.py": 9.37, - "tests/integration/test_gpio_expander_cache.py": 12.31, - "tests/integration/test_host_logger_thread_safety.py": 7.8, - "tests/integration/test_host_mode_basic.py": 11.59, - "tests/integration/test_host_mode_batch_delay.py": 8.18, - "tests/integration/test_host_mode_climate_basic_state.py": 12.53, - "tests/integration/test_host_mode_climate_control.py": 14.3, - "tests/integration/test_host_mode_empty_string_options.py": 13.74, - "tests/integration/test_host_mode_entity_fields.py": 15.51, - "tests/integration/test_host_mode_fan_preset.py": 9.63, - "tests/integration/test_host_mode_many_entities.py": 16.22, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 13.26, - "tests/integration/test_host_mode_noise_encryption.py": 29.86, - "tests/integration/test_host_mode_reconnect.py": 11.7, - "tests/integration/test_host_mode_sensor.py": 11.74, - "tests/integration/test_host_ota.py": 129.75, - "tests/integration/test_host_preferences.py": 8.46, - "tests/integration/test_host_preferences_suspend_resume.py": 14.3, - "tests/integration/test_improv_serial_uart.py": 15.6, - "tests/integration/test_large_message_batching.py": 13.6, - "tests/integration/test_legacy_area.py": 11.44, - "tests/integration/test_legacy_climate_compat.py": 12.99, - "tests/integration/test_legacy_fan_compat.py": 8.75, - "tests/integration/test_light_automations.py": 15.38, - "tests/integration/test_light_binary_effect_off_phase.py": 23.85, - "tests/integration/test_light_calls.py": 16.45, - "tests/integration/test_light_constant_brightness.py": 16.87, - "tests/integration/test_light_control_action.py": 16.25, - "tests/integration/test_light_dim_relative_action.py": 14.03, - "tests/integration/test_light_effect_zero_brightness.py": 16.53, - "tests/integration/test_light_initial_state.py": 15.16, - "tests/integration/test_light_repeat_effect.py": 22.35, - "tests/integration/test_light_restore_state.py": 11.68, - "tests/integration/test_light_resume_effect.py": 17.07, - "tests/integration/test_light_toggle_action.py": 14.35, - "tests/integration/test_light_transition_state_publish_interval.py": 47.18, - "tests/integration/test_lock_automations.py": 12.65, - "tests/integration/test_logger_buffered_recursion_guard.py": 12.2, - "tests/integration/test_loop_disable_enable.py": 11.31, - "tests/integration/test_loop_interval_decoupling.py": 13.71, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 11.78, - "tests/integration/test_lvgl_headless_render.py": 49.4, - "tests/integration/test_micros_to_millis.py": 6.71, - "tests/integration/test_multi_click_trigger.py": 10.38, - "tests/integration/test_multi_device_preferences.py": 16.06, - "tests/integration/test_noise_encryption_key_protection.py": 13.43, - "tests/integration/test_object_id_api_verification.py": 14.32, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.57, - "tests/integration/test_object_id_no_friendly_name.py": 15.59, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 24.62, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 29.88, - "tests/integration/test_online_image_bmp.py": 23.67, - "tests/integration/test_oversized_payloads.py": 51.21, - "tests/integration/test_preference_key_stability.py": 14.85, - "tests/integration/test_runtime_stats.py": 11.2, - "tests/integration/test_safe_mode_loop_runs.py": 8.22, - "tests/integration/test_scheduler_blocking_warning.py": 28.18, - "tests/integration/test_scheduler_bulk_cleanup.py": 12.49, - "tests/integration/test_scheduler_defer_cancel.py": 9.95, - "tests/integration/test_scheduler_defer_cancel_regular.py": 13.32, - "tests/integration/test_scheduler_defer_fifo_simple.py": 13.18, - "tests/integration/test_scheduler_defer_stress.py": 14.29, - "tests/integration/test_scheduler_heap_stress.py": 4.73, - "tests/integration/test_scheduler_internal_id_no_collision.py": 14.05, - "tests/integration/test_scheduler_interval_reschedule.py": 9.0, - "tests/integration/test_scheduler_interval_zero_coerced.py": 3.27, - "tests/integration/test_scheduler_null_name.py": 12.94, - "tests/integration/test_scheduler_numeric_id_test.py": 11.83, - "tests/integration/test_scheduler_pool.py": 9.66, - "tests/integration/test_scheduler_rapid_cancellation.py": 3.95, - "tests/integration/test_scheduler_recursive_timeout.py": 12.91, - "tests/integration/test_scheduler_removed_item_race.py": 11.9, - "tests/integration/test_scheduler_self_keyed.py": 13.42, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 8.01, - "tests/integration/test_scheduler_string_test.py": 10.2, - "tests/integration/test_script_array_params.py": 12.99, - "tests/integration/test_script_delay_params.py": 14.73, - "tests/integration/test_script_queued.py": 16.62, - "tests/integration/test_script_queued_idle_loop.py": 11.13, - "tests/integration/test_script_wait_on_boot.py": 13.31, - "tests/integration/test_sdl_headless_screenshot.py": 14.76, - "tests/integration/test_select_stringref_trigger.py": 10.98, - "tests/integration/test_sensor_filters_delta.py": 12.63, - "tests/integration/test_sensor_filters_ring_buffer.py": 11.13, - "tests/integration/test_sensor_filters_sliding_window.py": 40.2, - "tests/integration/test_sensor_filters_value_list.py": 10.25, - "tests/integration/test_sensor_raw_state.py": 26.04, - "tests/integration/test_sensor_timeout_filter.py": 13.81, - "tests/integration/test_set_internal_at_boot.py": 14.13, - "tests/integration/test_snapshot_display.py": 16.36, - "tests/integration/test_socket_ipv4_resolve.py": 11.75, - "tests/integration/test_socket_tcp_client_link.py": 9.79, - "tests/integration/test_socket_wake_gate_tcp.py": 15.29, - "tests/integration/test_status_flags.py": 20.87, - "tests/integration/test_strftime_to.py": 10.06, - "tests/integration/test_syslog.py": 18.51, - "tests/integration/test_tcp_uart_bridge.py": 14.71, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 8.4, - "tests/integration/test_template_climate_basic.py": 14.22, - "tests/integration/test_template_climate_custom_modes.py": 11.61, - "tests/integration/test_template_climate_nonoptimistic.py": 13.47, - "tests/integration/test_template_climate_on_control_ordering.py": 13.99, - "tests/integration/test_template_climate_publish_all_fields.py": 10.98, - "tests/integration/test_template_climate_sensor_push.py": 14.86, - "tests/integration/test_template_climate_set_actions.py": 11.79, - "tests/integration/test_template_climate_two_point_temperature.py": 14.92, - "tests/integration/test_template_text_save.py": 13.35, - "tests/integration/test_template_time.py": 10.07, - "tests/integration/test_text_command.py": 12.11, - "tests/integration/test_text_sensor_raw_state.py": 13.96, - "tests/integration/test_uart_mock_ld2410.py": 30.03, - "tests/integration/test_uart_mock_ld2412.py": 35.57, - "tests/integration/test_uart_mock_ld2420.py": 26.6, - "tests/integration/test_uart_mock_ld2450.py": 13.03, - "tests/integration/test_uart_mock_modbus.py": 309.49, - "tests/integration/test_uart_tcp_bridge.py": 12.67, - "tests/integration/test_udp.py": 11.64, - "tests/integration/test_use_address_runtime.py": 16.79, - "tests/integration/test_valve_control_action.py": 12.76, - "tests/integration/test_varint_five_byte_device_id.py": 14.05, - "tests/integration/test_wait_until_mid_loop_timing.py": 12.48, - "tests/integration/test_wait_until_on_boot.py": 12.54, - "tests/integration/test_wait_until_ordering.py": 13.32, - "tests/integration/test_wait_until_reentrant_restart.py": 5.89, - "tests/integration/test_wake_loop_forces_phase_b.py": 13.8, - "tests/integration/test_water_heater_template.py": 24.77 + "tests/integration/test_action_concurrent_reentry.py": 14.33, + "tests/integration/test_addressable_light_transition.py": 18.53, + "tests/integration/test_alarm_control_panel_state_transitions.py": 15.5, + "tests/integration/test_api_action_metadata.py": 11.08, + "tests/integration/test_api_action_responses.py": 15.46, + "tests/integration/test_api_action_timeout.py": 16.54, + "tests/integration/test_api_conditional_memory.py": 11.84, + "tests/integration/test_api_custom_services.py": 13.37, + "tests/integration/test_api_decode_wire_types.py": 17.49, + "tests/integration/test_api_empty_message_roundtrip.py": 12.35, + "tests/integration/test_api_encode_boundaries.py": 10.93, + "tests/integration/test_api_get_time_response_timezone.py": 13.5, + "tests/integration/test_api_homeassistant.py": 18.7, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.96, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.76, + "tests/integration/test_api_homeassistant_entities.py": 15.12, + "tests/integration/test_api_list_entities_backpressure.py": 14.2, + "tests/integration/test_api_message_size_batching.py": 13.72, + "tests/integration/test_api_outgoing_connection.py": 28.66, + "tests/integration/test_api_reboot_timeout.py": 9.01, + "tests/integration/test_api_string_lambda.py": 12.16, + "tests/integration/test_api_vv_logging.py": 10.14, + "tests/integration/test_api_zero_psk_provisioning.py": 18.26, + "tests/integration/test_areas_and_devices.py": 10.34, + "tests/integration/test_automation_wait_actions.py": 9.93, + "tests/integration/test_automations.py": 20.02, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.34, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.03, + "tests/integration/test_binary_sensor_invalidate_state.py": 8.43, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 9.39, + "tests/integration/test_branch_resume_async.py": 16.64, + "tests/integration/test_build_info.py": 12.41, + "tests/integration/test_camera_mock.py": 13.29, + "tests/integration/test_climate_control_action.py": 15.18, + "tests/integration/test_climate_custom_modes.py": 14.12, + "tests/integration/test_continuation_actions.py": 14.06, + "tests/integration/test_cover_control_action.py": 9.59, + "tests/integration/test_crc8_helper.py": 11.51, + "tests/integration/test_device_id_in_state.py": 18.41, + "tests/integration/test_duplicate_entities.py": 14.95, + "tests/integration/test_entity_icon.py": 15.57, + "tests/integration/test_entity_missing_state.py": 13.91, + "tests/integration/test_fan_turn_on_action.py": 13.85, + "tests/integration/test_fnv1_hash_object_id.py": 10.12, + "tests/integration/test_fnv1a_hash.py": 9.95, + "tests/integration/test_gpio_expander_cache.py": 4.43, + "tests/integration/test_host_logger_thread_safety.py": 12.54, + "tests/integration/test_host_mode_basic.py": 12.66, + "tests/integration/test_host_mode_batch_delay.py": 13.34, + "tests/integration/test_host_mode_climate_basic_state.py": 15.19, + "tests/integration/test_host_mode_climate_control.py": 15.35, + "tests/integration/test_host_mode_empty_string_options.py": 13.04, + "tests/integration/test_host_mode_entity_fields.py": 10.42, + "tests/integration/test_host_mode_fan_preset.py": 11.7, + "tests/integration/test_host_mode_many_entities.py": 16.79, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 15.62, + "tests/integration/test_host_mode_noise_encryption.py": 21.06, + "tests/integration/test_host_mode_reconnect.py": 11.29, + "tests/integration/test_host_mode_sensor.py": 12.25, + "tests/integration/test_host_ota.py": 109.97, + "tests/integration/test_host_preferences.py": 12.92, + "tests/integration/test_host_preferences_suspend_resume.py": 12.3, + "tests/integration/test_host_write_after_peer_close.py": 14.42, + "tests/integration/test_improv_serial_uart.py": 16.29, + "tests/integration/test_ir_rf_transmit_complete.py": 10.73, + "tests/integration/test_large_message_batching.py": 13.94, + "tests/integration/test_legacy_area.py": 13.34, + "tests/integration/test_legacy_climate_compat.py": 13.19, + "tests/integration/test_legacy_fan_compat.py": 9.31, + "tests/integration/test_light_automations.py": 14.39, + "tests/integration/test_light_binary_effect_off_phase.py": 14.75, + "tests/integration/test_light_calls.py": 13.85, + "tests/integration/test_light_constant_brightness.py": 12.22, + "tests/integration/test_light_control_action.py": 15.46, + "tests/integration/test_light_dim_relative_action.py": 15.3, + "tests/integration/test_light_effect_zero_brightness.py": 16.75, + "tests/integration/test_light_initial_state.py": 9.8, + "tests/integration/test_light_repeat_effect.py": 22.27, + "tests/integration/test_light_restore_state.py": 15.67, + "tests/integration/test_light_resume_effect.py": 17.79, + "tests/integration/test_light_toggle_action.py": 15.22, + "tests/integration/test_light_transition_state_publish_interval.py": 44.96, + "tests/integration/test_lock_automations.py": 12.55, + "tests/integration/test_logger_buffered_recursion_guard.py": 11.78, + "tests/integration/test_loop_disable_enable.py": 13.19, + "tests/integration/test_loop_interval_decoupling.py": 15.14, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 14.91, + "tests/integration/test_lvgl_color_picker_render.py": 91.0, + "tests/integration/test_lvgl_headless_render.py": 46.26, + "tests/integration/test_micros_to_millis.py": 9.14, + "tests/integration/test_multi_click_trigger.py": 13.62, + "tests/integration/test_multi_device_preferences.py": 15.18, + "tests/integration/test_noise_encryption_key_protection.py": 21.14, + "tests/integration/test_object_id_api_verification.py": 9.54, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.78, + "tests/integration/test_object_id_no_friendly_name.py": 24.2, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 5.07, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 26.9, + "tests/integration/test_online_image_bmp.py": 24.53, + "tests/integration/test_oversized_payloads.py": 43.03, + "tests/integration/test_preference_key_stability.py": 14.22, + "tests/integration/test_runtime_stats.py": 15.85, + "tests/integration/test_safe_mode_loop_runs.py": 8.89, + "tests/integration/test_scheduler_background_wake.py": 17.82, + "tests/integration/test_scheduler_blocking_warning.py": 30.27, + "tests/integration/test_scheduler_bulk_cleanup.py": 8.78, + "tests/integration/test_scheduler_defer_cancel.py": 12.9, + "tests/integration/test_scheduler_defer_cancel_regular.py": 12.79, + "tests/integration/test_scheduler_defer_fifo_simple.py": 13.14, + "tests/integration/test_scheduler_defer_stress.py": 13.2, + "tests/integration/test_scheduler_heap_stress.py": 9.15, + "tests/integration/test_scheduler_internal_id_no_collision.py": 14.81, + "tests/integration/test_scheduler_interval_reschedule.py": 13.07, + "tests/integration/test_scheduler_interval_zero_coerced.py": 12.32, + "tests/integration/test_scheduler_null_name.py": 3.81, + "tests/integration/test_scheduler_numeric_id_test.py": 9.77, + "tests/integration/test_scheduler_pool.py": 10.16, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.7, + "tests/integration/test_scheduler_recursive_timeout.py": 13.72, + "tests/integration/test_scheduler_removed_item_race.py": 12.73, + "tests/integration/test_scheduler_self_keyed.py": 13.03, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 11.88, + "tests/integration/test_scheduler_string_test.py": 14.1, + "tests/integration/test_script_array_params.py": 11.14, + "tests/integration/test_script_delay_params.py": 13.84, + "tests/integration/test_script_queued.py": 15.45, + "tests/integration/test_script_queued_idle_loop.py": 13.15, + "tests/integration/test_script_wait_on_boot.py": 12.64, + "tests/integration/test_sdl_headless_screenshot.py": 15.19, + "tests/integration/test_select_stringref_trigger.py": 13.51, + "tests/integration/test_sensor_filters_delta.py": 13.6, + "tests/integration/test_sensor_filters_ring_buffer.py": 9.22, + "tests/integration/test_sensor_filters_sliding_window.py": 40.87, + "tests/integration/test_sensor_filters_value_list.py": 15.43, + "tests/integration/test_sensor_raw_state.py": 25.12, + "tests/integration/test_sensor_timeout_filter.py": 9.64, + "tests/integration/test_sensor_timeout_throttle_filter.py": 14.63, + "tests/integration/test_set_internal_at_boot.py": 13.18, + "tests/integration/test_snapshot_display.py": 16.01, + "tests/integration/test_socket_ipv4_resolve.py": 11.3, + "tests/integration/test_socket_tcp_client_link.py": 13.88, + "tests/integration/test_socket_wake_gate_tcp.py": 14.28, + "tests/integration/test_status_flags.py": 20.55, + "tests/integration/test_status_momentary.py": 12.63, + "tests/integration/test_strftime_to.py": 9.47, + "tests/integration/test_syslog.py": 17.73, + "tests/integration/test_tcp_uart_bridge.py": 16.27, + "tests/integration/test_tcp_uart_server.py": 13.64, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.42, + "tests/integration/test_template_climate_basic.py": 14.42, + "tests/integration/test_template_climate_custom_modes.py": 14.3, + "tests/integration/test_template_climate_nonoptimistic.py": 10.11, + "tests/integration/test_template_climate_on_control_ordering.py": 4.57, + "tests/integration/test_template_climate_publish_all_fields.py": 12.56, + "tests/integration/test_template_climate_sensor_push.py": 14.89, + "tests/integration/test_template_climate_set_actions.py": 14.12, + "tests/integration/test_template_climate_two_point_temperature.py": 9.08, + "tests/integration/test_template_text_save.py": 13.96, + "tests/integration/test_template_time.py": 12.73, + "tests/integration/test_text_command.py": 13.09, + "tests/integration/test_text_sensor_raw_state.py": 14.3, + "tests/integration/test_uart_mock_ld2410.py": 33.11, + "tests/integration/test_uart_mock_ld2412.py": 61.26, + "tests/integration/test_uart_mock_ld2420.py": 23.39, + "tests/integration/test_uart_mock_ld2450.py": 11.79, + "tests/integration/test_uart_mock_modbus.py": 323.58, + "tests/integration/test_uart_mock_ufm01.py": 71.03, + "tests/integration/test_uart_tcp_bridge.py": 13.3, + "tests/integration/test_udp.py": 7.04, + "tests/integration/test_use_address_runtime.py": 12.4, + "tests/integration/test_valve_control_action.py": 9.25, + "tests/integration/test_varint_five_byte_device_id.py": 9.34, + "tests/integration/test_wait_until_mid_loop_timing.py": 13.21, + "tests/integration/test_wait_until_on_boot.py": 12.66, + "tests/integration/test_wait_until_ordering.py": 9.06, + "tests/integration/test_wait_until_reentrant_restart.py": 10.83, + "tests/integration/test_wake_loop_forces_phase_b.py": 14.23, + "tests/integration/test_water_heater_template.py": 21.96 } From 36082b23c2b4c4000f82a29e76ca086fc374eacb Mon Sep 17 00:00:00 2001 From: RobertH1993 Date: Mon, 12 Oct 2026 08:11:15 +0200 Subject: [PATCH 346/435] [remote_base] Add the Somfy RTS transmit protocol (#16502) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> 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/remote_base/__init__.py | 40 +++++++++++ .../remote_base/somfy_rts_protocol.cpp | 69 +++++++++++++++++++ .../remote_base/somfy_rts_protocol.h | 21 ++++++ esphome/core/defines.h | 1 + .../remote_transmitter/common-buttons.yaml | 21 ++++++ 5 files changed, 152 insertions(+) create mode 100644 esphome/components/remote_base/somfy_rts_protocol.cpp create mode 100644 esphome/components/remote_base/somfy_rts_protocol.h diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index af043c0ea82..fea3726f765 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -27,6 +27,7 @@ from esphome.const import ( CONF_ID, CONF_INDEX, CONF_INVERTED, + CONF_KEY, CONF_LEVEL, CONF_MAGNITUDE, CONF_NBITS, @@ -63,6 +64,9 @@ CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" CONF_SUFFIX = "suffix" CONF_SUFFIX_BITS = "suffix_bits" +CONF_CONTROL = "control" +CONF_CONTROL_IN_KEY = "control_in_key" +CONF_ROLLING_CODE = "rolling_code" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -2114,6 +2118,42 @@ register_protocol_action( ) +# SomfyRts +SOMFY_RTS_SCHEMA = cv.Schema( + { + cv.Required(CONF_KEY): cv.hex_uint8_t, + cv.Required(CONF_CONTROL): cv.All(cv.hex_uint8_t, cv.Range(max=0x0F)), + cv.Required(CONF_ROLLING_CODE): cv.hex_uint16_t, + cv.Required(CONF_ADDRESS): cv.All(cv.hex_uint32_t, cv.Range(max=0xFFFFFF)), + cv.Optional(CONF_CONTROL_IN_KEY, default=False): cv.boolean, + } +) + + +def _validate_somfy_rts(config: ConfigType) -> ConfigType: + key = config[CONF_KEY] + if config[CONF_CONTROL_IN_KEY] is True and isinstance(key, int) and key & 0x0F: + raise cv.Invalid( + f"With {CONF_CONTROL_IN_KEY} the low nibble of {CONF_KEY} carries {CONF_CONTROL} " + "and must be 0", + path=[CONF_KEY], + ) + return config + + +# Transmit only: the protocol has no decoder yet, so no binary sensor, trigger or dumper. +register_protocol_action( + "somfy_rts", + "SomfyRts", + SOMFY_RTS_SCHEMA, + (CONF_KEY, "key", cg.uint8), + (CONF_CONTROL, "control", cg.uint8), + (CONF_ROLLING_CODE, "rolling_code", cg.uint16), + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CONTROL_IN_KEY, "control_in_key", cg.bool_), + validate=_validate_somfy_rts, +) + # Nexa NexaData, NexaBinarySensor, NexaTrigger, _, NexaDumper = declare_protocol("Nexa") NEXA_SCHEMA = cv.Schema( diff --git a/esphome/components/remote_base/somfy_rts_protocol.cpp b/esphome/components/remote_base/somfy_rts_protocol.cpp new file mode 100644 index 00000000000..21c1f1d4577 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.cpp @@ -0,0 +1,69 @@ +#include "somfy_rts_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +// Timings from https://pushstack.wordpress.com/somfy-rts-protocol/ +static constexpr uint8_t FRAME_SIZE_IN_BYTES = 7; +static constexpr uint16_t HALF_BIT_US = 604; + +static constexpr uint16_t WAKEUP_HIGH_US = 9415; +static constexpr uint32_t WAKEUP_LOW_US = 89565; + +static constexpr uint16_t HW_SYNC_US = 2560; + +static constexpr uint16_t SW_SYNC_HIGH_US = 4550; +static constexpr uint16_t SW_SYNC_LOW_US = HALF_BIT_US; + +// Wakeup, two hardware syncs and a software sync (8), two entries per bit (112) +static constexpr uint32_t FRAME_ENTRIES = 8 + FRAME_SIZE_IN_BYTES * 8 * 2; + +void SomfyRtsProtocol::encode(RemoteTransmitData *dst, const SomfyRtsData &data) { + dst->reserve(FRAME_ENTRIES); + dst->set_carrier_frequency(0); + dst->item(WAKEUP_HIGH_US, WAKEUP_LOW_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(SW_SYNC_HIGH_US, SW_SYNC_LOW_US); + + uint8_t frame[FRAME_SIZE_IN_BYTES]; + // Some non standard remotes put the control nibble in the key byte and send 0xF in the control nibble + if (data.control_in_key) { + frame[0] = (data.key & 0xF0) | (data.control & 0x0F); + frame[1] = 0xF0; + } else { + frame[0] = data.key; + frame[1] = (data.control & 0x0F) << 4; + } + frame[2] = data.rolling_code >> 8; + frame[3] = data.rolling_code; + frame[4] = data.address >> 16; + frame[5] = data.address >> 8; + frame[6] = data.address; + + uint8_t crc = 0; + for (uint8_t data_byte : frame) { + crc ^= (data_byte >> 4) ^ (data_byte & 0xF); + } + frame[1] |= (crc & 0x0F); + + for (uint8_t i = 1; i < FRAME_SIZE_IN_BYTES; i++) { + frame[i] ^= frame[i - 1]; + } + + // Manchester, MSB first: a 1 is space then mark, a 0 is mark then space. Frames repeat with + // the 30 ms gap through the action's repeat option, not inside the frame. + for (uint8_t data_byte : frame) { + for (int8_t y = 7; y >= 0; y--) { + if (data_byte & (1 << y)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/somfy_rts_protocol.h b/esphome/components/remote_base/somfy_rts_protocol.h new file mode 100644 index 00000000000..df6ddd48317 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.h @@ -0,0 +1,21 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct SomfyRtsData { + uint32_t address; + uint16_t rolling_code; + uint8_t key; + uint8_t control; + bool control_in_key; +}; + +// Transmit only: there is no decoder yet, so no binary sensor, trigger or dumper is registered. +class SomfyRtsProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const SomfyRtsData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7ff80cbbd15..27cfa08c437 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -187,6 +187,7 @@ #define USE_REMOTE_PROTOCOL_ROOMBA #define USE_REMOTE_PROTOCOL_SAMSUNG #define USE_REMOTE_PROTOCOL_SAMSUNG36 +#define USE_REMOTE_PROTOCOL_SOMFY_RTS #define USE_REMOTE_PROTOCOL_SONY #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c81a8687d6b..97ce6a37ed4 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -419,3 +419,24 @@ button: on_press: remote_transmitter.transmit_roomba: data: 0x88 + - platform: template + name: SomfyRTS + on_press: + - remote_transmitter.transmit_somfy_rts: + address: 0x630139 + control: 0x08 + key: 0xA0 + rolling_code: 1234 + control_in_key: false + - platform: template + name: SomfyRTS Control In Key + on_press: + - remote_transmitter.transmit_somfy_rts: + address: 0x630139 + control: 0x02 + key: 0xA0 + rolling_code: !lambda return 1235; + control_in_key: true + repeat: + times: 3 + wait_time: 30ms From eb8a793f6ef3f8a6aa9ac33956bca5eaf684cda6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 20:22:11 -1000 Subject: [PATCH 347/435] [core] Add a name truncation helper and keep the MAC suffix in the nRF52 BLE name (#19768) --- esphome/components/esp32_ble/ble.cpp | 15 +------ esphome/components/wifi/wifi_component.cpp | 23 +++-------- .../zephyr_ble_server/ble_server.cpp | 25 +++++++---- esphome/core/helpers.cpp | 20 +++++++++ esphome/core/helpers.h | 10 +++++ tests/components/core/test_helpers.cpp | 41 +++++++++++++++++++ 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index b5051c63e17..11290a95f41 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -350,19 +350,8 @@ bool ESP32BLE::ble_setup_() { } } else { const auto &app_name = App.get_name(); - size_t name_len = app_name.length(); - if (name_len > 20) { - if (App.is_name_add_mac_suffix_enabled()) { - // Keep first 13 chars and last 7 chars (MAC suffix), remove middle - memcpy(name_buffer, app_name.c_str(), 13); - memcpy(name_buffer + 13, app_name.c_str() + name_len - 7, 7); - } else { - memcpy(name_buffer, app_name.c_str(), 20); - } - name_buffer[20] = '\0'; - } else { - memcpy(name_buffer, app_name.c_str(), name_len + 1); // Include null terminator - } + truncate_name_to(name_buffer, sizeof(name_buffer), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); device_name = name_buffer; } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9a9ca2cbb2a..f973d146e54 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1048,30 +1048,17 @@ void WiFiComponent::setup_ap_config_() { return; if (this->ap_.ssid_.empty()) { - // Build AP SSID from app name without heap allocation - // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7 + // Build AP SSID from app name without heap allocation; WiFi SSID max is 32 bytes static constexpr size_t AP_SSID_MAX_LEN = 32; - static constexpr size_t AP_SSID_PREFIX_LEN = 25; - static constexpr size_t AP_SSID_SUFFIX_LEN = 7; const auto &app_name = App.get_name(); - const char *name_ptr = app_name.c_str(); - size_t name_len = app_name.length(); - - if (name_len <= AP_SSID_MAX_LEN) { + if (app_name.length() <= AP_SSID_MAX_LEN) { // Name fits, use directly - this->ap_.set_ssid(name_ptr); + this->ap_.set_ssid(app_name.c_str()); } else { - // Name too long, need to truncate into stack buffer char ssid_buf[AP_SSID_MAX_LEN + 1]; - if (App.is_name_add_mac_suffix_enabled()) { - // Keep first 25 chars and last 7 chars (MAC suffix), remove middle - memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN); - memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN); - } else { - memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN); - } - ssid_buf[AP_SSID_MAX_LEN] = '\0'; + truncate_name_to(ssid_buf, sizeof(ssid_buf), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); this->ap_.set_ssid(ssid_buf); } } diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index dd6ecd3b20d..c890fba3d72 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -1,6 +1,8 @@ #ifdef USE_ZEPHYR #include "ble_server.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include @@ -13,13 +15,8 @@ static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-globa BLEServer *global_ble_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#define DEVICE_NAME CONFIG_BT_DEVICE_NAME -#define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1) - -static const bt_data AD[] = { - BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), - BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN), -}; +// What the advertisement has left for the name: the flags take 3 bytes and the name header 2 +static constexpr size_t MAX_ADV_NAME_LEN = BT_GAP_ADV_MAX_ADV_DATA_LEN - 3 - 2; static const bt_data SD[] = { #ifdef USE_OTA @@ -36,7 +33,19 @@ static void advertise(k_work *work) { ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc); } - rc = bt_le_adv_start(ADV_PARAM, AD, ARRAY_SIZE(AD), SD, ARRAY_SIZE(SD)); + // Zephyr would cut the end of a long name, which is where the MAC suffix is + char name[MAX_ADV_NAME_LEN + 1]; + const auto &app_name = App.get_name(); + const size_t name_len = + truncate_name_to(name, sizeof(name), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); + const uint8_t name_type = name_len < app_name.length() ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE; + const bt_data ad[] = { + BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), + BT_DATA(name_type, name, static_cast(name_len)), + }; + + rc = bt_le_adv_start(ADV_PARAM, ad, ARRAY_SIZE(ad), SD, ARRAY_SIZE(SD)); if (rc) { ESP_LOGE(TAG, "Advertising failed to start (rc %d)", rc); return; diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1b332515f66..906f9fb12ab 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -293,6 +293,26 @@ size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *na return total_len; } +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len) { + if (buffer_size == 0) { + return 0; + } + const size_t max_len = buffer_size - 1; + if (name_len <= max_len) { + memcpy(buffer, name, name_len); + buffer[name_len] = '\0'; + return name_len; + } + if (keep_suffix_len > max_len) { + keep_suffix_len = max_len; + } + const size_t head_len = max_len - keep_suffix_len; + memcpy(buffer, name, head_len); + memcpy(buffer + head_len, name + name_len - keep_suffix_len, keep_suffix_len); + buffer[max_len] = '\0'; + return max_len; +} + // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 50be278bb36..bcb03b010f6 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1256,6 +1256,16 @@ static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len); +/// Length of the suffix name_add_mac_suffix appends: "-" plus 6 characters of the MAC address +static constexpr size_t MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN = 7; + +/// Copy a name into a buffer, cutting it when it does not fit. +/// A name that is cut keeps its last keep_suffix_len characters (for example a MAC suffix) and loses +/// the middle instead; pass 0 to cut the end. +/// @param buffer Output buffer; holds at most buffer_size - 1 characters plus the null terminator +/// @return Length written (excluding null terminator) +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len); + ///@} /// @name Parsing & formatting diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 9fee0c723b5..ac71cf82327 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -7,6 +7,47 @@ namespace esphome::core::testing { +// --- truncate_name_to() --- + +TEST(TruncateNameTo, NameThatFitsIsCopied) { + char buffer[21]; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), "kitchen", 7, 7), 7u); + EXPECT_STREQ(buffer, "kitchen"); +} + +TEST(TruncateNameTo, NameOfExactlyTheLimitIsCopied) { + char buffer[8]; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), "kitchen", 7, 0), 7u); + EXPECT_STREQ(buffer, "kitchen"); +} + +TEST(TruncateNameTo, CutsTheEndWithoutASuffix) { + char buffer[21]; + const char *name = "a-very-long-device-name-here"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 0), 20u); + EXPECT_STREQ(buffer, "a-very-long-device-n"); +} + +TEST(TruncateNameTo, KeepsTheSuffixAndRemovesTheMiddle) { + char buffer[21]; + const char *name = "a-very-long-device-name-a1b2c3"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 7), 20u); + EXPECT_STREQ(buffer, "a-very-long-d-a1b2c3"); +} + +TEST(TruncateNameTo, SuffixLongerThanTheBufferIsCut) { + char buffer[5]; + const char *name = "name-a1b2c3"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 7), 4u); + EXPECT_STREQ(buffer, "b2c3"); +} + +TEST(TruncateNameTo, ZeroBufferSize) { + char buffer[1] = {'x'}; + EXPECT_EQ(truncate_name_to(buffer, 0, "name", 4, 0), 0u); + EXPECT_EQ(buffer[0], 'x'); +} + // --- format_hex_to() --- TEST(FormatHexTo, Basic) { From 0c645ac3a19dcde33c8667c584a65b124f8f219a Mon Sep 17 00:00:00 2001 From: Gildas Le Nadan <3ntr0p13+github@gmail.com> Date: Mon, 12 Oct 2026 08:24:50 +0200 Subject: [PATCH 348/435] [remote_base] Adding Marantz RC5 Extended protocol (#13164) Co-authored-by: Claude Sonnet 4.5 Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 42 +++++ .../components/remote_base/rc5x_protocol.cpp | 124 +++++++++++++ .../components/remote_base/rc5x_protocol.h | 31 ++++ esphome/core/defines.h | 1 + tests/components/remote_base/__init__.py | 10 +- tests/components/remote_base/rc5x_test.cpp | 164 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_receiver/esp32-common.yaml | 6 + .../remote_transmitter/common-buttons.yaml | 7 + 9 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/rc5x_protocol.cpp create mode 100644 esphome/components/remote_base/rc5x_protocol.h create mode 100644 tests/components/remote_base/rc5x_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index fea3726f765..cbe6aa8b495 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -67,6 +67,7 @@ CONF_SUFFIX_BITS = "suffix_bits" CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" +CONF_EXTENSION = "extension" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -1575,6 +1576,47 @@ register_protocol_action( ) +# RC5X (aka RC5 Marantz) +RC5XData, RC5XBinarySensor, RC5XTrigger, _, RC5XDumper = declare_protocol("RC5X") +RC5X_SCHEMA = RC5_SCHEMA.extend( + {cv.Required(CONF_EXTENSION): cv.hex_int_range(min=0, max=0x3F)} +) + + +@register_binary_sensor("rc5x", RC5XBinarySensor, RC5X_SCHEMA) +def rc5x_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + RC5XData, + ("address", config[CONF_ADDRESS]), + ("command", config[CONF_COMMAND]), + ("extension", config[CONF_EXTENSION]), + ) + ) + ) + + +@register_trigger("rc5x", RC5XTrigger, RC5XData) +def rc5x_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("rc5x", RC5XDumper) +def rc5x_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "rc5x", + "RC5X", + RC5X_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_EXTENSION, "extension", cg.uint8), +) + + # RC6 RC6Data, RC6BinarySensor, RC6Trigger, RC6Action, RC6Dumper = declare_protocol("RC6") RC6_SCHEMA = cv.Schema( diff --git a/esphome/components/remote_base/rc5x_protocol.cpp b/esphome/components/remote_base/rc5x_protocol.cpp new file mode 100644 index 00000000000..501766f0aa0 --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.cpp @@ -0,0 +1,124 @@ +#include "rc5x_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.rc5x"); + +static constexpr uint32_t HALF_BIT_US = 889; // a Manchester bit is two half-bits +// Start, field and toggle bits plus the address, then a pause of 4 half-bits, then command and extension +static constexpr uint8_t RC5_TOGGLE_BIT = 1; +static constexpr uint8_t RC5_ADDRESS_BITS = 5; +static constexpr uint8_t FIRST_PART_NBITS = 8; +static constexpr uint8_t RC5_COMMAND_BITS = 6; +static constexpr uint8_t RC5_EXT_BITS = 6; +static constexpr uint8_t NBITS = 20; +static constexpr uint8_t PAUSE_HALFBITS = 4; +// Half-bits in a frame, counting the pause; the pause starts after the first part's half-bits +static constexpr uint8_t NFRAMEHALFBITS = NBITS * 2 + PAUSE_HALFBITS; +static constexpr uint8_t PAUSE_START = FIRST_PART_NBITS * 2; + +void RC5XProtocol::encode(RemoteTransmitData *dst, const RC5XData &data) { + static bool toggle = false; + dst->set_carrier_frequency(36000); + dst->reserve(NFRAMEHALFBITS); + // Start bit, then the field bit, which is clear for commands 64 to 127 + const uint8_t command = data.command & 0x7F; // bit 6 travels in the field bit, higher bits have no place + const uint32_t start_field = command >= 64 ? 0b10 : 0b11; + const uint32_t out_data = start_field << (RC5_TOGGLE_BIT + RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(toggle) << (RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(data.address & 0x1F) << (RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(command & 0x3F) << RC5_EXT_BITS | (data.extension & 0x3F); + + for (uint8_t bit = NBITS; bit-- > 0;) { + if (bit == NBITS - 1 - FIRST_PART_NBITS) { + for (uint8_t i = 0; i < PAUSE_HALFBITS; i++) + dst->space(HALF_BIT_US); + } + if (out_data & (1UL << bit)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + toggle = !toggle; +} + +optional RC5XProtocol::decode(RemoteReceiveData src) { + // Expand the runs into half-bit levels (true = mark), as the RC5 decoder does. Each run is one + // half-bit (HALF_BIT_US) or two, except the pause, which is PAUSE_HALFBITS half-bits at the idle + // level plus up to one merged half on each side (the end of bit 12 and the start of bit 11). + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped: S1 is 1, so its + // first half sits at the idle level and merges into the pre-frame idle. + bool halfbits[NFRAMEHALFBITS + 2]; + uint8_t n = 1; + bool pause_seen = false; + for (uint32_t i = 0; n <= NFRAMEHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(HALF_BIT_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(HALF_BIT_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * HALF_BIT_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * HALF_BIT_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; + } else { + // The pause must start right after bit 12, or one half earlier when bit 12 ends idle + if (pause_seen || (n != PAUSE_START && n != PAUSE_START - 1)) + break; + const int32_t value = src.peek(i); + const bool level = value > 0; + const uint32_t halves = (static_cast(value > 0 ? value : -value) + HALF_BIT_US / 2) / HALF_BIT_US; + if (level == halfbits[1] || halves < PAUSE_HALFBITS || halves > PAUSE_HALFBITS + 2) + break; + for (uint32_t h = 0; h < halves; h++) + halfbits[n++] = level; + // It may also run one half into bit 11, but must cover the whole pause + if (n != PAUSE_START + PAUSE_HALFBITS && n != PAUSE_START + PAUSE_HALFBITS + 1) + return {}; + pause_seen = true; + } + } + + // A full frame, or one half short when the last bit ends idle and its trailing half is dropped. + // A dropped edge half is the inverse of its partner, since a Manchester bit always changes mid-bit. + if (!pause_seen || (n != NFRAMEHALFBITS && n != NFRAMEHALFBITS - 1)) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NFRAMEHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; + } + + const bool carrier = halfbits[1]; + uint32_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const uint8_t h = 2 * i + (i < FIRST_PART_NBITS ? 0 : PAUSE_HALFBITS); + const bool first = halfbits[h]; + const bool second = halfbits[h + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + // [18 field] [17 toggle] [16-12 address] [11-6 command] [5-0 extension] + const bool field_bit = bits & (1UL << (NBITS - 2)); // S2: the inverted 7th command bit + return RC5XData{ + .address = static_cast((bits >> (RC5_COMMAND_BITS + RC5_EXT_BITS)) & 0x1F), + .command = static_cast(((bits >> RC5_EXT_BITS) & 0x3F) | (field_bit ? 0 : 0x40)), + .extension = static_cast(bits & 0x3F), + }; +} + +void RC5XProtocol::dump(const RC5XData &data) { + ESP_LOGI(TAG, "Received RC5X (Marantz): address=0x%02X, command=0x%02X, extension=0x%02X", data.address, data.command, + data.extension); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5x_protocol.h b/esphome/components/remote_base/rc5x_protocol.h new file mode 100644 index 00000000000..1d1b2c003d5 --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.h @@ -0,0 +1,31 @@ +#pragma once + +#include "remote_base.h" + +/* + * Marantz RC5 extended protocol (RC5x) + * See https://arduino-irremote.github.io/Arduino-IRremote/ir__RC5__RC6_8hpp_source.html + */ + +namespace esphome::remote_base { + +struct RC5XData { + uint8_t address; + uint8_t command; + uint8_t extension; + + bool operator==(const RC5XData &rhs) const { + return address == rhs.address && command == rhs.command && extension == rhs.extension; + } +}; + +class RC5XProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const RC5XData &data); + optional decode(RemoteReceiveData src); + void dump(const RC5XData &data); +}; + +DECLARE_REMOTE_PROTOCOL(RC5X) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 27cfa08c437..0a876b4f0cc 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -182,6 +182,7 @@ #define USE_REMOTE_PROTOCOL_PRONTO #define USE_REMOTE_PROTOCOL_RAW #define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC5X #define USE_REMOTE_PROTOCOL_RC6 #define USE_REMOTE_PROTOCOL_RC_SWITCH #define USE_REMOTE_PROTOCOL_ROOMBA diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 572505b01a7..927ff022529 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,15 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq", "nexus"): + for protocol in ( + "hob2hood", + "haier", + "mirage", + "aeha", + "keeloq", + "nexus", + "rc5x", + ): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/rc5x_test.cpp b/tests/components/remote_base/rc5x_test.cpp new file mode 100644 index 00000000000..44ab365f97b --- /dev/null +++ b/tests/components/remote_base/rc5x_test.cpp @@ -0,0 +1,164 @@ +#include +#include "esphome/components/remote_base/rc5x_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +RawTimings encode(const RC5XData &data) { + RemoteTransmitData tx; + RC5XProtocol().encode(&tx, data); + return tx.get_data(); +} + +// A receiver starts on the first mark, sees runs of one level as one item, and may drop a trailing space. +RawTimings as_received(const RawTimings &timings, bool drop_tail = false) { + RawTimings merged; + for (int32_t item : timings) { + if (merged.empty() && item < 0) { + continue; + } + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + if (drop_tail && !merged.empty() && merged.back() < 0) { + merged.pop_back(); + } + return merged; +} + +RawTimings inverted(RawTimings timings) { + for (int32_t &item : timings) { + item = -item; + } + return timings; +} + +optional decode(const RawTimings &timings) { + return RC5XProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(const RC5XData &sent) { + // encode() flips the toggle on every call, so two calls cover both toggle values + const RawTimings first = encode(sent); + const RawTimings second = encode(sent); + ASSERT_NE(first, second); + for (const RawTimings &timings : {first, second}) { + for (bool drop_tail : {false, true}) { + auto decoded = decode(as_received(timings, drop_tail)); + ASSERT_TRUE(decoded.has_value()) << std::hex << "address 0x" << int(sent.address) << " command 0x" + << int(sent.command) << " extension 0x" << int(sent.extension); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, sent); + } + } +} + +} // namespace + +TEST(RC5XProtocolTest, RoundTripsBothToggleValues) { + expect_round_trip({.address = 0x10, .command = 0x00, .extension = 0x06}); + expect_round_trip({.address = 0x1F, .command = 0x0D, .extension = 0x15}); +} + +TEST(RC5XProtocolTest, RoundTripsCommandsWithBit5Set) { + expect_round_trip({.address = 0x10, .command = 0x20, .extension = 0x00}); + expect_round_trip({.address = 0x01, .command = 0x3F, .extension = 0x2A}); + expect_round_trip({.address = 0x00, .command = 0x25, .extension = 0x3F}); +} + +TEST(RC5XProtocolTest, RoundTripsExtendedCommands) { + expect_round_trip({.address = 0x10, .command = 0x40, .extension = 0x06}); + expect_round_trip({.address = 0x0A, .command = 0x55, .extension = 0x00}); + expect_round_trip({.address = 0x15, .command = 0x7F, .extension = 0x3F}); + expect_round_trip({.address = 0x10, .command = 0x60, .extension = 0x01}); +} + +TEST(RC5XProtocolTest, RoundTripsExtensionLimits) { + for (uint8_t address : {0x00, 0x1F}) { + for (uint8_t command : {0x00, 0x1F, 0x20, 0x3F, 0x40, 0x7F}) { + expect_round_trip({.address = address, .command = command, .extension = 0x00}); + expect_round_trip({.address = address, .command = command, .extension = 0x3F}); + } + } +} + +TEST(RC5XProtocolTest, DecodesCapturedMarantzAux1) { + // Captured AUX1 code (address 0x10, command 0x00, extension 0x06, toggle set) from the PR description + const RawTimings captured = {889, -889, 889, -889, 889, -889, 1778, -889, 889, -889, 889, -889, + 889, -4450, 889, -889, 889, -889, 889, -889, 889, -889, 889, -889, + 889, -889, 889, -889, 889, -889, 889, -1778, 889, -889, 1778}; + auto decoded = decode(captured); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x10); + EXPECT_EQ(decoded->command, 0x00); + EXPECT_EQ(decoded->extension, 0x06); +} + +TEST(RC5XProtocolTest, EncodeMatchesCapturedMarantzAux1) { + const RawTimings captured = {889, -889, 889, -889, 889, -889, 1778, -889, 889, -889, 889, -889, + 889, -4450, 889, -889, 889, -889, 889, -889, 889, -889, 889, -889, + 889, -889, 889, -889, 889, -889, 889, -1778, 889, -889, 1778}; + const RC5XData aux1{.address = 0x10, .command = 0x00, .extension = 0x06}; + // The capture has the toggle set; one of two consecutive frames does too + const RawTimings first = as_received(encode(aux1), true); + const RawTimings second = as_received(encode(aux1), true); + const RawTimings &toggled = first[6] == 1778 ? first : second; + ASSERT_EQ(toggled.size(), captured.size()); + for (size_t i = 0; i < captured.size(); i++) { + // The pause is 4 x 889 plus the half-bit before it, sent as 4445 against 4450 captured + EXPECT_NEAR(toggled[i], captured[i], 5) << i; + } +} + +TEST(RC5XProtocolTest, DecodesAtInvertedPolarity) { + const RC5XData sent{.address = 0x10, .command = 0x6A, .extension = 0x2B}; + // With the pin inverted, every carrier half reads as a space + const RawTimings timings = inverted(as_received(encode(sent), true)); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, sent); +} + +TEST(RC5XProtocolTest, MasksOutOfRangeFields) { + // Bit 7 of the command has no place on the wire and is dropped like the other high bits + RC5XData sent{.address = 0xF0, .command = 0xC5, .extension = 0xC6}; + auto decoded = decode(as_received(encode(sent))); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x10); + EXPECT_EQ(decoded->command, 0x45); + EXPECT_EQ(decoded->extension, 0x06); +} + +TEST(RC5XProtocolTest, RejectsPlainRc5AndBrokenFrames) { + // Without the pause the frame is too short + RawTimings timings = as_received(encode({.address = 0x10, .command = 0x00, .extension = 0x06})); + RawTimings no_pause; + for (int32_t item : timings) { + no_pause.push_back(item < -3000 ? -889 : item); + } + EXPECT_FALSE(decode(no_pause).has_value()); + // A pause far too long + for (int32_t &item : timings) { + if (item < -3000) { + item = -9000; + } + } + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 9eb21f30fc8..8d9d8506652 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -138,6 +138,11 @@ on_rc5: - logger.log: format: "on_rc5: %u %u" args: ["x.address", "x.command"] +on_rc5x: + then: + - logger.log: + format: "on_rc5x: %u %u %u" + args: ["x.address", "x.command", "x.extension"] on_rc6: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index ad5eb72b040..bc9cb59420f 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -15,6 +15,12 @@ binary_sensor: panasonic: address: 0x4004 command: 0x100BCBD + - platform: remote_receiver + name: RC5X Remote Input + rc5x: + address: 0x00 + command: 0x0D + extension: 0x03 - platform: remote_receiver name: Hob2Hood Light On hob2hood: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 97ce6a37ed4..16a754d8452 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -169,6 +169,13 @@ button: remote_transmitter.transmit_raw: code: !lambda |- return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; + - platform: template + name: RC5X Marantz + on_press: + remote_transmitter.transmit_rc5x: + address: 0x10 + command: 0x0 + extension: 0x6 - platform: template name: AEHA id: eaha_hitachi_climate_power_on From ba9061e34e2696027a8078b5c651c223568d2e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:53 +0200 Subject: [PATCH 349/435] [nextion] Pass std::string to custom-protocol automations that defer (#20571) Co-authored-by: Claude Opus 5.5 --- esphome/components/nextion/display.py | 20 ++++++++ tests/component_tests/nextion/__init__.py | 0 .../nextion/config/custom_triggers.yaml | 21 +++++++++ .../nextion/test_custom_trigger_args.py | 47 +++++++++++++++++++ tests/components/nextion/common.yaml | 31 ++++++++---- 5 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/nextion/__init__.py create mode 100644 tests/component_tests/nextion/config/custom_triggers.yaml create mode 100644 tests/component_tests/nextion/test_custom_trigger_args.py diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 983a4777e81..969a6c8e8c9 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -11,6 +11,7 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, TimePeriod +from esphome.types import ConfigType, TemplateArgsType from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( @@ -177,6 +178,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback" ), +) + +# The strings live only while the callback runs +_CUSTOM_CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_CUSTOM_BINARY_SENSOR, "add_custom_binary_sensor_callback", @@ -293,3 +298,18 @@ async def to_code(config): cg.add_define(define_name) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + for entry in _CUSTOM_CALLBACK_AUTOMATIONS: + for conf in config.get(entry.conf_key, []): + await automation.build_callback_automation( + var, entry.callback_method, _owning_args(entry.args, conf), conf + ) + + +def _owning_args(args: TemplateArgsType, conf: ConfigType) -> TemplateArgsType: + """Pass std::string instead of StringRef to an automation that defers, as it keeps its arguments.""" + if not automation.has_non_synchronous_actions(conf): + return args + return [ + (cg.std_string if type_ is cg.StringRef else type_, name) + for type_, name in args + ] diff --git a/tests/component_tests/nextion/__init__.py b/tests/component_tests/nextion/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/nextion/config/custom_triggers.yaml b/tests/component_tests/nextion/config/custom_triggers.yaml new file mode 100644 index 00000000000..6391a8babe8 --- /dev/null +++ b/tests/component_tests/nextion/config/custom_triggers.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 115200 + +display: + - platform: nextion + on_custom_text_sensor: + - then: + - lambda: 'ESP_LOGD("test", "%s=%s", key.c_str(), value.c_str());' + - then: + - delay: 1s + - lambda: 'ESP_LOGD("test", "%s=%s", key.c_str(), value.c_str());' diff --git a/tests/component_tests/nextion/test_custom_trigger_args.py b/tests/component_tests/nextion/test_custom_trigger_args.py new file mode 100644 index 00000000000..765b7ec786c --- /dev/null +++ b/tests/component_tests/nextion/test_custom_trigger_args.py @@ -0,0 +1,47 @@ +"""Tests for the argument types of the nextion custom-protocol triggers.""" + +from collections.abc import Callable +from pathlib import Path + +import esphome.codegen as cg +from esphome.components.nextion.display import _owning_args +from esphome.core import Lambda + +ARGS = [(cg.StringRef, "key"), (cg.bool_, "value")] +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +DEFERRING_ACTION = {"delay": "1s"} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + + +def test_synchronous_automation_keeps_string_ref() -> None: + assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION]}) == ARGS + + +def test_deferring_automation_gets_std_string() -> None: + assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}) == [ + (cg.std_string, "key"), + (cg.bool_, "value"), + ] + + +def test_nested_deferring_action_gets_std_string() -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + assert _owning_args(ARGS, {"then": actions})[0] == (cg.std_string, "key") + + +def test_all_string_refs_swapped() -> None: + args = [(cg.StringRef, "key"), (cg.StringRef, "value")] + assert _owning_args(args, {"then": [DEFERRING_ACTION]}) == [ + (cg.std_string, "key"), + (cg.std_string, "value"), + ] + + +def test_generated_automation_types( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("custom_triggers.yaml")) + + assert "Automation" in main_cpp + assert "Automation" in main_cpp diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 4f4347d89a9..fc2c833e83c 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -287,13 +287,20 @@ display: then: logger.log: "Nextion reported a buffer overflow!" on_custom_text_sensor: - then: - - lambda: |- - // key: StringRef, value: StringRef - if (key == "csv") { - // parse value here, or forward to your own component - ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); - } + - then: + - lambda: |- + // key: StringRef, value: StringRef + if (key == "csv") { + // parse value here, or forward to your own component + ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); + } + # Deferring: key and value are passed as std::string + - then: + - delay: 1s + - lambda: |- + if (key == "csv") { + ESP_LOGD("nextion.csv", "Got CSV earlier: %s", value.c_str()); + } on_custom_sensor: then: - lambda: |- @@ -308,9 +315,13 @@ display: ESP_LOGD("nextion.btn", "btn1=%s", ONOFF(value)); } on_custom_switch: - then: - - lambda: |- - ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); + - then: + - lambda: |- + ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); + - then: + - delay: 1s + - lambda: |- + ESP_LOGD("nextion.sw", "%s was %s", key.c_str(), ONOFF(value)); on_page: then: lambda: 'ESP_LOGD("display","Display shows new page %u", x);' From 92a4e283a923c68d61dd7435b192b836789dbf11 Mon Sep 17 00:00:00 2001 From: Zach Isbach Date: Sun, 11 Oct 2026 23:40:59 -0700 Subject: [PATCH 350/435] [epaper_spi] Add Good Display GDEY0213F51 4-color e-paper support (#17991) --- .../components/epaper_spi/models/jd79660.py | 36 +++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 23 ++++++++++++ 2 files changed, 59 insertions(+) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index ca824dccd4d..a70d3fe522e 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -118,3 +118,39 @@ jd79660.extend( (0x04,), ), ) + +# Good Display GDEY0213F51 +# +# Panel: 2.13" BWYR (4-color) e-paper, 122x250, controller JD79661 +# Init derived from Good Display sample +# +# Cross-checked against GxEPD2_213c_GDEY0213F51 +# +# +# Note: busy pin uses LOW=busy, HIGH=idle. +# +# fmt: off +jd79660.extend( + "goodisplay-gdey0213f51-2.13", + width=128, + height=250, + + initsequence=( + (0x4D, 0x78,), + (0x00, 0x0F, 0x29,), # PSR + (0x01, 0x07, 0x00,), # PWRR + (0x03, 0x10, 0x54, 0x44,), # POFS + (0x06, 0x05, 0x00, 0x3F, 0x0A, 0x25, 0x12, 0x1A,), # BTST_P + (0x50, 0x37,), # CDI + (0x60, 0x02, 0x02,), # TCON + (0x61, 128 // 256, 128 % 256, 250 // 256, 250 % 256,), # TRES: 128x250 fixed + (0xE7, 0x1C,), + (0xE3, 0x22,), + (0xB4, 0xD0,), + (0xB5, 0x03,), + (0xE9, 0x01,), + (0x30, 0x08,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 8cb2a995212..3839b4ff87a 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -234,6 +234,29 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Good Display GDEY0213F51 4-color e-paper (128x250, BWYR, JD79661) + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey0213f51-2.13 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); + it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 255, 0)); + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) - platform: epaper_spi spi_id: spi_bus From 7fbdcb0205a9ff96d774c7c72c2ad9ce57eb824b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:42:52 +0200 Subject: [PATCH 351/435] [emontx] Lock the emonTx across reboots (#20576) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 5 ++++ esphome/components/emontx/emontx.cpp | 23 ++++++++++++++++- esphome/components/emontx/emontx.h | 6 +++++ tests/components/emontx/common.yaml | 1 + tests/components/emontx/emontx_test.cpp | 33 +++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 2146545db52..d6c46b5509e 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -27,6 +27,7 @@ EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" +CONF_LOCK_ON_REBOOT = "lock_on_reboot" DOMAIN = "emontx" @@ -58,6 +59,7 @@ 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, } ) .extend(cv.COMPONENT_SCHEMA) @@ -133,6 +135,9 @@ 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)) + # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index a307ac0de2a..14dc89e56ae 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -6,7 +6,27 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); -void EmonTx::setup() { this->buffer_pos_ = 0; } +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"); +} + +void EmonTx::on_shutdown() { + if (!this->lock_on_reboot_) + return; + if (this->is_paused()) { + ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); + return; + } + ESP_LOGI(TAG, "Locking emonTx before shutdown"); + this->send_command("emonlock"); + this->flush(); +} /** * @brief Implements the main loop for parsing data from the serial port. @@ -97,6 +117,7 @@ void EmonTx::parse_json_(const char *data, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 83ee4fdbabe..bcec7ce171d 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -33,6 +33,7 @@ class EmonTx final : public Component, public uart::UARTDevice { void loop() override; void setup() override; void dump_config() override; + void on_shutdown() override; template void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward(callback)); } @@ -48,6 +49,10 @@ 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; } + #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } void register_sensor(const char *tag_name, sensor::Sensor *sensor); @@ -64,6 +69,7 @@ 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}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line std::array buffer_{}; }; diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 5c25e37abb5..e609a217e0e 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,6 +8,7 @@ button: emontx: id: test_emontx + lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 8530b0a0036..5d9fa096119 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -158,4 +158,37 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { EXPECT_EQ(this->lines_[0], "{\"P1\":2}"); } +TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.set_lock_on_reboot(true); + this->sut_.on_shutdown(); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.set_lock_on_reboot(true); + 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); + this->sut_.setup(); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); +} + +TEST_F(EmonTxTest, SetupSendsNothingByDefault) { + this->sut_.setup(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + } // namespace esphome::emontx::testing From c668ca516c6a110e3c4ec3f7732c8b5a3c71d3bc Mon Sep 17 00:00:00 2001 From: toups Date: Mon, 12 Oct 2026 02:48:55 -0400 Subject: [PATCH 352/435] [remote_base] updates to support 56 bit panasonic codes (#20260) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 47 ++++++-- .../remote_base/panasonic_protocol.cpp | 94 ++++++++-------- .../remote_base/panasonic_protocol.h | 9 +- tests/components/remote_base/__init__.py | 1 + .../components/remote_base/panasonic_test.cpp | 100 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 4 +- .../remote_receiver/esp32-common.yaml | 7 ++ .../remote_transmitter/common-buttons.yaml | 9 ++ .../components/remote_base/test_panasonic.py | 49 +++++++++ 9 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 tests/components/remote_base/panasonic_test.cpp create mode 100644 tests/unit_tests/components/remote_base/test_panasonic.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index cbe6aa8b495..1a767b723d7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -59,6 +59,7 @@ AUTO_LOAD = ["binary_sensor"] DOMAIN = "remote_base" +CONF_ADDRESS_2 = "address_2" CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" @@ -2122,41 +2123,73 @@ register_protocol_action( ) = declare_protocol("Panasonic") PANASONIC_SCHEMA = cv.Schema( { + cv.Optional(CONF_NBITS, default=48): cv.one_of(48, 56, int=True), cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_ADDRESS_2, default=0x00): cv.hex_uint8_t, cv.Required(CONF_COMMAND): cv.hex_uint32_t, } ) -@register_binary_sensor("panasonic", PanasonicBinarySensor, PANASONIC_SCHEMA) -def panasonic_binary_sensor(var, config): +def _panasonic_address_2_needs_56_bits(config: ConfigType) -> ConfigType: + # A 48-bit code has no second address byte; a lambda is only known on the device + address_2 = config[CONF_ADDRESS_2] + if config[CONF_NBITS] == 48 and isinstance(address_2, int) and address_2 != 0: + raise cv.Invalid( + f"{CONF_ADDRESS_2} is only sent with {CONF_NBITS}: 56", [CONF_ADDRESS_2] + ) + return config + + +# Transmit only +PANASONIC_ACTION_SCHEMA = PANASONIC_SCHEMA.extend( + { + cv.Optional(CONF_CARRIER_FREQUENCY, default="35000Hz"): cv.All( + cv.frequency, cv.uint32_t + ), + } +) + + +@register_binary_sensor( + "panasonic", + PanasonicBinarySensor, + cv.All(PANASONIC_SCHEMA, _panasonic_address_2_needs_56_bits), +) +def panasonic_binary_sensor(var: MockObj, config: ConfigType) -> None: cg.add( var.set_data( cg.StructInitializer( PanasonicData, ("address", config[CONF_ADDRESS]), ("command", config[CONF_COMMAND]), + ("nbits", config[CONF_NBITS]), + ("address_2", config[CONF_ADDRESS_2]), ) ) ) @register_trigger("panasonic", PanasonicTrigger, PanasonicData) -def panasonic_trigger(var, config): - pass +def panasonic_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" @register_dumper("panasonic", PanasonicDumper) -def panasonic_dumper(var, config): - pass +def panasonic_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" register_protocol_action( "panasonic", "Panasonic", - PANASONIC_SCHEMA, + PANASONIC_ACTION_SCHEMA, + (CONF_NBITS, "nbits", cg.uint8), (CONF_ADDRESS, "address", cg.uint16), + (CONF_ADDRESS_2, "address_2", cg.uint8), (CONF_COMMAND, "command", cg.uint32), + (CONF_CARRIER_FREQUENCY, "carrier_frequency", cg.uint32), + validate=_panasonic_address_2_needs_56_bits, ) diff --git a/esphome/components/remote_base/panasonic_protocol.cpp b/esphome/components/remote_base/panasonic_protocol.cpp index c2fc35d3ec9..bedc9bca910 100644 --- a/esphome/components/remote_base/panasonic_protocol.cpp +++ b/esphome/components/remote_base/panasonic_protocol.cpp @@ -11,62 +11,72 @@ static constexpr uint32_t BIT_HIGH_US = 502; static constexpr uint32_t BIT_ZERO_LOW_US = 400; static constexpr uint32_t BIT_ONE_LOW_US = 1244; +static constexpr uint8_t NBITS_ADDRESS = 16; +static constexpr uint8_t NBITS_ADDRESS_2 = 8; +static constexpr uint8_t NBITS_COMMAND = 32; +static constexpr uint8_t NBITS_SHORT = NBITS_ADDRESS + NBITS_COMMAND; +static constexpr uint8_t NBITS_LONG = NBITS_SHORT + NBITS_ADDRESS_2; + +static void encode_bits(RemoteTransmitData *dst, uint32_t value, uint8_t nbits) { + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + dst->item(BIT_HIGH_US, (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + } +} + +static bool decode_bits(RemoteReceiveData &src, uint32_t &value, uint8_t nbits) { + value = 0; + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + value |= mask; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return false; + } + } + return true; +} + void PanasonicProtocol::encode(RemoteTransmitData *dst, const PanasonicData &data) { - dst->reserve(100); + if (data.nbits != NBITS_SHORT && data.nbits != NBITS_LONG) { + ESP_LOGW(TAG, "Unsupported nbits %u, expected 48 or 56", data.nbits); + return; + } + // Header, one item per bit, trailing mark + dst->reserve(3 + 2 * data.nbits); + dst->set_carrier_frequency(data.carrier_frequency); dst->item(HEADER_HIGH_US, HEADER_LOW_US); - dst->set_carrier_frequency(35000); - - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + encode_bits(dst, data.address, NBITS_ADDRESS); + if (data.nbits == NBITS_LONG) { + encode_bits(dst, data.address_2, NBITS_ADDRESS_2); } + encode_bits(dst, data.command, NBITS_COMMAND); dst->mark(BIT_HIGH_US); } + optional PanasonicProtocol::decode(RemoteReceiveData src) { - PanasonicData out{ - .address = 0, - .command = 0, - }; if (!src.expect_item(HEADER_HIGH_US, HEADER_LOW_US)) return {}; - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.address |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.address &= ~mask; - } else { - return {}; - } - } + uint32_t address; + uint32_t word; + if (!decode_bits(src, address, NBITS_ADDRESS) || !decode_bits(src, word, NBITS_COMMAND)) + return {}; - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.command &= ~mask; - } else { - return {}; - } + PanasonicData out{}; + out.address = address; + uint32_t tail; + if (decode_bits(src, tail, NBITS_ADDRESS_2)) { + out.nbits = NBITS_LONG; + out.address_2 = word >> 24; + out.command = (word << 8) | tail; + } else { + out.command = word; } - return out; } + void PanasonicProtocol::dump(const PanasonicData &data) { - ESP_LOGI(TAG, "Received Panasonic: address=0x%04X, command=0x%08" PRIX32, data.address, data.command); + ESP_LOGI(TAG, "Received Panasonic: nbits=%u, address=0x%04X, address_2=0x%02X, command=0x%08" PRIX32, data.nbits, + data.address, data.address_2, data.command); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index da8e3a0ff7f..b560875982e 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -8,10 +8,17 @@ namespace esphome::remote_base { struct PanasonicData { + // address and command stay first so positional initializers written for the 48-bit struct keep working. uint16_t address; uint32_t command; + uint8_t nbits{48}; // 48 or 56 + uint8_t address_2{0}; + // Transmit only: a receiver cannot measure the carrier, so it is not compared. + uint32_t carrier_frequency{35000}; - bool operator==(const PanasonicData &rhs) const { return address == rhs.address && command == rhs.command; } + bool operator==(const PanasonicData &rhs) const { + return address == rhs.address && address_2 == rhs.address_2 && command == rhs.command && nbits == rhs.nbits; + } }; class PanasonicProtocol : public RemoteProtocol { diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 927ff022529..31ecbc3eac0 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "keeloq", "nexus", "rc5x", + "panasonic", ): request_protocol(protocol) diff --git a/tests/components/remote_base/panasonic_test.cpp b/tests/components/remote_base/panasonic_test.cpp new file mode 100644 index 00000000000..dfdf0e5069e --- /dev/null +++ b/tests/components/remote_base/panasonic_test.cpp @@ -0,0 +1,100 @@ +#include +#include "esphome/components/remote_base/panasonic_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +const PanasonicData FRAME_48{0x4004, 0x0100BCBD}; +const PanasonicData FRAME_56{0x4004, 0x0100BCBD, 56, 0x12}; + +RawTimings encode(const PanasonicData &data) { + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return PanasonicProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +} // namespace + +TEST(PanasonicProtocolTest, ForwardsTheConfiguredCarrier) { + PanasonicData data = FRAME_56; + data.carrier_frequency = 36000; + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, data); + EXPECT_EQ(tx.get_carrier_frequency(), 36000u); +} + +TEST(PanasonicProtocolTest, RoundTrips48BitFrame) { + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, FRAME_48); + EXPECT_EQ(tx.get_data().size(), 2u + 2u * 48u + 1u); + EXPECT_EQ(tx.get_carrier_frequency(), 35000u); + auto decoded = decode(tx.get_data()); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_48); +} + +TEST(PanasonicProtocolTest, RoundTrips56BitFrame) { + RawTimings timings = encode(FRAME_56); + EXPECT_EQ(timings.size(), 2u + 2u * 56u + 1u); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_56); +} + +TEST(PanasonicProtocolTest, Decodes56BitFrameWithTrailingItems) { + RawTimings timings = encode(FRAME_56); + timings.insert(timings.end(), {-20000, 3502, -1750, 502, -400}); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_56); +} + +TEST(PanasonicProtocolTest, Decodes48BitFrameWithTrailingItems) { + RawTimings timings = encode(FRAME_48); + // A repeat frame arriving inside the idle window. + timings.insert(timings.end(), {-20000, 3502, -1750, 502, -400}); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_48); +} + +TEST(PanasonicProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = encode(FRAME_48); + timings.resize(timings.size() - 4); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(PanasonicProtocolTest, InvalidNbitsSendsNothing) { + PanasonicData data = FRAME_48; + data.nbits = 40; + EXPECT_TRUE(encode(data).empty()); +} + +TEST(PanasonicProtocolTest, EqualityIgnoresCarrier) { + PanasonicData other_carrier = FRAME_56; + other_carrier.carrier_frequency = 36000; + EXPECT_EQ(FRAME_56, other_carrier); + PanasonicData other_address_2 = FRAME_56; + other_address_2.address_2 = 0x13; + EXPECT_NE(FRAME_56, other_address_2); + EXPECT_NE(FRAME_48, FRAME_56); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 8d9d8506652..fca5b0c3745 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -116,8 +116,8 @@ on_onkyori: on_panasonic: then: - logger.log: - format: "on_panasonic: %u %lu" - args: ["x.address", "long(x.command)"] + format: "on_panasonic: %u %u %u %lu" + args: ["x.nbits", "x.address", "x.address_2", "long(x.command)"] on_pioneer: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index bc9cb59420f..da9a0fc9630 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -21,6 +21,13 @@ binary_sensor: address: 0x00 command: 0x0D extension: 0x03 + - platform: remote_receiver + name: Panasonic 56 bit Remote Input + panasonic: + nbits: 56 + address: 0x4004 + address_2: 0x12 + command: 0x100BCBD - platform: remote_receiver name: Hob2Hood Light On hob2hood: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 16a754d8452..2ef158456e7 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -103,6 +103,15 @@ button: remote_transmitter.transmit_panasonic: address: 0x4004 command: 0x1000BCD + - platform: template + name: Panasonic 56 bit + on_press: + remote_transmitter.transmit_panasonic: + nbits: 56 + address: 0x4004 + address_2: 0x12 + command: !lambda return 0x1000BCD; + carrier_frequency: 36kHz - platform: template name: Pioneer on_press: diff --git a/tests/unit_tests/components/remote_base/test_panasonic.py b/tests/unit_tests/components/remote_base/test_panasonic.py new file mode 100644 index 00000000000..3f0e8794c03 --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_panasonic.py @@ -0,0 +1,49 @@ +"""A Panasonic address_2 is only sent with a 56-bit code.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.remote_base import ( + CONF_ADDRESS_2, + PANASONIC_SCHEMA, + _panasonic_address_2_needs_56_bits, +) +import esphome.config_validation as cv +from esphome.const import CONF_NBITS +from esphome.core import Lambda + +BASE = {"address": 0x4004, "command": 0x0100BCBD} + + +def test_address_2_needs_56_bits() -> None: + config = PANASONIC_SCHEMA({**BASE, CONF_NBITS: 56, CONF_ADDRESS_2: 0x12}) + assert _panasonic_address_2_needs_56_bits(config)[CONF_ADDRESS_2] == 0x12 + with pytest.raises(cv.Invalid, match="only sent with nbits: 56"): + _panasonic_address_2_needs_56_bits( + PANASONIC_SCHEMA({**BASE, CONF_NBITS: 48, CONF_ADDRESS_2: 0x12}) + ) + + +def test_zero_address_2_is_fine_with_48_bits() -> None: + config = PANASONIC_SCHEMA({**BASE, CONF_ADDRESS_2: 0}) + assert _panasonic_address_2_needs_56_bits(config)[CONF_NBITS] == 48 + + +def test_lambda_address_2_is_not_checked_here() -> None: + config = {**BASE, CONF_NBITS: 48, CONF_ADDRESS_2: Lambda("return 0x12;")} + assert isinstance( + _panasonic_address_2_needs_56_bits(config)[CONF_ADDRESS_2], Lambda + ) + + +def test_transmit_action_checks_address_2() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_panasonic"].raw_schema + assert schema({**BASE, CONF_NBITS: 56, CONF_ADDRESS_2: 0x12})[CONF_NBITS] == 56 + assert isinstance( + schema({**BASE, CONF_ADDRESS_2: Lambda("return 0x12;")})[CONF_ADDRESS_2], + Lambda, + ) + with pytest.raises(cv.Invalid, match="only sent with nbits: 56"): + schema({**BASE, CONF_ADDRESS_2: 0x12}) From 20cdca6f1871a52521d65103bb020881deb1eac9 Mon Sep 17 00:00:00 2001 From: Brad Date: Mon, 12 Oct 2026 00:01:12 -0700 Subject: [PATCH 353/435] [remote_base] Decode the full MagiQuest frame and check its checksum (#10622) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 18 ++- .../remote_base/magiquest_protocol.cpp | 60 ++++++--- .../remote_base/magiquest_protocol.h | 17 +-- tests/components/remote_base/__init__.py | 1 + .../components/remote_base/magiquest_test.cpp | 126 ++++++++++++++++++ .../remote_receiver/esp32-common.yaml | 7 + 6 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 tests/components/remote_base/magiquest_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 1a767b723d7..5b6cb33dec4 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -953,16 +953,22 @@ MAGIQUEST_SCHEMA = cv.Schema( } ) +# A wand_id of 0 matches any wand. Magnitude is still accepted but not matched on. +MAGIQUEST_BINARY_SENSOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WAND_ID, default=0): cv.hex_uint32_t, + cv.Optional(CONF_MAGNITUDE): cv.hex_uint16_t, + } +) -@register_binary_sensor("magiquest", MagiQuestBinarySensor, MAGIQUEST_SCHEMA) + +@register_binary_sensor( + "magiquest", MagiQuestBinarySensor, MAGIQUEST_BINARY_SENSOR_SCHEMA +) def magiquest_binary_sensor(var, config): cg.add( var.set_data( - cg.StructInitializer( - MagiQuestData, - ("magnitude", config[CONF_MAGNITUDE]), - ("wand_id", config[CONF_WAND_ID]), - ) + cg.StructInitializer(MagiQuestData, ("wand_id", config[CONF_WAND_ID])) ) ) diff --git a/esphome/components/remote_base/magiquest_protocol.cpp b/esphome/components/remote_base/magiquest_protocol.cpp index 5b47f7e438f..71a6a5ace1b 100644 --- a/esphome/components/remote_base/magiquest_protocol.cpp +++ b/esphome/components/remote_base/magiquest_protocol.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { @@ -10,12 +10,25 @@ namespace esphome::remote_base { ESPHOME_LOG_TAG(TAG, "remote.magiquest"); static constexpr uint32_t MAGIQUEST_UNIT = 288; // us +// Half a unit is the widest window that still tells a one unit mark or space from a two unit one. +static constexpr uint32_t MAGIQUEST_TOLERANCE = MAGIQUEST_UNIT / 2; static constexpr uint32_t MAGIQUEST_ONE_MARK = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ONE_SPACE = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_MARK = MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_SPACE = 3 * MAGIQUEST_UNIT; +static bool checksum_is_valid(uint32_t wand_id, uint32_t magnitude_and_checksum) { + // The frame's six payload bytes, which start at the wand id's top bit, must sum to zero. + const uint32_t id_bytes = wand_id << 1; + uint8_t sum = 0; + for (uint8_t shift = 0; shift < 32; shift += 8) { + sum += (id_bytes >> shift) + (magnitude_and_checksum >> shift); + } + return sum == 0; +} + void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &data) { + // Still the old frame layout, which the decoder no longer reads; changing it would alter what existing configs send. dst->reserve(101); // 2 start bits, 48 data bits, 1 stop bit dst->set_carrier_frequency(38000); @@ -41,41 +54,48 @@ void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &dat dst->mark(MAGIQUEST_UNIT); } optional MagiQuestProtocol::decode(RemoteReceiveData src) { - MagiQuestData data{ - .magnitude = 0, - .wand_id = 0, - }; - // Two start bits - if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE) || - !src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - return {}; + src.set_tolerance(MAGIQUEST_TOLERANCE, TOLERANCE_MODE_TIME); + + MagiQuestData data{}; + + // 8 bit header, always zero + for (uint8_t i = 0; i < 8; i++) { + if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { + return {}; + } } - for (uint32_t mask = 1 << 31; mask; mask >>= 1) { + // 31 bit wand id + for (uint32_t mask = 1 << 30; mask; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { data.wand_id |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.wand_id &= ~mask; - } else { + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } - for (uint16_t mask = 1 << 15; mask; mask >>= 1) { + // 9 bit magnitude, then 8 bit checksum + uint32_t magnitude_and_checksum = 0; + for (uint32_t mask = 1 << 16; mask > 1; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { - data.magnitude |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.magnitude &= ~mask; - } else { + magnitude_and_checksum |= mask; + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } + // The last bit has no trailing space. A wrong or missing mark here is caught by the checksum. + if (src.expect_mark(MAGIQUEST_ONE_MARK)) { + magnitude_and_checksum |= 1; + } - src.expect_mark(MAGIQUEST_UNIT); + if (!checksum_is_valid(data.wand_id, magnitude_and_checksum)) { + return {}; + } + data.magnitude = magnitude_and_checksum >> 8; return data; } void MagiQuestProtocol::dump(const MagiQuestData &data) { - ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=0x%04X", data.wand_id, data.magnitude); + ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=%u", data.wand_id, data.magnitude); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index 98a5fe20819..4e88f06cd45 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -5,23 +5,24 @@ #include /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { +// Older decoders read the wand id 5 bits too early, so ids in older configs lack its 5 low bits. +static constexpr uint8_t MAGIQUEST_LEGACY_WAND_ID_SHIFT = 5; + struct MagiQuestData { uint16_t magnitude; uint32_t wand_id; + // Not symmetric: `this` is the decoded frame and `rhs` the configured match. A configured wand_id of 0 matches + // any wand. Magnitude is not compared; it appears to encode the cast type rather than a strength. The legacy + // match applies to every configured id, so an id below 2^26 also matches the 32 wands whose full id shifts to it. bool operator==(const MagiQuestData &rhs) const { - // Treat 0xffff as a special, wildcard magnitude - // In testing, the wand never produces this value, and this allows us to match - // on just the wand_id if wanted. - if (rhs.wand_id != this->wand_id) { - return false; - } - return (this->wand_id == 0xffff || rhs.wand_id == 0xffff || this->wand_id == rhs.wand_id); + return rhs.wand_id == 0 || rhs.wand_id == this->wand_id || + rhs.wand_id == (this->wand_id >> MAGIQUEST_LEGACY_WAND_ID_SHIFT); } }; diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 31ecbc3eac0..82840b5c2b4 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "aeha", "keeloq", "nexus", + "magiquest", "rc5x", "panasonic", ): diff --git a/tests/components/remote_base/magiquest_test.cpp b/tests/components/remote_base/magiquest_test.cpp new file mode 100644 index 00000000000..3641f1ec807 --- /dev/null +++ b/tests/components/remote_base/magiquest_test.cpp @@ -0,0 +1,126 @@ +#include +#include "esphome/components/remote_base/magiquest_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr int32_t UNIT = 288; +constexpr uint32_t WAND_ID = 0x2A5B3C1D; +constexpr uint16_t MAGNITUDE = 258; +constexpr MagiQuestData DECODED{.magnitude = MAGNITUDE, .wand_id = WAND_ID}; + +void add_bit(RawTimings &timings, bool one, bool last = false) { + timings.push_back(one ? 2 * UNIT : UNIT); + if (!last) { + timings.push_back(-(one ? 2 * UNIT : 3 * UNIT)); + } +} + +// Overwrite bit `bit` of a built frame with a one +void set_one(RawTimings &timings, size_t bit) { + timings[2 * bit] = 2 * UNIT; + timings[2 * bit + 1] = -2 * UNIT; +} + +// Build a frame the way a wand sends it: 8 zero header bits, a 31 bit wand id, a 9 bit magnitude and a checksum +// that makes the six payload bytes sum to zero. The last bit has no trailing space. +RawTimings build_frame(uint32_t wand_id, uint16_t magnitude, uint8_t checksum_offset = 0) { + const uint32_t id_bytes = wand_id << 1; + const uint32_t magnitude_bits = uint32_t(magnitude) << 8; + uint8_t sum = 0; + for (uint8_t shift = 0; shift < 32; shift += 8) { + sum += (id_bytes >> shift) + (magnitude_bits >> shift); + } + const uint32_t magnitude_and_checksum = magnitude_bits | uint8_t(-sum + checksum_offset); + + RawTimings timings; + for (int i = 0; i < 8; i++) { + add_bit(timings, false); + } + for (uint32_t mask = 1 << 30; mask; mask >>= 1) { + add_bit(timings, wand_id & mask); + } + for (uint32_t mask = 1 << 16; mask; mask >>= 1) { + add_bit(timings, magnitude_and_checksum & mask, mask == 1); + } + return timings; +} + +// The receiver's tolerance is passed for completeness; decode() replaces it with the protocol's fixed window +optional decode(const RawTimings &timings) { + return MagiQuestProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_decodes_to(const RawTimings &timings, uint32_t wand_id, uint16_t magnitude) { + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->wand_id, wand_id); + EXPECT_EQ(decoded->magnitude, magnitude); + } +} + +} // namespace + +TEST(MagiQuestProtocolTest, DecodesAValidFrame) { + expect_decodes_to(build_frame(WAND_ID, MAGNITUDE), WAND_ID, MAGNITUDE); +} + +// Magnitude 1 with wand id 0 needs a checksum of 0xFF, so the last bit is a one; magnitude 2 needs 0xFE. +TEST(MagiQuestProtocolTest, DecodesBothValuesOfTheLastBit) { + expect_decodes_to(build_frame(0, 1), 0, 1); + expect_decodes_to(build_frame(0, 2), 0, 2); +} + +TEST(MagiQuestProtocolTest, RejectsABadChecksum) { + EXPECT_FALSE(decode(build_frame(WAND_ID, MAGNITUDE, 1)).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsAFlippedDataBit) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + set_one(timings, 8); // first wand id bit, a zero in WAND_ID + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsANonZeroHeader) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + set_one(timings, 0); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsATruncatedFrame) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + timings.resize(timings.size() - 10); + EXPECT_FALSE(decode(timings).has_value()); +} + +// Receivers stretch marks and shrink spaces; 120 us is more than 25% of a unit but within the fixed window. +TEST(MagiQuestProtocolTest, DecodesWithReceiverJitterUnderTheDefaultTolerance) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + for (auto &timing : timings) { + timing += 120; + } + expect_decodes_to(timings, WAND_ID, MAGNITUDE); +} + +TEST(MagiQuestProtocolTest, MatchesAConfiguredWand) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID})); + EXPECT_FALSE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID + 1})); +} + +TEST(MagiQuestProtocolTest, MatchesAnyWandWhenNoIdIsConfigured) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = 0})); +} + +// Configs written for the old decoder hold the id without its 5 low bits. +TEST(MagiQuestProtocolTest, MatchesAnIdFromTheOldDecoder) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID >> 5})); +} + +TEST(MagiQuestProtocolTest, IgnoresMagnitudeWhenMatching) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0xFFFF, .wand_id = WAND_ID})); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index da9a0fc9630..f66c2ed3b0b 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -32,6 +32,13 @@ binary_sensor: name: Hob2Hood Light On hob2hood: command: light_on + - platform: remote_receiver + name: MagiQuest Wand + magiquest: + wand_id: 0x0F1E2D3C + - platform: remote_receiver + name: MagiQuest Any Wand + magiquest: {} - platform: remote_receiver name: Nexus Sensor nexus: From d265ed2b9e1a4fd27accc9df1a9369bffc48edad Mon Sep 17 00:00:00 2001 From: Siddharth Singh <148135273+Siddharthsinghkumar@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:59:09 +0530 Subject: [PATCH 354/435] [remote_base] Fix NEC command_repeats to send repeat frames (#17663) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 3 +- .../components/remote_base/nec_protocol.cpp | 59 ++++---- tests/components/remote_base/__init__.py | 1 + .../remote_base/nec_protocol_test.cpp | 132 ++++++++++++++++++ 4 files changed, 159 insertions(+), 36 deletions(-) create mode 100644 tests/components/remote_base/nec_protocol_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 5b6cb33dec4..a664a6bdaf8 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1076,7 +1076,8 @@ NEC_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, cv.Required(CONF_COMMAND): cv.hex_uint16_t, - cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.uint16_t, + # Non-RMT transmitters hold an interrupt lock for the whole send, ~108 ms per extra repeat + cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.int_range(min=1, max=10), } ) diff --git a/esphome/components/remote_base/nec_protocol.cpp b/esphome/components/remote_base/nec_protocol.cpp index 3edb50141c4..c4ba8c5ebc9 100644 --- a/esphome/components/remote_base/nec_protocol.cpp +++ b/esphome/components/remote_base/nec_protocol.cpp @@ -1,4 +1,5 @@ #include "nec_protocol.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::remote_base { @@ -10,35 +11,42 @@ static constexpr uint32_t HEADER_LOW_US = 4500; static constexpr uint32_t BIT_HIGH_US = 560; static constexpr uint32_t BIT_ONE_LOW_US = 1690; static constexpr uint32_t BIT_ZERO_LOW_US = 560; +static constexpr uint32_t REPEAT_HEADER_LOW_US = HEADER_LOW_US / 2; +// A held button repeats every 108 ms: the first gap depends on the frame's own length, later ones follow a repeat frame +static constexpr uint32_t REPEAT_PERIOD_US = 108000; +static constexpr uint32_t REPEAT_FRAME_US = HEADER_HIGH_US + REPEAT_HEADER_LOW_US + BIT_HIGH_US; +static constexpr uint32_t REPEAT_GAP_US = REPEAT_PERIOD_US - REPEAT_FRAME_US; +// Transmitters without an RMT peripheral hold an interrupt lock for the whole send, about 108 ms per repeat +static constexpr uint16_t MAX_COMMAND_REPEATS = 10; void NECProtocol::encode(RemoteTransmitData *dst, const NECData &data) { ESP_LOGD(TAG, "Sending NEC: address=0x%04X, command=0x%04X command_repeats=%d", data.address, data.command, data.command_repeats); - dst->reserve(2 + 32 + 32 * data.command_repeats + 2); + // The schema bounds literal values; a lambda can produce anything, so clamp here too + const uint16_t repeats = clamp(data.command_repeats, 1, MAX_COMMAND_REPEATS); + // Frame: header pair, 32 bit pairs, stop mark. Each extra repeat: gap space, header pair, mark. + dst->reserve(2 + 64 + 1 + (repeats - 1) * 4); dst->set_carrier_frequency(38000); dst->item(HEADER_HIGH_US, HEADER_LOW_US); + uint32_t frame_us = HEADER_HIGH_US + HEADER_LOW_US + BIT_HIGH_US; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (uint16_t repeats = 0; repeats < data.command_repeats; repeats++) { + for (uint16_t value : {data.address, data.command}) { for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + const uint32_t low_us = (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US; + dst->item(BIT_HIGH_US, low_us); + frame_us += BIT_HIGH_US + low_us; } } dst->mark(BIT_HIGH_US); + + for (uint16_t repeat = 1; repeat < repeats; repeat++) { + dst->space(repeat == 1 ? REPEAT_PERIOD_US - frame_us : REPEAT_GAP_US); + dst->item(HEADER_HIGH_US, REPEAT_HEADER_LOW_US); + dst->mark(BIT_HIGH_US); + } } optional NECProtocol::decode(RemoteReceiveData src) { NECData data{ @@ -69,26 +77,7 @@ optional NECProtocol::decode(RemoteReceiveData src) { } } - while (src.peek_item(BIT_HIGH_US, BIT_ONE_LOW_US) || src.peek_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - uint16_t command = 0; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - command &= ~mask; - } else { - return {}; - } - } - - // Make sure the extra/repeated data matches original command - if (command != data.command) { - return {}; - } - - data.command_repeats += 1; - } - + // Repeat frames arrive as separate captures (receiver idle is far below the repeat gap) and are not decoded src.expect_mark(BIT_HIGH_US); return data; } diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 82840b5c2b4..cddee51195b 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "aeha", "keeloq", "nexus", + "nec", "magiquest", "rc5x", "panasonic", diff --git a/tests/components/remote_base/nec_protocol_test.cpp b/tests/components/remote_base/nec_protocol_test.cpp new file mode 100644 index 00000000000..6b6914ce9ca --- /dev/null +++ b/tests/components/remote_base/nec_protocol_test.cpp @@ -0,0 +1,132 @@ +#include +#include +#include "esphome/components/remote_base/nec_protocol.h" + +namespace esphome::remote_base::testing { + +// NEC timings from the spec (esphome/issues#6603), independent of the encoder constants +static constexpr int32_t HEADER_MARK = 9000; +static constexpr int32_t HEADER_SPACE = -4500; +static constexpr int32_t REPEAT_HEADER_SPACE = -2250; +static constexpr int32_t BIT_MARK = 560; +static constexpr int32_t ONE_SPACE = -1690; +static constexpr int32_t ZERO_SPACE = -560; +static constexpr int32_t REPEAT_PERIOD = 108000; +static constexpr int32_t REPEAT_GAP = -(REPEAT_PERIOD - (HEADER_MARK - REPEAT_HEADER_SPACE + BIT_MARK)); + +// Builds a full NEC frame by hand: header, address and command LSB first, stop mark +static std::vector build_frame(uint16_t address, uint16_t command) { + std::vector raw{HEADER_MARK, HEADER_SPACE}; + for (uint16_t value : {address, command}) { + for (int bit = 0; bit < 16; bit++) { + raw.push_back(BIT_MARK); + raw.push_back((value >> bit) & 1 ? ONE_SPACE : ZERO_SPACE); + } + } + raw.push_back(BIT_MARK); + return raw; +} + +/// The space that puts the first repeat frame 108 ms after the start of this frame +static int32_t first_repeat_gap(const std::vector &frame) { + int32_t duration = 0; + for (int32_t item : frame) { + duration += item < 0 ? -item : item; + } + return -(REPEAT_PERIOD - duration); +} + +TEST(NECProtocolTest, EncodeWithoutRepeats) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x7F80, .command = 0xF20D, .command_repeats = 1}); + + EXPECT_EQ(dst.get_carrier_frequency(), 38000u); + EXPECT_EQ(dst.get_data(), build_frame(0x7F80, 0xF20D)); +} + +TEST(NECProtocolTest, EncodeWithRepeats) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x7F80, .command = 0xF20D, .command_repeats = 3}); + + std::vector expected = build_frame(0x7F80, 0xF20D); + expected.insert(expected.end(), {first_repeat_gap(expected), HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}); + expected.insert(expected.end(), {REPEAT_GAP, HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}); + EXPECT_EQ(dst.get_data(), expected); +} + +TEST(NECProtocolTest, FirstRepeatGapFollowsFrameLength) { + NECProtocol protocol; + RemoteTransmitData all_zeros; + protocol.encode(&all_zeros, NECData{.address = 0, .command = 0, .command_repeats = 2}); + RemoteTransmitData all_ones; + protocol.encode(&all_ones, NECData{.address = 0xFFFF, .command = 0xFFFF, .command_repeats = 2}); + + const std::vector zeros_frame = build_frame(0, 0); + const std::vector ones_frame = build_frame(0xFFFF, 0xFFFF); + EXPECT_EQ(all_zeros.get_data()[zeros_frame.size()], first_repeat_gap(zeros_frame)); + EXPECT_EQ(all_ones.get_data()[ones_frame.size()], first_repeat_gap(ones_frame)); + // Both frames start their repeat 108 ms after they began + EXPECT_EQ(first_repeat_gap(zeros_frame) - first_repeat_gap(ones_frame), + static_cast(32 * (ONE_SPACE - ZERO_SPACE))); +} + +TEST(NECProtocolTest, EncodeClampsRepeats) { + NECProtocol protocol; + RemoteTransmitData none; + protocol.encode(&none, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 0}); + EXPECT_EQ(none.get_data().size(), 67u); + + RemoteTransmitData many; + protocol.encode(&many, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 500}); + EXPECT_EQ(many.get_data().size(), 67u + 4u * 9u); +} + +TEST(NECProtocolTest, EncodeReserveMatchesSize) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 5}); + + EXPECT_EQ(dst.get_data().size(), 67u + 4u * 4u); + EXPECT_GE(dst.get_data().capacity(), dst.get_data().size()); +} + +TEST(NECProtocolTest, DecodeFullFrame) { + NECProtocol protocol; + std::vector raw = build_frame(0x1234, 0x5678); + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + auto decoded = protocol.decode(rx); + + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access does not model gtest's ASSERT_TRUE as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234); + EXPECT_EQ(decoded->command, 0x5678); + EXPECT_EQ(decoded->command_repeats, 1); + } +} + +TEST(NECProtocolTest, DecodeWithoutStopMark) { + NECProtocol protocol; + std::vector raw = build_frame(0x1234, 0x5678); + raw.pop_back(); + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + auto decoded = protocol.decode(rx); + + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234); + EXPECT_EQ(decoded->command, 0x5678); + } +} + +TEST(NECProtocolTest, DecodeStandaloneRepeatFrameRejected) { + NECProtocol protocol; + std::vector raw{HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}; + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + + EXPECT_FALSE(protocol.decode(rx).has_value()); +} + +} // namespace esphome::remote_base::testing From 061b27af02c90da834f2a48fce50c1a4a2fcde42 Mon Sep 17 00:00:00 2001 From: marshn Date: Mon, 12 Oct 2026 08:54:55 +0100 Subject: [PATCH 355/435] [remote_base] Add a data option and checksum handling to the Drayton protocol (#13060) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 30 +++- .../remote_base/drayton_protocol.cpp | 165 +++++++++--------- .../components/remote_base/drayton_protocol.h | 6 +- tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/drayton_test.cpp | 141 +++++++++++++++ .../remote_receiver/common-actions.yaml | 4 +- .../remote_receiver/esp32-common.yaml | 11 ++ .../remote_transmitter/common-buttons.yaml | 7 + 8 files changed, 273 insertions(+), 92 deletions(-) create mode 100644 tests/components/remote_base/drayton_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a664a6bdaf8..b0dc93e3b79 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1501,22 +1501,34 @@ async def raw_action(var, config, args): ) = declare_protocol("Drayton") DRAYTON_SCHEMA = cv.Schema( { - cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), - cv.Required(CONF_CHANNEL): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), - cv.Required(CONF_COMMAND): cv.All(cv.hex_int, cv.Range(min=0, max=0x7F)), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_DATA): cv.hex_int_range(min=0, max=0xF), + cv.Optional(CONF_CHANNEL): cv.hex_int_range(min=0, max=0x1F), + cv.Optional(CONF_COMMAND): cv.hex_int_range(min=0, max=0x7F), } ) +# The legacy command is the 4 data bits followed by this many checksum bits +DRAYTON_NBITS_CHECKSUM_IN_COMMAND = 3 +# Either `data`, or the legacy `channel` + `command` pair (raw packet bits) +_validate_drayton_form = cv.All( + cv.has_none_or_all_keys(CONF_CHANNEL, CONF_COMMAND), + cv.has_exactly_one_key(CONF_DATA, CONF_COMMAND), +) -@register_binary_sensor("drayton", DraytonBinarySensor, DRAYTON_SCHEMA) +@register_binary_sensor( + "drayton", DraytonBinarySensor, cv.All(DRAYTON_SCHEMA, _validate_drayton_form) +) def drayton_binary_sensor(var, config): + # Matching compares address and data only + if (command := config.get(CONF_COMMAND)) is not None: + data = command >> DRAYTON_NBITS_CHECKSUM_IN_COMMAND + else: + data = config[CONF_DATA] cg.add( var.set_data( cg.StructInitializer( - DraytonData, - ("address", config[CONF_ADDRESS]), - ("channel", config[CONF_CHANNEL]), - ("command", config[CONF_COMMAND]), + DraytonData, ("address", config[CONF_ADDRESS]), ("data", data) ) ) ) @@ -1537,8 +1549,10 @@ register_protocol_action( "Drayton", DRAYTON_SCHEMA, (CONF_ADDRESS, "address", cg.uint16), + (CONF_DATA, "data", cg.uint8), (CONF_CHANNEL, "channel", cg.uint8), (CONF_COMMAND, "command", cg.uint8), + validate=_validate_drayton_form, ) diff --git a/esphome/components/remote_base/drayton_protocol.cpp b/esphome/components/remote_base/drayton_protocol.cpp index 598f1a476ef..1bb2a684175 100644 --- a/esphome/components/remote_base/drayton_protocol.cpp +++ b/esphome/components/remote_base/drayton_protocol.cpp @@ -12,13 +12,12 @@ static constexpr uint8_t CARRIER_KHZ = 2; static constexpr uint8_t NBITS_PREAMBLE = 12; static constexpr uint8_t NBITS_SYNC = 4; static constexpr uint8_t NBITS_ADDRESS = 16; -static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_DATA = 4; +static constexpr uint8_t NBITS_CHECKSUM = 8; static constexpr uint8_t NBITS_COMMAND = 7; -static constexpr uint8_t NDATABITS = NBITS_ADDRESS + NBITS_CHANNEL + NBITS_COMMAND; -static constexpr uint8_t MIN_RX_SRC = (NDATABITS + NBITS_SYNC / 2); - -static constexpr uint8_t CMD_ON = 0x41; -static constexpr uint8_t CMD_OFF = 0x02; +static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_PKT = NBITS_ADDRESS + NBITS_DATA + NBITS_CHECKSUM; +static constexpr uint8_t MIN_RX_SRC = (NBITS_PKT + NBITS_SYNC / 2); /* Drayton Protocol @@ -31,59 +30,72 @@ The number of these bits seen at the receiver varies depending on the state of the bias when the packet transmission starts. The receiver algoritmn takes account of this. -The packet appears to be Manchester encoded, with a '10' tranmitted pair -representing a '1' bit and a '01' pair representing a '0' bit. Each packet is -begun with a '1100' syncronisation symbol which breaks this rule. Following +The packet is Manchester coded, with a '10' tranmitted pair representing +a '1' bit and a '01' pair representing a '0' bit. Each packet is begun +with a '1100' syncronisation symbol which breaks this rule. Following the sync are 28 '01' or '10' pairs. -------------------- -Boiler On Command as received: +Sample Boiler On Command as received: 101010101010110001101001010101101001010101010101100101010101101001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-1-0-0-0-0-0-1-1-0-0-1-0 (Where pppp represents the preamble bits and SSSS represents the sync symbol) -28 bits of data received 01100001100000001000001 10010 (bin) or 6180832 (hex) +28 bits received 01100001100000001000001 10010 (bin) or 6180832 (hex) -Boiler Off Command as received: +Sample Boiler Off Command as received: 101010101010110001101001010101101001010101010101010101010110011001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-0-0-0-0-0-1-0-1-0-0-1-0 -28 bits of data received 0110000110000000000001010010 (bin) or 6180052 (hex) +28 bits received 0110000110000000000001010010 (bin) or 6180052 (hex) -------------------- -I have used 'RFLink' software (RLink Firmware Version: 1.1 Revision: 48) to -capture and retransmit the Digistat packets. RFLink splits each packet into an -ID, SWITCH, and CMD field. +The 28 bits consist of 20 bits of payload and 8 bits of checksum. -0;17;Drayton;ID=c300;SWITCH=12;CMD=ON; -20;18;Drayton;ID=c300;SWITCH=12;CMD=OFF; +In order of transmission the payload consists of the following fields: --------------------- +Bits Description +12 ID of the thermostat. A Digistat receiver will only respond to a packet if its ID + matches the ID that the receiver has previously learnt. +4 Pairing field, normally 0000. The first packets sent by the thermostat after its + batteries are inserted have this field set to 0001, which lets a Digistat receiver + in pairing mode learn the ID of the thermostat. +1 Thermostat on (1) or off (0). +1 Always 0. +1 Low battery (1) or battery OK (0). +1 Always 0. -Spliting my received data into three parts of 16, 7 and 5 bits gives address, -channel and Command values of: +The YAML/lambda 'address' is the first 16 bits as sent (the 12 bit ID followed by the +pairing field, so the pairing flag is the address LSB), and 'data' is the next 4 bits +(0x8 = on, 0x2 = low battery). -On 6180832 0110000110000000 1000001 10010 -address: '0x6180' channel: '0x12' command: '0x41' +Checksum: see calc_cs(); the +1 when the sum is above 0x7F is empirical. The captured +packets above give 0x32 (On) and 0x52 (Off). Packets that fail it are logged at verbose level. -Off 6180052 0110000110000000 0000010 10010 -address: '0x6180' channel: '0x12' command: '0x02' - -These values are slightly different to those used by RFLink (the RFLink -ID/Adress value is rotated/manipulated), and I don't know who's interpretation -is correct. A larger data sample would help (I have only found five different -packet captures online) or definitive information from Drayton. - -Splitting each packet in this way works well for me with esphome. Any -corrections or additional data samples would be gratefully received. - -marshn +Legacy split (from RFLink): address (16), command (7: data + top 3 checksum bits), channel (5: low checksum bits). +When channel and command are set they are sent as is, with no checksum calculated. */ +static uint8_t calc_cs(uint32_t out_data) { + uint16_t cs = 0; + uint32_t rev = reverse_bits(out_data); + + for (uint8_t i = 4; i <= 20; i += 4) { + cs <<= 1; + cs += (rev >> i) & 0x0F; + } + + if (cs > 0x7F) { + cs += 1; + } + // Sent LSB first + return reverse_bits(uint8_t(cs << 1)); +} + void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { uint16_t khz = CARRIER_KHZ; dst->set_carrier_frequency(khz * 1000); @@ -108,17 +120,21 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } } - ESP_LOGD(TAG, "Send Drayton: address=%04x channel=%03x cmd=%02x", data.address, data.channel, data.command); + if (data.channel || data.command) { + out_data = data.address; + out_data <<= NBITS_COMMAND; + out_data |= data.command; + out_data <<= NBITS_CHANNEL; + out_data |= data.channel; + } else { + out_data = data.address << (NBITS_DATA + NBITS_CHECKSUM); + out_data |= (data.data & ((1u << NBITS_DATA) - 1)) << NBITS_CHECKSUM; // a lambda may exceed 4 bits + out_data |= calc_cs(out_data); + } + ESP_LOGD(TAG, "addr=%04x data=%x channel=%02x command=%02x", data.address, data.data, data.channel, data.command); + ESP_LOGV(TAG, "out_data %07" PRIx32, out_data); - out_data = data.address; - out_data <<= NBITS_COMMAND; - out_data |= data.command; - out_data <<= NBITS_CHANNEL; - out_data |= data.channel; - - ESP_LOGV(TAG, "Send Drayton: out_data %08" PRIx32, out_data); - - for (uint32_t mask = 1UL << (NDATABITS - 1); mask != 0; mask >>= 1) { + for (uint32_t mask = 1UL << (NBITS_PKT - 1); mask != 0; mask >>= 1) { if (out_data & mask) { dst->mark(BIT_TIME_US); dst->space(BIT_TIME_US); @@ -130,21 +146,9 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } optional DraytonProtocol::decode(RemoteReceiveData src) { - DraytonData out{ - .address = 0, - .channel = 0, - .command = 0, - }; + DraytonData out{}; while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, - "Decode Drayton: %" PRId32 ", %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 "", - src.size() - src.get_index(), src.peek(0), src.peek(1), src.peek(2), src.peek(3), src.peek(4), - src.peek(5), src.peek(6), src.peek(7), src.peek(8), src.peek(9), src.peek(10), src.peek(11), src.peek(12), - src.peek(13), src.peek(14), src.peek(15), src.peek(16), src.peek(17), src.peek(18), src.peek(19)); - // If first preamble item is a space, skip it if (src.peek_space_at_least(1)) { src.advance(1); @@ -152,12 +156,12 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Look for sync pulse, after. If sucessful index points to space of sync symbol while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, "Decode Drayton: sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), - src.peek(), src.peek(1)); + ESP_LOGVV(TAG, "sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), src.peek(), + src.peek(1)); if (src.peek_mark(2 * BIT_TIME_US) && (src.peek_space(2 * BIT_TIME_US, 1) || src.peek_space(3 * BIT_TIME_US, 1))) { src.advance(1); - ESP_LOGVV(TAG, "Decode Drayton: Found SYNC, - %" PRIu32, src.get_index()); + ESP_LOGVV(TAG, "Found SYNC %" PRIu32, src.get_index()); break; } else { src.advance(2); @@ -165,8 +169,8 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } // No point continuing if not enough samples remaining to complete a packet - if (src.size() - src.get_index() < NDATABITS) { - ESP_LOGV(TAG, "Decode Drayton: Fail 1, - %" PRIu32, src.get_index()); + if (src.size() - src.get_index() < NBITS_PKT) { + ESP_LOGV(TAG, "Fail1 %" PRIu32, src.get_index()); break; } @@ -174,17 +178,14 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Extract first bit // Checks next bit to leave index pointing correctly uint32_t out_data = 0; - uint8_t bit = NDATABITS - 1; - ESP_LOGVV(TAG, "Decode Drayton: first bit %" PRId32 " %" PRId32 ", %" PRId32, src.peek(0), src.peek(1), - src.peek(2)); + uint8_t bit = NBITS_PKT - 1; if (src.expect_space(3 * BIT_TIME_US) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; } else if (src.expect_space(2 * BIT_TIME_US) && src.expect_mark(BIT_TIME_US) && (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { out_data |= 1 << bit; } else { - ESP_LOGV(TAG, "Decode Drayton: Fail 2, - %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail2 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -192,7 +193,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // if there is no transition at the start of the bit period, then the transition in the middle of // the previous bit period. while (--bit >= 1) { - ESP_LOGVV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + ESP_LOGVV(TAG, "Data %2d %08" PRIx32, bit, out_data); if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; @@ -205,8 +206,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } if (bit > 0) { - ESP_LOGVV(TAG, "Decode Drayton: Fail 3, %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail3 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -218,21 +218,28 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { continue; } - ESP_LOGV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + uint8_t cs = out_data & 0xFF; + uint8_t expected_cs = calc_cs(out_data); - out.channel = (uint8_t) (out_data & 0x1F); - out_data >>= NBITS_CHANNEL; - out.command = (uint8_t) (out_data & 0x7F); - out_data >>= NBITS_COMMAND; - out.address = (uint16_t) (out_data & 0xFFFF); + if (cs != expected_cs) { + ESP_LOGV(TAG, "Dropped %07" PRIx32 ": checksum 0x%02x, expected 0x%02x", out_data, cs, expected_cs); + continue; + } + + ESP_LOGV(TAG, "Data %07" PRIx32, out_data); + + out.data = (out_data >> NBITS_CHECKSUM) & ((1u << NBITS_DATA) - 1); + out.address = out_data >> (NBITS_CHECKSUM + NBITS_DATA); + out.channel = out_data & ((1u << NBITS_CHANNEL) - 1); + out.command = (out_data >> NBITS_CHANNEL) & ((1u << NBITS_COMMAND) - 1); return out; } return {}; } + void DraytonProtocol::dump(const DraytonData &data) { - ESP_LOGI(TAG, "Received Drayton: address=0x%04X (0x%04x), channel=0x%03x command=0x%03X", data.address, - ((data.address << 1) & 0xffff), data.channel, data.command); + ESP_LOGI(TAG, "Received Drayton: address=0x%04X, data=0x%X", data.address, data.data); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index ef9dc39fb3b..83988fbaa69 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -11,10 +11,10 @@ struct DraytonData { uint16_t address; uint8_t channel; uint8_t command; + uint8_t data; // last, so positional initializers written for the legacy layout keep their meaning - bool operator==(const DraytonData &rhs) const { - return address == rhs.address && channel == rhs.channel && command == rhs.command; - } + // channel/command: legacy split of the same bits, filled on decode, ignored by operator== + bool operator==(const DraytonData &rhs) const { return address == rhs.address && data == rhs.data; } }; class DraytonProtocol : public RemoteProtocol { diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index cddee51195b..36060d079ce 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -17,6 +17,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "nec", "magiquest", "rc5x", + "drayton", "panasonic", ): request_protocol(protocol) diff --git a/tests/components/remote_base/drayton_test.cpp b/tests/components/remote_base/drayton_test.cpp new file mode 100644 index 00000000000..455235cd993 --- /dev/null +++ b/tests/components/remote_base/drayton_test.cpp @@ -0,0 +1,141 @@ +#include +#include +#include "esphome/components/remote_base/drayton_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr int32_t BIT_US = 500; +constexpr size_t PACKET_BITS = 28; + +// Captured packets from the header comment of drayton_protocol.cpp +constexpr uint32_t ON_PACKET = 0x6180832; +constexpr uint32_t OFF_PACKET = 0x6180052; +const char *const ON_CAPTURE = "101010101010110001101001010101101001010101010101100101010101101001011001"; +const char *const OFF_CAPTURE = "101010101010110001101001010101101001010101010101010101010110011001011001"; + +DraytonData with_data(uint16_t address, uint8_t data) { + DraytonData out{}; + out.address = address; + out.data = data; + return out; +} + +DraytonData with_legacy(uint16_t address, uint8_t channel, uint8_t command) { + DraytonData out{}; + out.address = address; + out.channel = channel; + out.command = command; + return out; +} + +RawTimings encode(const DraytonData &data) { + RemoteTransmitData tx; + DraytonProtocol().encode(&tx, data); + return tx.get_data(); +} + +// The packet bits sent after the preamble and sync, one Manchester pair per bit ('10' = 1) +uint32_t sent_packet(const RawTimings &timings) { + uint32_t word = 0; + for (size_t i = timings.size() - 2 * PACKET_BITS; i < timings.size(); i += 2) { + word = (word << 1) | (timings[i] > 0 ? 1 : 0); + } + return word; +} + +// A receiver reports runs of the same level as one item. +RawTimings as_received(const RawTimings &timings) { + RawTimings merged; + for (int32_t item : timings) { + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + return merged; +} + +RawTimings from_capture(const std::string &bits) { + RawTimings timings; + for (char bit : bits) { + timings.push_back(bit == '1' ? BIT_US : -BIT_US); + } + return as_received(timings); +} + +optional decode(const RawTimings &timings) { + return DraytonProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +} // namespace + +TEST(DraytonProtocolTest, EncodeAppendsChecksum) { + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x8))), ON_PACKET); + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x0))), OFF_PACKET); + // A lambda value above 4 bits is masked instead of spilling into the address + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x18))), ON_PACKET); +} + +TEST(DraytonProtocolTest, LegacyEncodeSendsChannelAndCommandAsIs) { + EXPECT_EQ(sent_packet(encode(with_legacy(0x6180, 0x12, 0x41))), ON_PACKET); + EXPECT_EQ(sent_packet(encode(with_legacy(0x1234, 0x01, 0x05))), 0x12340A1u); +} + +TEST(DraytonProtocolTest, DecodesCapturedPackets) { + auto on = decode(from_capture(ON_CAPTURE)); + ASSERT_TRUE(on.has_value()); + if (!on.has_value()) { + return; + } + EXPECT_EQ(on->address, 0x6180); + EXPECT_EQ(on->data, 0x8); + EXPECT_EQ(on->channel, 0x12); + EXPECT_EQ(on->command, 0x41); + + auto off = decode(from_capture(OFF_CAPTURE)); + ASSERT_TRUE(off.has_value()); + if (!off.has_value()) { + return; + } + EXPECT_EQ(off->address, 0x6180); + EXPECT_EQ(off->data, 0x0); + EXPECT_EQ(off->channel, 0x12); + EXPECT_EQ(off->command, 0x02); +} + +TEST(DraytonProtocolTest, RoundTrips) { + for (uint16_t address : {0x0000, 0x6180, 0x1231, 0xFFFF}) { + for (uint8_t data : {0x0, 0x2, 0x8, 0xA, 0xF}) { + auto decoded = decode(as_received(encode(with_data(address, data)))); + ASSERT_TRUE(decoded.has_value()) << std::hex << address << " " << int(data); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, address); + EXPECT_EQ(decoded->data, data); + } + } +} + +TEST(DraytonProtocolTest, RejectsBadChecksum) { + // On packet with the low checksum bits changed from 0x12 to 0x13 + EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x13, 0x41)))).has_value()); + // On packet with the data bits of the Off packet + EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x12, 0x01)))).has_value()); +} + +TEST(DraytonProtocolTest, BinarySensorFormsMatchDecodedPacket) { + auto on = decode(from_capture(ON_CAPTURE)); + ASSERT_TRUE(on.has_value()); + if (!on.has_value()) { + return; + } + // The decoded channel and command are ignored + EXPECT_TRUE(*on == with_data(0x6180, 0x8)); + EXPECT_FALSE(*on == with_data(0x6181, 0x8)); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index fca5b0c3745..cd01690344b 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -51,8 +51,8 @@ on_dooya: on_drayton: then: - logger.log: - format: "on_drayton: %u %u %u" - args: ["x.address", "x.channel", "x.command"] + format: "on_drayton: %u %u %u %u" + args: ["x.address", "x.channel", "x.command", "x.data"] on_dyson: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index f66c2ed3b0b..d2b135f0340 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -21,6 +21,17 @@ binary_sensor: address: 0x00 command: 0x0D extension: 0x03 + - platform: remote_receiver + name: Drayton Thermostat On + drayton: + address: 0x6180 + data: 0x8 + - platform: remote_receiver + name: Drayton Thermostat On Legacy + drayton: + address: 0x6180 + channel: 0x12 + command: 0x41 - platform: remote_receiver name: Panasonic 56 bit Remote Input panasonic: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 2ef158456e7..52a43f41b9f 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -27,6 +27,13 @@ button: on_press: remote_transmitter.transmit_brennenstuhl: code: 0xBD2E2C + - platform: template + name: Drayton Thermostat On + id: drayton_thermostat_on + on_press: + remote_transmitter.transmit_drayton: + address: 0x1230 + data: 0x8 - platform: template name: Dyson fan up id: dyson_fan_up From 1e05106a783084815d2be10c79373becc5e7a2d9 Mon Sep 17 00:00:00 2001 From: mfishma Date: Mon, 12 Oct 2026 01:01:48 -0700 Subject: [PATCH 356/435] [mipi_spi] Add M5STACK-ATOMS3R-ST7735 as a display panel configuration (#18673) --- esphome/components/mipi_spi/models/ili.py | 2 +- esphome/components/mipi_spi/models/m5stack.py | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 187fcfd8c09..835799c262b 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -649,7 +649,7 @@ DriverChip( (0xF3, 0x52, 0xA4, 0x7F, 0x33, 0x34, 0xDF), ), ) -DriverChip( +ST7735 = DriverChip( "ST7735", color_order=MODE_RGB, width=128, diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index a54bd19d883..f62bee5f602 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -4,6 +4,7 @@ from esphome.components.mipi import ( GMCTRP1, IDMOFF, IFMODE, + MODE_BGR, PWCTR1, PWCTR2, SETEXTC, @@ -11,7 +12,7 @@ from esphome.components.mipi import ( DriverChip, ) -from .ili import ILI9341, ST7789V +from .ili import ILI9341, ST7735, ST7789V # fmt: off DriverChip( @@ -71,3 +72,23 @@ GC9107.extend( cs_pin=14, requires={"psram"}, ) + +# M5Stack AtomS3R (ST7735 revision - May 14, 2026+) +ST7735.extend( + "M5STACK-ATOMS3R-ST7735", + native_width=132, + native_height=132, + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=3, + data_rate="40MHz", + color_order=MODE_BGR, + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, + requires={"psram"}, +) From 602bf00435e89883b208dc66028b883b13b232da Mon Sep 17 00:00:00 2001 From: Anil Daoud Date: Mon, 12 Oct 2026 16:21:26 +0800 Subject: [PATCH 357/435] [daikin_brc] Add sub_unit parameter for multi-unit addressing (#15234) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/daikin_brc/climate.py | 4 ++++ esphome/components/daikin_brc/daikin_brc.cpp | 11 ++++++++--- esphome/components/daikin_brc/daikin_brc.h | 3 +++ tests/components/daikin_brc/common.yaml | 4 ++++ tests/components/daikin_brc/test.esp32-idf.yaml | 3 +-- tests/components/daikin_brc/test.esp8266-ard.yaml | 3 +-- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/esphome/components/daikin_brc/climate.py b/esphome/components/daikin_brc/climate.py index c5c1d3739ee..c710dc94a84 100644 --- a/esphome/components/daikin_brc/climate.py +++ b/esphome/components/daikin_brc/climate.py @@ -6,6 +6,8 @@ from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] +CONF_SUB_UNIT = "sub_unit" + daikin_brc_ns = cg.esphome_ns.namespace("daikin_brc") DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR) @@ -13,6 +15,7 @@ DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).extend( { cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + cv.Optional(CONF_SUB_UNIT, default=0): cv.int_range(min=0, max=5), } ) @@ -20,3 +23,4 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).ext async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) + cg.add(var.set_sub_unit(config[CONF_SUB_UNIT])) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index cfb3521a66d..c16eea733f7 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -20,13 +20,18 @@ void DaikinBrcClimate::transmit_state() { 0xDA, 0x17, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00}; + // The low 3 bits of the frame header byte address the indoor unit, in the preamble and the state frame + remote_state[3] |= this->sub_unit_; + remote_state[10] |= this->sub_unit_; + remote_state[6] += this->sub_unit_; // preamble checksum covers byte 3 + remote_state[12] = this->alt_mode_(); remote_state[13] = this->mode_button_; remote_state[14] = this->operation_mode_(); remote_state[17] = this->temperature_(); remote_state[18] = this->fan_speed_swing_(); - // Calculate checksum + // Calculate state frame checksum for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1; i++) { remote_state[DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1] += remote_state[i]; } @@ -254,8 +259,8 @@ bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) { if (byte != 0x17) return false; } else if (pos == 3) { - // frame header - if (byte != 0x18) + // frame header, with this unit's address in the low 3 bits + if (byte != (0x18 | this->sub_unit_)) return false; } else if (pos == 4) { // frame type diff --git a/esphome/components/daikin_brc/daikin_brc.h b/esphome/components/daikin_brc/daikin_brc.h index c963f72ab68..5160e44a111 100644 --- a/esphome/components/daikin_brc/daikin_brc.h +++ b/esphome/components/daikin_brc/daikin_brc.h @@ -60,6 +60,8 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR { this->fahrenheit_ = value; this->temperature_step_ = value ? 0.5f : 1.0f; } + /// Which indoor unit (0 to 5) this climate addresses on a multi-unit system + void set_sub_unit(uint8_t value) { this->sub_unit_ = value; } protected: uint8_t mode_button_ = 0x00; @@ -75,6 +77,7 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); bool fahrenheit_{false}; + uint8_t sub_unit_{0}; }; } // namespace esphome::daikin_brc diff --git a/tests/components/daikin_brc/common.yaml b/tests/components/daikin_brc/common.yaml index 89786954ba0..0e363cd1564 100644 --- a/tests/components/daikin_brc/common.yaml +++ b/tests/components/daikin_brc/common.yaml @@ -2,3 +2,7 @@ climate: - platform: daikin_brc name: Daikin_brc Climate transmitter_id: xmitr + - platform: daikin_brc + name: Daikin_brc Climate Unit 2 + transmitter_id: xmitr + sub_unit: 1 diff --git a/tests/components/daikin_brc/test.esp32-idf.yaml b/tests/components/daikin_brc/test.esp32-idf.yaml index e891f9dc85c..9761ee9edec 100644 --- a/tests/components/daikin_brc/test.esp32-idf.yaml +++ b/tests/components/daikin_brc/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + daikin_brc: !include common.yaml diff --git a/tests/components/daikin_brc/test.esp8266-ard.yaml b/tests/components/daikin_brc/test.esp8266-ard.yaml index 4bed2f03e55..6140ccb881e 100644 --- a/tests/components/daikin_brc/test.esp8266-ard.yaml +++ b/tests/components/daikin_brc/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + daikin_brc: !include common.yaml From e92d23999635b136f1775fe75e131089d665ba2c Mon Sep 17 00:00:00 2001 From: "Sebastian G." Date: Mon, 12 Oct 2026 10:37:05 +0200 Subject: [PATCH 358/435] [dsmr] add Switzerland channel 1-1 sensors (EWL Luzern AM550) (#17391) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/dsmr/__init__.py | 2 +- esphome/components/dsmr/sensor.py | 168 ++++++++++++++++++++++ platformio.ini | 2 +- tests/components/dsmr/test.esp32-idf.yaml | 60 ++++++++ 4 files changed, 230 insertions(+), 2 deletions(-) diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 07dbf558310..4ba9d1696ae 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.9.0") + cg.add_library("esphome/dsmr_parser", "1.10.0") def final_validate(config: ConfigType) -> None: diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 6aecc62d6bb..c8d12c326ba 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -97,6 +97,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_delivered_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_delivered_tariff1_ch"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -109,6 +115,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_returned_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_returned_tariff1_ch"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -121,6 +133,77 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + # Reactive energy totals and per-quadrant (Q1-Q4) values (Switzerland, e.g. EWL Luzern AM550) + cv.Optional("total_imported_energy_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("total_exported_energy_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -231,6 +314,91 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + # Per-phase active power and maximum demand (Switzerland, e.g. EWL Luzern AM550) + cv.Optional("power_delivered_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_delivered_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_delivered_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("maximum_power_delivered_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("maximum_power_returned_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional("reactive_power_delivered"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, accuracy_decimals=3, diff --git a/platformio.ini b/platformio.ini index 7b3414a32a7..0d6338d6cd1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.9.0 ; dsmr + esphome/dsmr_parser@1.10.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library diff --git a/tests/components/dsmr/test.esp32-idf.yaml b/tests/components/dsmr/test.esp32-idf.yaml index 9eb7d3e1784..4e8224226f2 100644 --- a/tests/components/dsmr/test.esp32-idf.yaml +++ b/tests/components/dsmr/test.esp32-idf.yaml @@ -12,6 +12,66 @@ sensor: name: "Energy Consumed Luxembourg. OBIS: 1-0:1.8.0" energy_delivered_tariff1: name: "Energy Consumed Tariff 1. OBIS: 1-0:1.8.1" + energy_delivered_ch: + name: "Energy Consumed (CH). OBIS: 1-1:1.8.0" + energy_returned_ch: + name: "Energy Produced (CH). OBIS: 1-1:2.8.0" + total_imported_energy_ch: + name: "Reactive Energy Imported (CH). OBIS: 1-1:3.8.0" + total_exported_energy_ch: + name: "Reactive Energy Exported (CH). OBIS: 1-1:4.8.0" + reactive_energy_q1_ch: + name: "Reactive Energy Q1 (CH). OBIS: 1-1:5.8.0" + reactive_energy_q1_tariff1_ch: + name: "Reactive Energy Q1 Tariff 1 (CH). OBIS: 1-1:5.8.1" + reactive_energy_q1_tariff2_ch: + name: "Reactive Energy Q1 Tariff 2 (CH). OBIS: 1-1:5.8.2" + reactive_energy_q2_ch: + name: "Reactive Energy Q2 (CH). OBIS: 1-1:6.8.0" + reactive_energy_q2_tariff1_ch: + name: "Reactive Energy Q2 Tariff 1 (CH). OBIS: 1-1:6.8.1" + reactive_energy_q2_tariff2_ch: + name: "Reactive Energy Q2 Tariff 2 (CH). OBIS: 1-1:6.8.2" + reactive_energy_q3_ch: + name: "Reactive Energy Q3 (CH). OBIS: 1-1:7.8.0" + reactive_energy_q3_tariff1_ch: + name: "Reactive Energy Q3 Tariff 1 (CH). OBIS: 1-1:7.8.1" + reactive_energy_q3_tariff2_ch: + name: "Reactive Energy Q3 Tariff 2 (CH). OBIS: 1-1:7.8.2" + reactive_energy_q4_ch: + name: "Reactive Energy Q4 (CH). OBIS: 1-1:8.8.0" + reactive_energy_q4_tariff1_ch: + name: "Reactive Energy Q4 Tariff 1 (CH). OBIS: 1-1:8.8.1" + reactive_energy_q4_tariff2_ch: + name: "Reactive Energy Q4 Tariff 2 (CH). OBIS: 1-1:8.8.2" + power_delivered_l1_ch: + name: "Power Consumed L1 (CH). OBIS: 1-1:21.7.0" + power_delivered_l2_ch: + name: "Power Consumed L2 (CH). OBIS: 1-1:41.7.0" + power_delivered_l3_ch: + name: "Power Consumed L3 (CH). OBIS: 1-1:61.7.0" + power_returned_l1_ch: + name: "Power Produced L1 (CH). OBIS: 1-1:22.7.0" + power_returned_l2_ch: + name: "Power Produced L2 (CH). OBIS: 1-1:42.7.0" + power_returned_l3_ch: + name: "Power Produced L3 (CH). OBIS: 1-1:62.7.0" + reactive_power_delivered_l1_ch: + name: "Reactive Power Consumed L1 (CH). OBIS: 1-1:23.7.0" + reactive_power_delivered_l2_ch: + name: "Reactive Power Consumed L2 (CH). OBIS: 1-1:43.7.0" + reactive_power_delivered_l3_ch: + name: "Reactive Power Consumed L3 (CH). OBIS: 1-1:63.7.0" + reactive_power_returned_l1_ch: + name: "Reactive Power Produced L1 (CH). OBIS: 1-1:24.7.0" + reactive_power_returned_l2_ch: + name: "Reactive Power Produced L2 (CH). OBIS: 1-1:44.7.0" + reactive_power_returned_l3_ch: + name: "Reactive Power Produced L3 (CH). OBIS: 1-1:64.7.0" + maximum_power_delivered_ch: + name: "Maximum Power Consumed (CH). OBIS: 1-1:1.6.0" + maximum_power_returned_ch: + name: "Maximum Power Produced (CH). OBIS: 1-1:2.6.0" text_sensor: - platform: dsmr From af76f19ab48834ea7d87e35dbf2cfb6d3afda6b4 Mon Sep 17 00:00:00 2001 From: BugHaver <43462320+bughaver@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:18:22 +1100 Subject: [PATCH 359/435] [heatpumpir] Add ZMP IR receiver (#17099) 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/heatpumpir/heatpumpir.cpp | 19 +++ esphome/components/heatpumpir/heatpumpir.h | 11 +- .../heatpumpir/receiver_mitsubishi_heavy.cpp | 109 ++++++++++++++++++ .../heatpumpir/receiver_mitsubishi_heavy.h | 20 ++++ .../receiver_mitsubishi_heavy_zmp.cpp | 63 ++++++++++ tests/components/heatpumpir/common.yaml | 9 ++ .../heatpumpir/test.bk72xx-ard.yaml | 4 +- .../components/heatpumpir/test.esp32-ard.yaml | 4 +- .../components/heatpumpir/test.esp32-idf.yaml | 4 +- .../heatpumpir/test.esp8266-ard.yaml | 4 +- .../common/remote_receiver/bk72xx-ard.yaml | 12 ++ 11 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp create mode 100644 esphome/components/heatpumpir/receiver_mitsubishi_heavy.h create mode 100644 esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp create mode 100644 tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 2376a67416f..ca0ffb37c18 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -1,5 +1,7 @@ #include "heatpumpir.h" +#include "receiver_mitsubishi_heavy.h" + #if defined(USE_ARDUINO) || defined(USE_ESP32) #include @@ -246,6 +248,23 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd); } +bool HeatpumpIRClimate::on_receive(remote_base::RemoteReceiveData data) { + switch (this->protocol_) { + case PROTOCOL_MITSUBISHI_HEAVY_ZMP: { + uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]; + if (!decode_mitsubishi_heavy_frame(data, frame, *this)) + return false; + decode_mitsubishi_heavy_zmp(frame, *this); + break; + } + default: + return false; // no decoder for this protocol + } + this->target_temperature = clamp(this->target_temperature, this->min_temperature_, this->max_temperature_); + this->publish_state(); + return true; +} + } // namespace esphome::heatpumpir #endif diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 8e0668d59db..2c119c75504 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -102,7 +102,14 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} void setup() override; - void set_protocol(Protocol protocol) { this->protocol_ = protocol; } + void set_protocol(Protocol protocol) { + this->protocol_ = protocol; + if (protocol == PROTOCOL_MITSUBISHI_HEAVY_ZMP) { + this->presets_.insert(climate::CLIMATE_PRESET_NONE); + this->presets_.insert(climate::CLIMATE_PRESET_ECO); + this->presets_.insert(climate::CLIMATE_PRESET_BOOST); + } + } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -117,6 +124,8 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { HeatpumpIR *heatpump_ir_; /// Transmit via IR the state of this climate controller. void transmit_state() override; + /// Handle received IR data from the remote control. + bool on_receive(remote_base::RemoteReceiveData data) override; Protocol protocol_; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp new file mode 100644 index 00000000000..004eb46c007 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp @@ -0,0 +1,109 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +static constexpr uint8_t FRAME_PREFIX[5] = {0x52, 0xAE, 0xC3, 0x26, 0xD9}; +static constexpr uint8_t XOR_CHECKSUM_EXPECTED = 0xFF; +static constexpr uint8_t PREFIX_LENGTH = 5; +static constexpr uint8_t MODE_TEMP_BYTE = 9; +static constexpr uint8_t MODE_MASK = 0x07; +static constexpr uint8_t TEMP_MASK = 0x0F; +static constexpr uint8_t TEMPERATURE_BASE = 17; +static constexpr uint8_t TEMPERATURE_SHIFT = 4; + +static bool read_raw_bytes(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + if (!data.expect_item(MITSUBISHI_HEAVY_HDR_MARK, MITSUBISHI_HEAVY_HDR_SPACE)) + return false; + + for (uint8_t i = 0; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i++) { + uint8_t byte = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ONE_SPACE)) { + byte |= 1 << bit; + } else if (!data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ZERO_SPACE)) { + return false; + } + } + frame[i] = byte; + } + + return true; +} + +static bool validate_prefix(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = 0; i < PREFIX_LENGTH; i++) { + if (frame[i] != FRAME_PREFIX[i]) + return false; + } + return true; +} + +static bool validate_checksums(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = PREFIX_LENGTH; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i += 2) { + if ((uint8_t) (frame[i] ^ frame[i + 1]) != XOR_CHECKSUM_EXPECTED) + return false; + } + return true; +} + +static bool decode_mode_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], climate::ClimateMode &mode) { + if (frame[MODE_TEMP_BYTE] & MITSUBISHI_HEAVY_MODE_OFF) { + mode = climate::CLIMATE_MODE_OFF; + return true; + } + switch (frame[MODE_TEMP_BYTE] & MODE_MASK) { + case MITSUBISHI_HEAVY_MODE_AUTO: + mode = climate::CLIMATE_MODE_HEAT_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_HEAT: + mode = climate::CLIMATE_MODE_HEAT; + return true; + case MITSUBISHI_HEAVY_MODE_COOL: + mode = climate::CLIMATE_MODE_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_DRY: + mode = climate::CLIMATE_MODE_DRY; + return true; + case MITSUBISHI_HEAVY_MODE_FAN: + mode = climate::CLIMATE_MODE_FAN_ONLY; + return true; + default: + return false; + } +} + +static float decode_temperature_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + uint8_t temp_nibble = frame[MODE_TEMP_BYTE] >> TEMPERATURE_SHIFT; + uint8_t inverted_nibble = ~temp_nibble; + uint8_t temp_offset = inverted_nibble & TEMP_MASK; + return TEMPERATURE_BASE + temp_offset; +} + +static bool apply_climate_state(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + climate::ClimateMode mode; + if (!decode_mode_field(frame, mode)) + return false; + climate.mode = mode; + climate.target_temperature = decode_temperature_field(frame); + return true; +} + +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate) { + if (!read_raw_bytes(data, frame)) + return false; + if (!validate_prefix(frame)) + return false; + if (!validate_checksums(frame)) + return false; + return apply_climate_state(frame, climate); +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h new file mode 100644 index 00000000000..61e3c34ca46 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::heatpumpir { + +class HeatpumpIRClimate; + +/// Bytes in a Mitsubishi Heavy IR frame, prefix included +inline constexpr uint8_t MITSUBISHI_HEAVY_FRAME_LENGTH = 11; + +/// Read a Mitsubishi Heavy frame into `frame`, check its prefix and inverted byte pairs, and apply the +/// fields every variant shares (mode and target temperature). Returns false when no valid frame was read. +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate); + +/// Apply the ZMP-specific fields (fan, preset, swing) of a validated frame. +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate); + +} // namespace esphome::heatpumpir diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp new file mode 100644 index 00000000000..4d92c35c77e --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp @@ -0,0 +1,63 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +// Fan speed sits in bits 7:5 of byte 7; the vertical swing code spans bit 1 of byte 5 and bits 4:3 of byte 7 +static constexpr uint8_t BYTE_SWING_H = 5; +static constexpr uint8_t BYTE_FAN = 7; +static constexpr uint8_t FAN_SPEED_MASK = 0xE0; +static constexpr uint8_t VS_MASK_BYTE5 = 0x02; +static constexpr uint8_t VS_MASK_BYTE7 = 0x18; +// The library has no horizontal swing for ZMP and always sends MITSUBISHI_HEAVY_ZMP_HS_STOP (0xCC) in byte 5. +// Captures of the remote show 0x5C while it swings; the mask leaves out the vertical swing and clean bits. +static constexpr uint8_t HORIZONTAL_SWING_MASK = 0xDC; +static constexpr uint8_t HORIZONTAL_SWING_ACTIVE = 0x5C; + +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + uint8_t fan = frame[BYTE_FAN] & FAN_SPEED_MASK; + climate.preset = climate::CLIMATE_PRESET_NONE; + switch (fan) { + case MITSUBISHI_HEAVY_ZMP_FAN_AUTO: + climate.fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case MITSUBISHI_HEAVY_ZMP_FAN1: + climate.fan_mode = climate::CLIMATE_FAN_LOW; + break; + case MITSUBISHI_HEAVY_ZMP_FAN2: + climate.fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case MITSUBISHI_HEAVY_ZMP_FAN3: + climate.fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case MITSUBISHI_HEAVY_ZMP_HIPOWER: + climate.preset = climate::CLIMATE_PRESET_BOOST; + break; // the remote keeps the fan mode and restores it when the preset ends + case MITSUBISHI_HEAVY_ZMP_ECONO: + climate.preset = climate::CLIMATE_PRESET_ECO; + break; + } + + uint8_t swing_h = frame[BYTE_SWING_H] & HORIZONTAL_SWING_MASK; + uint8_t swing_v = (frame[BYTE_SWING_H] & VS_MASK_BYTE5) | (frame[BYTE_FAN] & VS_MASK_BYTE7); + bool h_swing = (swing_h == HORIZONTAL_SWING_ACTIVE); + bool v_swing = (swing_v == MITSUBISHI_HEAVY_ZMP_VS_SWING); + + if (h_swing && v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_BOTH; + } else if (h_swing) { + climate.swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else if (v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + climate.swing_mode = climate::CLIMATE_SWING_OFF; + } +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/tests/components/heatpumpir/common.yaml b/tests/components/heatpumpir/common.yaml index a2779f9803e..24cc22f06f2 100644 --- a/tests/components/heatpumpir/common.yaml +++ b/tests/components/heatpumpir/common.yaml @@ -7,6 +7,15 @@ climate: min_temperature: 18 max_temperature: 30 transmitter_id: xmitr + - platform: heatpumpir + protocol: mitsubishi_heavy_zmp + horizontal_default: left + vertical_default: up + name: HeatpumpIR Climate Mitsubishi ZMP + min_temperature: 18 + max_temperature: 30 + transmitter_id: xmitr + receiver_id: rcvr - platform: heatpumpir protocol: daikin horizontal_default: mleft diff --git a/tests/components/heatpumpir/test.bk72xx-ard.yaml b/tests/components/heatpumpir/test.bk72xx-ard.yaml index 6cce1918253..779adcc307f 100644 --- a/tests/components/heatpumpir/test.bk72xx-ard.yaml +++ b/tests/components/heatpumpir/test.bk72xx-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/bk72xx-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/bk72xx-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp32-ard.yaml b/tests/components/heatpumpir/test.esp32-ard.yaml index 01009de071c..bd5a74f356c 100644 --- a/tests/components/heatpumpir/test.esp32-ard.yaml +++ b/tests/components/heatpumpir/test.esp32-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp32-idf.yaml b/tests/components/heatpumpir/test.esp32-idf.yaml index e891f9dc85c..3c81acdbaba 100644 --- a/tests/components/heatpumpir/test.esp32-idf.yaml +++ b/tests/components/heatpumpir/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp8266-ard.yaml b/tests/components/heatpumpir/test.esp8266-ard.yaml index 4bed2f03e55..5449d62be4a 100644 --- a/tests/components/heatpumpir/test.esp8266-ard.yaml +++ b/tests/components/heatpumpir/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml b/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml new file mode 100644 index 00000000000..d73aca27d72 --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for BK72XX Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO17 + +remote_receiver: + id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% From 5a90bceba0ff9d9f7c591083948300068f292788 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Mon, 12 Oct 2026 05:42:17 -0400 Subject: [PATCH 360/435] [cse7766] Clamp a power factor above 1 instead of publishing NaN (#15307) 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/cse7766/cse7766.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 0bf077b7468..bbe76855fb8 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -202,9 +202,10 @@ void CSE7766Component::parse_data_() { float pf = NAN; if (apparent_power > 0) { pf = power / apparent_power; - if (pf < 0 || pf > 1) { - ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf); - pf = NAN; + if (pf > 1) { + // Noise near the chip's resolution limit, seen during inrush, pushes the ratio past unity + ESP_LOGD(TAG, "Power factor %.4f above 1, clamped", pf); + pf = 1.0f; } } else if (apparent_power == 0 && power == 0) { // No load, report ideal power factor From 3472ac3dcc06f37b398c5b72109f1f38ba5ec021 Mon Sep 17 00:00:00 2001 From: BugHaver <43462320+bughaver@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:44:01 +1100 Subject: [PATCH 361/435] [heatpumpir] Add Eco and Boost presets and fix fan speeds on Mitsubishi Heavy ZJ and ZMP (#16877) Co-authored-by: J. Nick Koston --- esphome/components/heatpumpir/heatpumpir.cpp | 25 ++++++++++++++++++-- esphome/components/heatpumpir/heatpumpir.h | 11 +++++---- tests/components/heatpumpir/common.yaml | 8 +++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index ca0ffb37c18..b4597fe4abf 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -127,6 +127,13 @@ void HeatpumpIRClimate::setup() { } } +void HeatpumpIRClimate::control(const climate::ClimateCall &call) { + // Picking a fan speed ends HiPower or Econo, as it does on the remote + if (this->has_fan_byte_presets_() && call.get_fan_mode().has_value() && !call.get_preset().has_value()) + this->preset = climate::CLIMATE_PRESET_NONE; + climate_ir::ClimateIR::control(call); +} + void HeatpumpIRClimate::transmit_state() { uint8_t power_mode_cmd; uint8_t operating_mode_cmd; @@ -190,8 +197,9 @@ void HeatpumpIRClimate::transmit_state() { } // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, - // so low/medium/high have to start at FAN_1 there instead of FAN_2. - const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC; + // so low/medium/high have to start at FAN_1 there instead of FAN_2. Mitsubishi Heavy + // ZJ and ZMP also stop at FAN_3 and use FAN_4 and FAN_5 for HiPower and Econo. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC || this->has_fan_byte_presets_(); switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; @@ -208,6 +216,19 @@ void HeatpumpIRClimate::transmit_state() { break; } + if (this->has_fan_byte_presets_()) { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + fan_speed_cmd = FAN_4; // HiPower + break; + case climate::CLIMATE_PRESET_ECO: + fan_speed_cmd = FAN_5; // Econo + break; + default: + break; + } + } + switch (this->mode) { case climate::CLIMATE_MODE_COOL: power_mode_cmd = POWER_ON; diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 2c119c75504..d00fa91d05a 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -104,10 +104,8 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { void setup() override; void set_protocol(Protocol protocol) { this->protocol_ = protocol; - if (protocol == PROTOCOL_MITSUBISHI_HEAVY_ZMP) { - this->presets_.insert(climate::CLIMATE_PRESET_NONE); - this->presets_.insert(climate::CLIMATE_PRESET_ECO); - this->presets_.insert(climate::CLIMATE_PRESET_BOOST); + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; } } void set_horizontal_default(HorizontalDirection horizontal_direction) { @@ -122,8 +120,13 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { protected: HeatpumpIR *heatpump_ir_; + void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. void transmit_state() override; + /// Mitsubishi Heavy ZJ and ZMP carry HiPower and Econo in the fan byte as FAN_4 and FAN_5 + bool has_fan_byte_presets_() const { + return this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZJ || this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZMP; + } /// Handle received IR data from the remote control. bool on_receive(remote_base::RemoteReceiveData data) override; Protocol protocol_; diff --git a/tests/components/heatpumpir/common.yaml b/tests/components/heatpumpir/common.yaml index 24cc22f06f2..de45f91ec96 100644 --- a/tests/components/heatpumpir/common.yaml +++ b/tests/components/heatpumpir/common.yaml @@ -7,6 +7,14 @@ climate: min_temperature: 18 max_temperature: 30 transmitter_id: xmitr + - platform: heatpumpir + protocol: mitsubishi_heavy_zj + horizontal_default: left + vertical_default: mup + name: HeatpumpIR Climate Mitsubishi ZJ + min_temperature: 18 + max_temperature: 30 + transmitter_id: xmitr - platform: heatpumpir protocol: mitsubishi_heavy_zmp horizontal_default: left From c0d4287c6e2e754af8e266db544fc949a3252257 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:07 -1000 Subject: [PATCH 362/435] [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 3c88d39192d8b9cc6a198a2a2f510714e554cf46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:33 -1000 Subject: [PATCH 363/435] [remote_base] List the hob2hood, nexus and onkyori protocol defines in defines.h (#20577) --- esphome/core/defines.h | 3 +++ .../remote_base/test_protocol_defines.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/unit_tests/components/remote_base/test_protocol_defines.py diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0a876b4f0cc..7c3c7d2861b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -169,6 +169,7 @@ #define USE_REMOTE_PROTOCOL_DYSON #define USE_REMOTE_PROTOCOL_GOBOX #define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_HOB2HOOD #define USE_REMOTE_PROTOCOL_JVC #define USE_REMOTE_PROTOCOL_KEELOQ #define USE_REMOTE_PROTOCOL_LG @@ -177,6 +178,8 @@ #define USE_REMOTE_PROTOCOL_MIRAGE #define USE_REMOTE_PROTOCOL_NEC #define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_NEXUS +#define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC #define USE_REMOTE_PROTOCOL_PIONEER #define USE_REMOTE_PROTOCOL_PRONTO diff --git a/tests/unit_tests/components/remote_base/test_protocol_defines.py b/tests/unit_tests/components/remote_base/test_protocol_defines.py new file mode 100644 index 00000000000..fdf6997440b --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_protocol_defines.py @@ -0,0 +1,20 @@ +"""Every gated protocol source needs its define listed for static analysis.""" + +from pathlib import Path + +from esphome.components.remote_base import _PROTOCOL_STEMS, protocol_define + +DEFINES_H = Path(__file__).parents[4] / "esphome" / "core" / "defines.h" + + +def test_every_protocol_define_is_listed_in_defines_h() -> None: + """A protocol compiled only behind its define would drop out of clang-tidy without this line.""" + defines = DEFINES_H.read_text(encoding="utf-8") + missing = [ + stem + for stem in _PROTOCOL_STEMS + if f"#define {protocol_define(stem)}\n" not in defines + ] + assert not missing, ( + f"Add #define {', '.join(protocol_define(s) for s in missing)} to {DEFINES_H.name}" + ) From c212950df5b7700fdc70d60488df67fd91f5c3c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:30:59 -1000 Subject: [PATCH 364/435] [wifi] Exclude the station connect from the loop blocking guard on LibreTiny (#20575) --- esphome/components/wifi/wifi_component_libretiny.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 2a9e682440e..ca32f3d3ffa 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -252,9 +252,14 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTING); s_ignored_disconnect_count = 0; - WiFiStatus status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), - ap.get_channel(), // 0 = auto - ap.has_bssid() ? ap.get_bssid().data() : NULL); + WiFiStatus status; + { + // The driver blocks until the join has started, or finished on Realtek + UnavoidableBlockingScope scope; + status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), + ap.get_channel(), // 0 = auto + ap.has_bssid() ? ap.get_bssid().data() : NULL); + } if (status != WL_CONNECTED) { ESP_LOGW(TAG, "WiFi.begin failed: %d", status); // Without this reset the state machine stays at CONNECTING and each retry From 057a0c5843662b675e31dad6a436a7b3e822d7f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:31:15 -1000 Subject: [PATCH 365/435] [api] Exclude the spare key refill from the loop blocking guard (#20574) --- esphome/components/api/api_server.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 7dad4e390e0..f63039a9714 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -237,6 +237,8 @@ void APIServer::refill_spare_ephemeral_() { return; } } + // Generating the key pair cannot be split across loop passes + UnavoidableBlockingScope scope; noise::prepare_spare_ephemeral(); } #endif From bde67d83f22596b96d595a8f6f57517faeb337b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:33:19 -1000 Subject: [PATCH 366/435] [noise] Exclude the handshake write step from the loop blocking guard (#20573) --- esphome/components/noise/noise_handshake.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index e0b7ca7c372..181dd63bb21 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -1,5 +1,6 @@ #include "noise_handshake.h" #ifdef USE_NOISE +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -111,6 +112,8 @@ int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { out_len = 0; + // The key agreement cannot be split across loop passes + UnavoidableBlockingScope scope; CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); From 698988669d225bc21f077f5d60f371fdf249bd24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:37:51 -1000 Subject: [PATCH 367/435] [remote_transmitter] Compile pronto, rc6, midea and lambda byte payloads in the test config (#20565) --- .../remote_transmitter/common-buttons.yaml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 52a43f41b9f..7fa8cd1132d 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -442,6 +442,54 @@ button: on_press: remote_transmitter.transmit_roomba: data: 0x88 + - platform: template + name: Pronto + on_press: + remote_transmitter.transmit_pronto: + data: "0000 006D 0001 0000 0015 0016" + - platform: template + name: RC6 + on_press: + remote_transmitter.transmit_rc6: + address: 0x00 + command: !lambda return 0x0C; + - platform: template + name: Midea + on_press: + remote_transmitter.transmit_midea: + code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF] + - platform: template + name: Midea lambda + on_press: + remote_transmitter.transmit_midea: + code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; + - platform: template + name: Haier lambda + on_press: + remote_transmitter.transmit_haier: + code: !lambda return {0xA6, 0xDA, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05}; + - platform: template + name: Mirage lambda + on_press: + remote_transmitter.transmit_mirage: + code: !lambda return {0x56, 0x77, 0x00, 0x00, 0x22, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + - platform: template + name: AEHA lambda + on_press: + remote_transmitter.transmit_aeha: + address: 0x8008 + data: !lambda return {0x00, 0x02, 0xFD, 0xFF}; + - platform: template + name: ABBWelcome fixed message id + on_press: + remote_transmitter.transmit_abbwelcome: + source_address: 0x123456 + destination_address: 0x654321 + three_byte_address: true + retransmission: true + message_type: 0x01 + message_id: 0x42 + data: [0x10, 0x20] - platform: template name: SomfyRTS on_press: From 9acc07a97350ca578e002ffcff300f1df01f2e87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:48:02 -1000 Subject: [PATCH 368/435] [remote_base] Generate transmit_pronto and transmit_rc6 with no C++ class (#20564) --- esphome/components/remote_base/__init__.py | 53 ++++++++++++------- .../components/remote_base/pronto_protocol.h | 13 ----- esphome/components/remote_base/rc6_protocol.h | 19 ------- .../remote_transmitter/config/rc6_pronto.yaml | 28 ++++++++++ .../remote_transmitter/test_rc6_pronto.py | 41 ++++++++++++++ 5 files changed, 104 insertions(+), 50 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/rc6_pronto.yaml create mode 100644 tests/component_tests/remote_transmitter/test_rc6_pronto.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index b0dc93e3b79..93e26165d6b 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -308,26 +308,38 @@ def register_action( return decorator +ProtocolField = ( + tuple[str, str, SafeExpType] | automation.ApplyField | automation.ApplyCall +) + + +def _protocol_field( + field: ProtocolField, +) -> automation.ApplyField | automation.ApplyCall: + if not isinstance(field, tuple): + return field + key, member, type_ = field + return automation.ApplyField(key, f"data.{member} = {{}}", type_) + + def register_protocol_action( name: str, protocol: str, schema: cv.Schema, - *fields: tuple[str, str, SafeExpType], + *fields: ProtocolField, validate: Callable[[ConfigType], ConfigType] | None = None, ) -> None: """Register ``transmit_`` with no C++ action class. One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields - and sends it; the action is a core ``ApplyAction``. + and sends it; the action is a core ``ApplyAction``. ``ApplyField`` and ``ApplyCall`` pass through + and target the call object, whose protocol data is ``data``. """ action, schema, validator = _transmit_action(name, schema, validate) builder = automation.apply_action_builder( action, schema, - *( - automation.ApplyField(key, f"data.{member} = {{}}", type_) - for key, member, type_ in fields - ), + *(_protocol_field(field) for field in fields), automation.ApplyField((CONF_REPEAT, CONF_TIMES), "set_send_times", cg.uint32), automation.ApplyField( (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 @@ -1206,7 +1218,7 @@ register_protocol_action( ProntoData, ProntoBinarySensor, ProntoTrigger, - ProntoAction, + _, ProntoDumper, ) = declare_protocol("Pronto") PRONTO_SCHEMA = cv.Schema( @@ -1240,10 +1252,10 @@ def pronto_dumper(var, config): pass -@register_action("pronto", ProntoAction, PRONTO_SCHEMA) -async def pronto_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.std_string) - cg.add(var.set_data(template_)) +# delta only matters when matching a received code +register_protocol_action( + "pronto", "Pronto", PRONTO_SCHEMA, (CONF_DATA, "data", cg.std_string) +) # Gobox @@ -1640,7 +1652,7 @@ register_protocol_action( # RC6 -RC6Data, RC6BinarySensor, RC6Trigger, RC6Action, RC6Dumper = declare_protocol("RC6") +RC6Data, RC6BinarySensor, RC6Trigger, _, RC6Dumper = declare_protocol("RC6") RC6_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint8_t, @@ -1674,12 +1686,17 @@ def rc6_dumper(var, config): pass -@register_action("rc6", RC6Action, RC6_SCHEMA) -async def rc6_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc6", + "RC6", + RC6_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + # Each action flips its own toggle bit, starting at 0: the lambda's static is per action + automation.ApplyCall( + "data.toggle = []() {{ static uint8_t toggle; return toggle++; }}()" + ), +) # RC Switch Raw diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index bfd04c5cd9a..4119b87f286 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -37,17 +37,4 @@ class ProntoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pronto) -template class ProntoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(std::string, data) - TEMPLATABLE_VALUE(int, delta) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ProntoData data{}; - data.data = this->data_.value(x...); - data.delta = this->delta_.value(x...); - ProntoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index c4a2e8529bb..cdeb8dfec76 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -22,23 +22,4 @@ class RC6Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC6) -template class RC6Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - RC6Data data{}; - data.mode = 0; - data.toggle = this->toggle_; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC6Protocol().encode(dst, data); - this->toggle_ = !this->toggle_; - } - - protected: - uint8_t toggle_{0}; -}; - } // namespace esphome::remote_base diff --git a/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml b/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml new file mode 100644 index 00000000000..a91040ebaba --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: RC6 one + on_press: + - remote_transmitter.transmit_rc6: + address: 0x00 + command: 0x0C + - platform: template + name: RC6 two + on_press: + - remote_transmitter.transmit_rc6: + address: 0x00 + command: 0x0D + - platform: template + name: Pronto + on_press: + - remote_transmitter.transmit_pronto: + data: "0000 006D 0001 0000 0015 0016" diff --git a/tests/component_tests/remote_transmitter/test_rc6_pronto.py b/tests/component_tests/remote_transmitter/test_rc6_pronto.py new file mode 100644 index 00000000000..ba68f5a48bf --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_rc6_pronto.py @@ -0,0 +1,41 @@ +"""transmit_rc6 keeps a toggle bit per action, and transmit_pronto sends its string.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome import automation +import esphome.codegen as cg +from esphome.components.remote_base import _protocol_field +from esphome.const import CONF_TEMPERATURE +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_rc6_toggle_and_pronto( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rc6_pronto.yaml")) + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 3 + toggle = ( + "apply_call.data.toggle = []() { static uint8_t toggle; return toggle++; }();" + ) + # Each RC6 action has its own generated function, so its own toggle static. + assert toggle in bodies[0] + assert toggle in bodies[1] + assert ( + 'apply_call.data.data = progmem_string(ESPHOME_F("0000 006D 0001 0000 0015 0016"));' + in bodies[2] + ) + assert "delta" not in bodies[2] + + +def test_apply_objects_pass_through() -> None: + call = automation.ApplyCall("stop()") + assert _protocol_field(call) is call + field = automation.ApplyField( + CONF_TEMPERATURE, "data.set_temperature({})", cg.float_ + ) + assert _protocol_field(field) is field + plain = _protocol_field((CONF_TEMPERATURE, "temperature", cg.float_)) + assert plain.target == "data.temperature = {}" From 780ccfc462e5c5836c60df3dd16f0846f7fd1fa7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 03:56:24 -1000 Subject: [PATCH 369/435] [adc] Skip the output raw, sample count, attenuation and sampling mode setters when they match the defaults (#20068) --- esphome/components/adc/adc_sensor.h | 8 ++--- esphome/components/adc/sensor.py | 32 ++++++++++++++----- .../adc/test_adc_defaults.yaml | 20 ++++++++++++ tests/component_tests/adc/test_adc_sensor.py | 16 ++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/adc/test_adc_defaults.yaml diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 46b7e7a2ffd..f8130876456 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v #endif // USE_RP2 protected: - uint8_t sample_count_{1}; - bool output_raw_{false}; + uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py + bool output_raw_{false}; // Must match CONF_RAW default in sensor.py InternalGPIOPin *pin_; - SamplingMode sampling_mode_{SamplingMode::AVG}; + SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py #ifdef USE_ESP32 float sample_autorange_(); @@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v bool autorange_{false}; adc_oneshot_unit_handle_t adc_handle_{nullptr}; adc_cali_handle_t calibration_handle_{nullptr}; - adc_atten_t attenuation_{ADC_ATTEN_DB_0}; + adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py adc_channel_t channel_{}; adc_unit_t adc_unit_{}; struct SetupFlags { diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 8cdea4f01a6..b44d88d56e3 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"] CONF_SAMPLES = "samples" CONF_SAMPLING_MODE = "sampling_mode" +# Keep in sync with the initializers in adc_sensor.h +DEFAULT_ATTENUATION = "0db" +DEFAULT_SAMPLING_MODE = "avg" +DEFAULT_SAMPLES = 1 + _attenuation = cv.enum(ATTENUATION_MODES, lower=True) _sampling_mode = cv.enum(SAMPLING_MODES, lower=True) @@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType: ): raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'") - if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1: + if ( + config.get(CONF_ATTENUATION, None) == "auto" + and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1 + ): raise cv.Invalid( "Automatic attenuation cannot be used when multisampling is set" ) @@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_PIN): validate_adc_pin, cv.Optional(CONF_RAW, default=False): cv.boolean, - cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All( + cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All( cv.only_on_esp32, _attenuation ), cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec), - cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255), - cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode, + cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range( + min=1, max=255 + ), + cv.Optional( + CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE + ): _sampling_mode, } ) .extend(cv.polling_component_schema("60s")), @@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_output_raw(config[CONF_RAW])) - cg.add(var.set_sample_count(config[CONF_SAMPLES])) - cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE])) + # Skip the setters when the config matches the C++ initializers. + if config[CONF_RAW]: + cg.add(var.set_output_raw(True)) + if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES: + cg.add(var.set_sample_count(samples)) + if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE: + cg.add(var.set_sampling_mode(sampling_mode)) if CORE.is_esp32: # Re-enable ESP-IDF's ADC driver (excluded by default to save compile time) @@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None: if attenuation := config.get(CONF_ATTENUATION): if attenuation == "auto": cg.add(var.set_autorange(cg.global_ns.true)) - else: + elif attenuation != DEFAULT_ATTENUATION: cg.add(var.set_attenuation(attenuation)) variant = get_esp32_variant() diff --git a/tests/component_tests/adc/test_adc_defaults.yaml b/tests/component_tests/adc/test_adc_defaults.yaml new file mode 100644 index 00000000000..1bee400e62a --- /dev/null +++ b/tests/component_tests/adc/test_adc_defaults.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: adc + pin: GPIO36 + name: ADC Defaults + id: adc_defaults + + - platform: adc + pin: GPIO39 + name: ADC Custom + id: adc_custom + raw: true + attenuation: 6db + sampling_mode: max + samples: 4 diff --git a/tests/component_tests/adc/test_adc_sensor.py b/tests/component_tests/adc/test_adc_sensor.py index a6d86f03051..3f1a4ae350d 100644 --- a/tests/component_tests/adc/test_adc_sensor.py +++ b/tests/component_tests/adc/test_adc_sensor.py @@ -30,3 +30,19 @@ def test_adc_regular_pin_is_not_deprecated( assert "adc_voltage->set_is_temperature();" not in main_cpp assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1 + + +def test_adc_default_setters_are_not_emitted( + generate_main: Callable[[str | Path], str], +) -> None: + """Raw, attenuation and sampling mode matching the C++ initializers are skipped.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_defaults.yaml") + + assert "adc_defaults->set_output_raw(" not in main_cpp + assert "adc_defaults->set_attenuation(" not in main_cpp + assert "adc_defaults->set_sampling_mode(" not in main_cpp + assert "adc_defaults->set_sample_count(" not in main_cpp + assert "adc_custom->set_output_raw(true);" in main_cpp + assert "adc_custom->set_attenuation(ADC_ATTEN_DB_6);" in main_cpp + assert "adc_custom->set_sampling_mode(adc::SamplingMode::MAX);" in main_cpp + assert "adc_custom->set_sample_count(4);" in main_cpp From 00872d433696fe775b40f2b01883c32fdccff4c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 03:56:53 -1000 Subject: [PATCH 370/435] [ethernet] Skip setters that pass the default PHY address, MDC, MDIO, clock and SPI interface (#20070) --- esphome/components/ethernet/__init__.py | 43 +++++++++++----- .../components/ethernet/ethernet_component.h | 12 ++--- .../ethernet/config/rmii_custom.yaml | 14 ++++++ .../ethernet/config/rmii_defaults.yaml | 13 +++++ .../ethernet/config/spi_s3_own_bus.yaml | 14 ++++++ .../ethernet/test_default_setters.py | 49 +++++++++++++++++++ 6 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/ethernet/config/rmii_custom.yaml create mode 100644 tests/component_tests/ethernet/config/rmii_defaults.yaml create mode 100644 tests/component_tests/ethernet/config/spi_s3_own_bus.yaml create mode 100644 tests/component_tests/ethernet/test_default_setters.py diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 58915ff96db..31e4952d8e0 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -214,6 +214,15 @@ CLK_MODES = { spi_host_device_t = cg.global_ns.enum("spi_host_device_t") +# Must match the member initializers in ethernet_component.h; setters are skipped +# when the config already equals them. +DEFAULT_SPI_INTERFACE = "spi2" +DEFAULT_PHY_ADDR = 0 +DEFAULT_MDC_PIN = 23 +DEFAULT_MDIO_PIN = 18 +DEFAULT_CLK_MODE = "CLK_EXT_IN" +DEFAULT_CLK_PIN = 0 + SPI_INTERFACE_MAP = { "spi2": spi_host_device_t.SPI2_HOST, "spi3": spi_host_device_t.SPI3_HOST, @@ -437,7 +446,9 @@ RMII_SCHEMA = cv.All( " GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17" ), cv.Required(CONF_CLK): CLK_SCHEMA, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 + ), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -454,7 +465,9 @@ GENERIC_SCHEMA = cv.All( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 + ), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -683,6 +696,15 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(final_step) +def _add_mdio_setters(var: cg.MockObj, config: ConfigType) -> None: + if (phy_addr := config[CONF_PHY_ADDR]) != DEFAULT_PHY_ADDR: + cg.add(var.set_phy_addr(phy_addr)) + if (mdc_pin := config[CONF_MDC_PIN]) != DEFAULT_MDC_PIN: + cg.add(var.set_mdc_pin(mdc_pin)) + if (mdio_pin := config[CONF_MDIO_PIN]) != DEFAULT_MDIO_PIN: + cg.add(var.set_mdio_pin(mdio_pin)) + + async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, @@ -701,7 +723,8 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) - cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) + if (interface := config[CONF_INTERFACE]) != DEFAULT_SPI_INTERFACE: + cg.add(var.set_interface(SPI_INTERFACE_MAP[interface])) cg.add(var.set_cs_pin(config[CONF_CS_PIN])) if CONF_INTERRUPT_PIN in config: cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) @@ -730,18 +753,16 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) elif config[CONF_TYPE] in ("GENERIC", "YT8531"): # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. - cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) - cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + _add_mdio_setters(var, config) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) _add_phy_registers(var, config) else: - cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) - cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) - cg.add(var.set_clk_mode(config[CONF_CLK][CONF_MODE])) - cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN])) + _add_mdio_setters(var, config) + if (clk_mode := config[CONF_CLK][CONF_MODE]) != DEFAULT_CLK_MODE: + cg.add(var.set_clk_mode(clk_mode)) + if (clk_pin := config[CONF_CLK][CONF_PIN]) != DEFAULT_CLK_PIN: + cg.add(var.set_clk_pin(clk_pin)) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) _add_phy_registers(var, config) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 44c4d6733d3..8d5552c6cca 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -271,7 +271,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI2_HOST}; + spi_host_device_t interface_{SPI2_HOST}; // Must match DEFAULT_SPI_INTERFACE in __init__.py #ifdef USE_SPI // When set, the SPI bus is owned and initialized by this spi component // and the ethernet chip only adds a device to it. @@ -283,16 +283,16 @@ class EthernetComponent final : public Component { #else // Group all 32-bit members first int power_pin_{-1}; - emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; + emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; // Must match DEFAULT_CLK_MODE in __init__.py #ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT StaticVector phy_registers_{}; #endif // Group all 8-bit members together - uint8_t clk_pin_{0}; - uint8_t phy_addr_{0}; - uint8_t mdc_pin_{23}; - uint8_t mdio_pin_{18}; + uint8_t clk_pin_{0}; // Must match DEFAULT_CLK_PIN in __init__.py + uint8_t phy_addr_{0}; // Must match DEFAULT_PHY_ADDR in __init__.py + uint8_t mdc_pin_{23}; // Must match DEFAULT_MDC_PIN in __init__.py + uint8_t mdio_pin_{18}; // Must match DEFAULT_MDIO_PIN in __init__.py #endif // USE_ETHERNET_SPI // ESP32 pointers diff --git a/tests/component_tests/ethernet/config/rmii_custom.yaml b/tests/component_tests/ethernet/config/rmii_custom.yaml new file mode 100644 index 00000000000..291753592c4 --- /dev/null +++ b/tests/component_tests/ethernet/config/rmii_custom.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + type: LAN8720 + mdc_pin: GPIO4 + mdio_pin: GPIO5 + phy_addr: 1 + clk: + mode: CLK_OUT + pin: GPIO17 diff --git a/tests/component_tests/ethernet/config/rmii_defaults.yaml b/tests/component_tests/ethernet/config/rmii_defaults.yaml new file mode 100644 index 00000000000..7f39b31dfdb --- /dev/null +++ b/tests/component_tests/ethernet/config/rmii_defaults.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + type: LAN8720 + mdc_pin: GPIO23 + mdio_pin: GPIO18 + clk: + mode: CLK_EXT_IN + pin: GPIO0 diff --git a/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml b/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml new file mode 100644 index 00000000000..3f21fcd2c63 --- /dev/null +++ b/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + +ethernet: + type: W5500 + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO4 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/test_default_setters.py b/tests/component_tests/ethernet/test_default_setters.py new file mode 100644 index 00000000000..4658e8a4fbf --- /dev/null +++ b/tests/component_tests/ethernet/test_default_setters.py @@ -0,0 +1,49 @@ +"""Ethernet setters are skipped when the config matches the C++ initializers.""" + +from collections.abc import Callable +from pathlib import Path + +_MDIO_SETTERS = ("set_phy_addr(", "set_mdc_pin(", "set_mdio_pin(") + + +def test_rmii_defaults_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rmii_defaults.yaml")) + + for setter in (*_MDIO_SETTERS, "set_clk_mode(", "set_clk_pin("): + assert setter not in main_cpp + + +def test_rmii_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rmii_custom.yaml")) + + assert "set_phy_addr(1);" in main_cpp + assert "set_mdc_pin(4);" in main_cpp + assert "set_mdio_pin(5);" in main_cpp + assert "set_clk_mode(::EMAC_CLK_OUT);" in main_cpp + assert "set_clk_pin(17);" in main_cpp + + +def test_spi2_interface_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ESP32-S3 defaults to spi2, which is the C++ initializer.""" + main_cpp = generate_main(component_config_path("spi_s3_own_bus.yaml")) + + assert "set_interface(" not in main_cpp + + +def test_spi3_interface_is_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Classic ESP32 defaults to spi3, so the setter is still needed.""" + main_cpp = generate_main(component_config_path("spi_own_bus.yaml")) + + assert "set_interface(::SPI3_HOST);" in main_cpp From 9e8845885b0f3ab3afa84a81ee463d48a1fc707e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 12 Oct 2026 11:22:59 -0400 Subject: [PATCH 371/435] [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 670145cfe01..1ede1f4d8de 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 72ea9ae4585d7d6b91e8a02a0e871587cafd1c35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:14:19 -1000 Subject: [PATCH 372/435] [remote_base] Generate the byte payload transmit actions with no C++ class (#20568) --- esphome/components/remote_base/__init__.py | 168 ++++++++---------- .../remote_base/abbwelcome_protocol.h | 35 +--- .../components/remote_base/aeha_protocol.cpp | 3 +- .../components/remote_base/aeha_protocol.h | 18 +- .../components/remote_base/haier_protocol.cpp | 3 +- .../components/remote_base/haier_protocol.h | 13 +- .../components/remote_base/midea_protocol.h | 12 -- .../remote_base/mirage_protocol.cpp | 11 +- .../components/remote_base/mirage_protocol.h | 13 +- .../components/remote_base/remote_base.cpp | 5 + esphome/components/remote_base/remote_base.h | 15 +- .../remote_transmitter/__init__.py | 2 +- .../config/byte_payloads.yaml | 33 ++++ .../test_abbwelcome_payload_tables.py | 12 +- .../remote_transmitter/test_byte_payloads.py | 33 ++++ .../remote_transmitter/test_ir_codes.py | 13 +- .../remote_base/ir_code_encoders_test.cpp | 51 ++---- 17 files changed, 223 insertions(+), 217 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/byte_payloads.yaml create mode 100644 tests/component_tests/remote_transmitter/test_byte_payloads.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 93e26165d6b..45873f2385c 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -322,18 +322,33 @@ def _protocol_field( return automation.ApplyField(key, f"data.{member} = {{}}", type_) +_BYTES = cg.std_vector.template(cg.uint8) + + +def _code_table(config: ConfigType, value: list[int]) -> str: + return f"{automation.progmem_bytes('remote_base_code', value)}, {len(value)}" + + +def _bytes_field(conf_key: str, target: str) -> automation.ApplyField: + """A constant's flash table and length, or a lambda's vector; the callee takes both.""" + return automation.ApplyField(conf_key, target, _BYTES, const_fn=_code_table) + + def register_protocol_action( name: str, protocol: str, schema: cv.Schema, *fields: ProtocolField, validate: Callable[[ConfigType], ConfigType] | None = None, + call: str = "protocol_call", ) -> None: """Register ``transmit_`` with no C++ action class. One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields and sends it; the action is a core ``ApplyAction``. ``ApplyField`` and ``ApplyCall`` pass through and target the call object, whose protocol data is ``data``. + ``call="encode_call"`` targets a plain transmit call whose ``encode()`` runs one of the + protocol's encoders, for payloads it encodes straight from a pointer and length. """ action, schema, validator = _transmit_action(name, schema, validate) builder = automation.apply_action_builder( @@ -344,7 +359,7 @@ def register_protocol_action( automation.ApplyField( (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 ), - call=f"protocol_call<{ns.class_(f'{protocol}Protocol')}>", + call=f"{call}<{ns.class_(f'{protocol}Protocol')}>", id_key=CONF_TRANSMITTER_ID, ) @@ -2375,10 +2390,7 @@ register_protocol_action( # Midea -MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol( - "Midea" -) -MideaAction = ns.class_("MideaAction", RemoteTransmitterActionBase) +MideaData, MideaBinarySensor, MideaTrigger, _, MideaDumper = declare_protocol("Midea") MIDEA_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=5, max=5)), @@ -2401,26 +2413,17 @@ def midea_dumper(var, config): pass -async def _code_bytes( - value: Any, args: TemplateArgsType, set_template: MockObj, set_static: MockObj -) -> None: - """Constant codes share one PROGMEM table pool across the remote protocols.""" - await automation.templatable_bytes( - value, args, set_template, set_static, "remote_base_code" - ) - - -@register_action("midea", MideaAction, MIDEA_SCHEMA) -async def midea_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "midea", + "Midea", + MIDEA_SCHEMA, + _bytes_field(CONF_CODE, f"data = {MideaData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # AEHA -AEHAData, AEHABinarySensor, AEHATrigger, AEHAAction, AEHADumper = declare_protocol( - "AEHA" -) +AEHAData, AEHABinarySensor, AEHATrigger, _, AEHADumper = declare_protocol("AEHA") AEHA_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, @@ -2452,9 +2455,9 @@ def aeha_dumper(var, config): pass -@register_action( +register_protocol_action( "aeha", - AEHAAction, + "AEHA", AEHA_SCHEMA.extend( { cv.Optional(CONF_CARRIER_FREQUENCY, default="38000Hz"): cv.All( @@ -2462,15 +2465,18 @@ def aeha_dumper(var, config): ), } ), + automation.ApplyField( + CONF_CARRIER_FREQUENCY, "get_data()->set_carrier_frequency({})", cg.uint32 + ), + automation.ApplyCall( + "encode({}, {})", + ( + (CONF_ADDRESS, cg.uint16), + (CONF_DATA, _BYTES, _code_table), + ), + ), + call="encode_call", ) -async def aeha_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - await _code_bytes( - config[CONF_DATA], args, var.set_data_template, var.set_data_static - ) - templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) - cg.add(var.set_carrier_frequency(templ)) # Hob2Hood @@ -2529,10 +2535,7 @@ register_protocol_action( # Haier -HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol( - "Haier" -) -HaierAction = ns.class_("HaierAction", RemoteTransmitterActionBase) +HaierData, HaierBinarySensor, HaierTrigger, _, HaierDumper = declare_protocol("Haier") HAIER_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All( @@ -2562,11 +2565,13 @@ def haier_dumper(var, config): pass -@register_action("haier", HaierAction, HAIER_SCHEMA) -async def haier_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "haier", + "Haier", + HAIER_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # ABBWelcome @@ -2574,7 +2579,7 @@ async def haier_action(var, config, args): ABBWelcomeData, ABBWelcomeBinarySensor, ABBWelcomeTrigger, - ABBWelcomeAction, + _, ABBWelcomeDumper, ) = declare_protocol("ABBWelcome") @@ -2626,49 +2631,28 @@ def abbwelcome_dumper(var, config): pass -@register_action("abbwelcome", ABBWelcomeAction, ABB_WELCOME_SCHEMA) -async def abbwelcome_action(var, config, args): - cg.add( - var.set_three_byte_address( - await cg.templatable(config[CONF_THREE_BYTE_ADDRESS], args, cg.bool_) - ) - ) - cg.add( - var.set_source_address( - await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_destination_address( - await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_retransmission( - await cg.templatable(config[CONF_RETRANSMISSION], args, cg.bool_) - ) - ) - cg.add( - var.set_message_type( - await cg.templatable(config[CONF_MESSAGE_TYPE], args, cg.uint8) - ) - ) - template_ = await cg.templatable(CONF_MESSAGE_ID not in config, args, cg.bool_) - cg.add(var.set_auto_message_id(template_)) - if CONF_MESSAGE_ID in config: - cg.add( - var.set_message_id( - await cg.templatable(config[CONF_MESSAGE_ID], args, cg.uint8) - ) - ) - # TemplatableBytes starts in lambda mode with no function, so a transmit without data sets an empty payload - await automation.templatable_bytes( - config.get(CONF_DATA, []), - args, - var.set_data_template, - var.set_data_static, - "abbwelcome_data", - ) +register_protocol_action( + "abbwelcome", + "ABBWelcome", + ABB_WELCOME_SCHEMA, + # First: the address length decides where the other fields go + automation.ApplyField( + CONF_THREE_BYTE_ADDRESS, "data.set_three_byte_address({})", cg.bool_ + ), + automation.ApplyField( + CONF_SOURCE_ADDRESS, "data.set_source_address({})", cg.uint32 + ), + automation.ApplyField( + CONF_DESTINATION_ADDRESS, "data.set_destination_address({})", cg.uint32 + ), + automation.ApplyField(CONF_RETRANSMISSION, "data.set_retransmission({})", cg.bool_), + automation.ApplyField(CONF_MESSAGE_TYPE, "data.set_message_type({})", cg.uint8), + # Without message_id, finalize() picks one + automation.ApplyCall("data.auto_message_id = true"), + automation.ApplyField(CONF_MESSAGE_ID, "data.set_fixed_message_id({})", cg.uint8), + _bytes_field(CONF_DATA, "data.set_data({})"), + automation.ApplyCall("data.finalize()"), +) # Mirage @@ -2676,7 +2660,7 @@ async def abbwelcome_action(var, config, args): MirageData, MirageBinarySensor, MirageTrigger, - MirageAction, + _, MirageDumper, ) = declare_protocol("Mirage") @@ -2702,11 +2686,13 @@ def mirage_dumper(var, config): pass -@register_action("mirage", MirageAction, MIRAGE_SCHEMA) -async def mirage_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "mirage", + "Mirage", + MIRAGE_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # Toto diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 901950d3634..ecd27a95a56 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "remote_base.h" #include @@ -111,13 +112,19 @@ class ABBWelcomeData { return (this->data_[7] << 16) + (this->data_[8] << 8) + this->data_[9]; } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } + /// A configured id, which finalize() keeps instead of picking one + void set_fixed_message_id(uint8_t message_id) { + this->set_message_id(message_id); + this->auto_message_id = false; + } uint8_t get_message_id() const { return this->data_[4 + 2 * this->get_address_length()]; } + /// data may be a PROGMEM table void set_data(const uint8_t *data, size_t len) { uint8_t size = static_cast(std::min(MAX_DATA_LENGTH, len)); this->data_[2] &= (0xff ^ DATA_LENGTH_MASK); this->data_[2] |= (size & DATA_LENGTH_MASK); if (size) - std::copy_n(data, size, this->data_.begin() + 5 + 2 * this->get_address_length()); + progmem_memcpy(this->data_.data() + 5 + 2 * this->get_address_length(), data, size); } void set_data(const std::vector &data) { this->set_data(data.data(), data.size()); } std::vector get_data() const { @@ -226,30 +233,4 @@ class ABBWelcomeBinarySensor : public RemoteReceiverBinarySensorBase { using ABBWelcomeTrigger = RemoteReceiverTrigger; using ABBWelcomeDumper = RemoteReceiverDumper; -template class ABBWelcomeAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, source_address) - TEMPLATABLE_VALUE(uint32_t, destination_address) - TEMPLATABLE_VALUE(bool, retransmission) - TEMPLATABLE_VALUE(bool, three_byte_address) - TEMPLATABLE_VALUE(uint8_t, message_type) - TEMPLATABLE_VALUE(uint8_t, message_id) - TEMPLATABLE_VALUE(bool, auto_message_id) - TEMPLATABLE_BYTES(data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ABBWelcomeData data; - data.set_three_byte_address(this->three_byte_address_.value(x...)); - data.set_source_address(this->source_address_.value(x...)); - data.set_destination_address(this->destination_address_.value(x...)); - data.set_retransmission(this->retransmission_.value(x...)); - data.set_message_type(this->message_type_.value(x...)); - data.set_message_id(this->message_id_.value(x...)); - data.auto_message_id = this->auto_message_id_.value(x...); - this->data_.template visit( - [&data](const uint8_t *bytes, size_t len) { data.set_data(bytes, len); }, x...); - data.finalize(); - ABBWelcomeProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index 3263f6b2208..70454dbc497 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -1,4 +1,5 @@ #include "aeha_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -28,7 +29,7 @@ void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8 } for (size_t i = 0; i < len; i++) { - const uint8_t bit = data[i]; + const uint8_t bit = progmem_read_byte(data + i); for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { if (bit & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index f6064dd5df7..ab762612e17 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -18,7 +18,11 @@ class AEHAProtocol : public RemoteProtocol { void encode(RemoteTransmitData *dst, const AEHAData &data) { this->encode(dst, data.address, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, uint16_t address, const std::vector &data) { + this->encode(dst, address, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const AEHAData &data); @@ -28,18 +32,4 @@ class AEHAProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(AEHA) -template class AEHAAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_BYTES(data) - TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - - void encode(RemoteTransmitData *dst, Ts... x) override { - const uint16_t address = this->address_.value(x...); - dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - this->data_.visit( - [dst, address](const uint8_t *data, size_t len) { AEHAProtocol().encode(dst, address, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index e7fa563fb54..07e37935209 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -1,4 +1,5 @@ #include "haier_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -39,7 +40,7 @@ void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t dst->mark(BIT_MARK_US); uint8_t checksum = 0; for (size_t i = 0; i < len; i++) { - const uint8_t item = data[i]; + const uint8_t item = progmem_read_byte(data + i); this->encode_byte_(dst, item); checksum += item; } diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 770784b0fc5..05940494716 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -14,7 +14,11 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: void encode(RemoteTransmitData *dst, const HaierData &data) { this->encode(dst, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const HaierData &data); @@ -24,13 +28,4 @@ class HaierProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Haier) -template class HaierAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - this->code_.visit([dst](const uint8_t *data, size_t len) { HaierProtocol().encode(dst, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 19521e8f6d2..572179605a0 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -78,16 +78,4 @@ class MideaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Midea) -template class MideaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MideaData data = this->code_.is_static() ? MideaData(this->code_.data(), this->code_.size()) - : MideaData(this->code_.value(x...)); - data.finalize(); - MideaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index 23c6bd21e8f..099388f8ecf 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -1,7 +1,10 @@ #include "mirage_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::remote_base { ESPHOME_LOG_TAG(TAG, "remote.mirage"); @@ -17,8 +20,12 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { + // data may be a PROGMEM table, so log a RAM copy + uint8_t log_buf[MIRAGE_MAX_DATA_BYTES]; + const size_t log_len = std::min(len, MIRAGE_MAX_DATA_BYTES); + progmem_memcpy(log_buf, data, log_len); char hex_buf[format_hex_pretty_size(MIRAGE_MAX_DATA_BYTES)]; - ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data, len)); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, log_buf, log_len)); dst->set_carrier_frequency(38000); dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); @@ -26,7 +33,7 @@ void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t dst->mark(BIT_MARK_US); uint8_t checksum = 0; for (size_t i = 0; i < len; i++) { - const uint8_t item = data[i]; + const uint8_t item = progmem_read_byte(data + i); this->encode_byte_(dst, item); checksum += (item >> 4) + (item & 0xF); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index 923d412e841..ae6ed0a8e9a 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -16,7 +16,11 @@ class MirageProtocol : public RemoteProtocol { void encode(RemoteTransmitData *dst, const MirageData &data) { this->encode(dst, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const MirageData &data); @@ -26,13 +30,4 @@ class MirageProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Mirage) -template class MirageAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - this->code_.visit([dst](const uint8_t *data, size_t len) { MirageProtocol().encode(dst, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 38db90c97bc..3c5291ffb24 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,6 +182,11 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ +RemoteTransmitterBase::TransmitCall RemoteTransmitterBase::transmit() { + this->temp_.reset(); + return TransmitCall(this, this->take_seq_()); +} + void RemoteTransmitterBase::transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, uint32_t send_wait) { auto call = this->transmit(); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index fc2f3a71301..783343c5a20 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -187,10 +187,7 @@ class RemoteTransmitterBase : public RemoteComponentBase { uint16_t seq_; }; - TransmitCall transmit() { - this->temp_.reset(); - return TransmitCall(this, this->take_seq_()); - } + TransmitCall transmit(); template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmit_encoded_(&encode_thunk, &data, send_times, send_wait); @@ -213,6 +210,16 @@ class RemoteTransmitterBase : public RemoteComponentBase { template ProtocolCall protocol_call() { return ProtocolCall(this); } + /// A transmit call whose encode() runs one of Protocol's encoders on its data, for a payload + /// the protocol encodes straight from a pointer and length; the call a generated action builds. + template class EncodeCall : public TransmitCall { + public: + explicit EncodeCall(TransmitCall call) : TransmitCall(call) {} + template void encode(const Args &...args) { Protocol().encode(this->get_data(), args...); } + }; + template EncodeCall encode_call() { + return EncodeCall(this->transmit()); + } protected: using EncodeFn = void (*)(RemoteTransmitData *dst, const void *data); diff --git a/tests/component_tests/remote_transmitter/__init__.py b/tests/component_tests/remote_transmitter/__init__.py index 7b2d567ec7f..da300b47b13 100644 --- a/tests/component_tests/remote_transmitter/__init__.py +++ b/tests/component_tests/remote_transmitter/__init__.py @@ -3,7 +3,7 @@ import re _PROTOCOL_CALL_BODY = re.compile( - r"auto apply_call = \S+->protocol_call\(\);\n(.*?)apply_call\.perform\(\);", + r"auto apply_call = \S+->(?:protocol|encode)_call\(\);\n(.*?)apply_call\.perform\(\);", re.DOTALL, ) diff --git a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml new file mode 100644 index 00000000000..41eefdad7fa --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: ABBWelcome fixed id + on_press: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x123456 + destination_address: 0x56789A + three_byte_address: true + message_type: 0x01 + message_id: 0x42 + data: [0x10] + - platform: template + name: AEHA lambda + on_press: + - remote_transmitter.transmit_aeha: + address: !lambda return 0x8008; + carrier_frequency: 36700Hz + data: !lambda return {0x00, 0x02}; + - platform: template + name: Midea lambda + on_press: + - remote_transmitter.transmit_midea: + code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; diff --git a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py index a653b5137ef..ea1dbcf2944 100644 --- a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py +++ b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py @@ -9,13 +9,17 @@ def test_constant_data_shares_progmem_tables( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """Equal data shares one table, empty or missing data is an empty table, lambdas stay templates.""" + """Equal data shares one table, empty data is a null table, lambdas are called inline.""" main_cpp = generate_main(component_config_path("abbwelcome_payload_tables.yaml")) tables = re.findall( r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp ) assert [v for _, v in tables] == ["{0x10, 0x20, 0x30}"] - assert main_cpp.count(f"set_data_static({tables[0][0]}, 3);") == 2 - assert main_cpp.count("set_data_static(nullptr, 0);") == 2 - assert main_cpp.count("set_data_template(") == 1 + assert main_cpp.count(f"apply_call.data.set_data({tables[0][0]}, 3);") == 2 + # Empty data is a null table; missing data leaves the default empty payload + assert main_cpp.count("apply_call.data.set_data(nullptr, 0);") == 1 + assert ( + main_cpp.count("apply_call.data.set_data([]() -> std::vector {") == 1 + ) + assert "std::vector{" not in main_cpp diff --git a/tests/component_tests/remote_transmitter/test_byte_payloads.py b/tests/component_tests/remote_transmitter/test_byte_payloads.py new file mode 100644 index 00000000000..5bdd60cae0b --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_byte_payloads.py @@ -0,0 +1,33 @@ +"""The byte payload transmit actions as generated code.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_byte_payloads( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("byte_payloads.yaml")) + abb, aeha, midea = protocol_call_bodies(main_cpp) + # The address length goes first, a configured id turns the automatic one off + assert abb.index("set_three_byte_address(true);") < abb.index("set_source_address(") + assert abb.index("auto_message_id = true;") < abb.index( + "set_fixed_message_id(0x42);" + ) + assert abb.rstrip().endswith("apply_call.data.finalize();") + # The carrier is set before the payload is encoded + assert aeha.index("set_carrier_frequency(36700);") < aeha.index( + "apply_call.encode(" + ) + assert ( + "apply_call.encode(static_cast(0x8008), []() -> std::vector {" + in aeha + ) + assert ( + "apply_call.data = remote_base::MideaData([]() -> std::vector {" + in midea + ) + assert midea.rstrip().endswith("apply_call.data.finalize();") diff --git a/tests/component_tests/remote_transmitter/test_ir_codes.py b/tests/component_tests/remote_transmitter/test_ir_codes.py index 9be34941b61..8d08e52ffde 100644 --- a/tests/component_tests/remote_transmitter/test_ir_codes.py +++ b/tests/component_tests/remote_transmitter/test_ir_codes.py @@ -1,4 +1,4 @@ -"""Constant IR byte codes live in shared PROGMEM tables; lambdas stay lambdas.""" +"""Constant IR byte codes are encoded from shared PROGMEM tables; lambdas are called inline.""" from collections.abc import Callable from pathlib import Path @@ -16,9 +16,10 @@ def test_ir_codes_use_shared_progmem_tables( ) assert len(tables) == 4 # Midea (shared by two actions), AEHA, Haier, Mirage midea = tables[0] - assert main_cpp.count(f"->set_code_static({midea}, 5);") == 2 - assert re.search(r"->set_data_static\(remote_base_code\w*, 6\);", main_cpp) - assert re.search(r"->set_code_static\(remote_base_code\w*, 13\);", main_cpp) - assert re.search(r"->set_code_static\(remote_base_code\w*, 14\);", main_cpp) - assert "->set_code_template([](" in main_cpp + assert main_cpp.count(f"apply_call.data = remote_base::MideaData({midea}, 5);") == 2 + assert re.search(r"apply_call\.encode\(0x8008, remote_base_code\w*, 6\);", main_cpp) + assert re.search(r"apply_call\.encode\(remote_base_code\w*, 13\);", main_cpp) + assert re.search(r"apply_call\.encode\(remote_base_code\w*, 14\);", main_cpp) + assert "apply_call.encode([]() -> std::vector {" in main_cpp + # Constant codes are read from the table, never copied into a vector assert "std::vector{" not in main_cpp diff --git a/tests/components/remote_base/ir_code_encoders_test.cpp b/tests/components/remote_base/ir_code_encoders_test.cpp index bffea25864e..b003318a1f9 100644 --- a/tests/components/remote_base/ir_code_encoders_test.cpp +++ b/tests/components/remote_base/ir_code_encoders_test.cpp @@ -62,27 +62,15 @@ RawTimings with_trailing_space(RawTimings timings) { return timings; } -template RawTimings encode(A &action) { - RemoteTransmitData dst; - action.encode(&dst); - return dst.get_data(); -} - -// Exposes the protected encode() of an action for testing -template class Open : public A { - public: - using A::encode; -}; - -// A constant and a lambda code must both encode to the golden timings -template -void expect_static_and_lambda_match(const uint8_t *code, size_t len, std::vector (*lambda)(), - const RawTimings &golden) { - Open fixed, from_lambda; - fixed.set_code_static(code, len); - from_lambda.set_code_template(lambda); - EXPECT_EQ(encode(fixed), golden); - EXPECT_EQ(encode(from_lambda), golden); +// A constant table and a lambda's vector take the two encoder overloads generated actions call +template +void expect_pointer_and_vector_match(const uint8_t *code, size_t len, const std::vector &bytes, + const RawTimings &golden, const Lead &...lead) { + RemoteTransmitData fixed, from_vector; + P().encode(&fixed, lead..., code, len); + P().encode(&from_vector, lead..., bytes); + EXPECT_EQ(fixed.get_data(), golden); + EXPECT_EQ(from_vector.get_data(), golden); } template void expect_decodes_to(const RawTimings &timings, const std::vector &code) { @@ -94,25 +82,16 @@ template void expect_decodes_to(const RawTimings &timings, const std } // namespace -TEST(IrCodeEncodersTest, HaierStaticAndLambdaMatchGolden) { - expect_static_and_lambda_match>(HAIER_CODE, sizeof(HAIER_CODE), haier_code, HAIER_GOLDEN); +TEST(IrCodeEncodersTest, HaierPointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(HAIER_CODE, sizeof(HAIER_CODE), haier_code(), HAIER_GOLDEN); } -TEST(IrCodeEncodersTest, MirageStaticAndLambdaMatchGolden) { - expect_static_and_lambda_match>(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code, MIRAGE_GOLDEN); +TEST(IrCodeEncodersTest, MiragePointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code(), MIRAGE_GOLDEN); } -TEST(IrCodeEncodersTest, AehaStaticAndLambdaMatchGolden) { - Open> fixed, lambda; - for (auto *action : {&fixed, &lambda}) { - // TemplatableFn fields take functions, not raw constants - action->set_address([]() -> uint16_t { return AEHA_ADDRESS; }); - action->set_carrier_frequency([]() -> uint32_t { return 38000; }); - } - fixed.set_data_static(AEHA_CODE, sizeof(AEHA_CODE)); - lambda.set_data_template(aeha_code); - EXPECT_EQ(encode(fixed), AEHA_GOLDEN); - EXPECT_EQ(encode(lambda), AEHA_GOLDEN); +TEST(IrCodeEncodersTest, AehaPointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(AEHA_CODE, sizeof(AEHA_CODE), aeha_code(), AEHA_GOLDEN, AEHA_ADDRESS); } TEST(IrCodeEncodersTest, StructEncodersMatchGolden) { From 2dad6e4f626ae9a6d3a8d6b665bcf9a584be916c Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 13:15:13 -0400 Subject: [PATCH 373/435] [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 61f242d8caf..667e288fd72 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -345,6 +345,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): @@ -556,6 +562,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 f854da4f8d7a3e368d49218fff1db2fafcaf8e7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:15:51 -1000 Subject: [PATCH 374/435] [cli] Add --prompt-ota-key to present a different key for one upload (#19397) --- esphome/__main__.py | 143 ++++++++++-- esphome/util.py | 11 + tests/unit_tests/test_main.py | 411 ++++++++++++++++++++++++++++++++++ tests/unit_tests/test_util.py | 23 ++ 4 files changed, 575 insertions(+), 13 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 77c1c9f1ade..0c164893df2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -48,8 +48,11 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, + KEY_CORE, KEY_ESP32, + KEY_TARGET_PLATFORM, KEY_VARIANT, + PLATFORM_HOST, SECRETS_FILES, ErrorFormat, Toolchain, @@ -70,6 +73,7 @@ from esphome.util import ( get_serial_ports, is_picotool_usb_permission_error, list_yaml_files, + read_secret_line, run_external_command, run_external_process, safe_print, @@ -1232,14 +1236,28 @@ def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> tuple[int, str | None]: host = devices[0] + port_type = get_port_type(host) + ota_key = getattr(args, "ota_key", None) + if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL): + _LOGGER.warning( + "The presented OTA key only applies to network uploads; ignored for %s", + host, + ) + platform_upload = platform_hooks.get_platform_hook( CORE.target_platform, "upload_program" ) if platform_upload is not None and platform_upload(config, args, host): + if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL): + # A platform's own network path (nrf52 mcumgr) has no key handshake + _LOGGER.warning( + "The presented OTA key only applies to the %s OTA platform; " + "ignored for %s", + CONF_ESPHOME, + host, + ) return 0, host - port_type = get_port_type(host) - # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths. is_partition_table = getattr(args, "partition_table", False) @@ -1282,6 +1300,8 @@ def upload_program( network_devices = _resolve_network_devices(devices, config, args) if chosen_platform == CONF_WEB_SERVER: + if ota_key: + raise EsphomeError(OTA_KEY_PLATFORM_ERROR) if is_partition_table or is_bootloader: raise EsphomeError( f"{option_string} is only supported with the esphome OTA platform; " @@ -1295,6 +1315,17 @@ def upload_program( return _upload_via_native_api(config, network_devices, args) +def _ota_upload_platforms(config: ConfigType) -> dict[str, None]: + """The network OTA platforms the config provides, one entry each, in + config order; the web_server final-validate hook merges duplicates.""" + return { + ota_platform: None + for ota_item in config.get(CONF_OTA, []) + if (ota_platform := ota_item.get(CONF_PLATFORM)) + in (CONF_ESPHOME, CONF_WEB_SERVER) + } + + def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: """Pick the OTA platform to use, optionally honoring ``--ota-platform``. @@ -1307,16 +1338,7 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to ``web_server`` only when that is the only available platform. """ - # Use a dict (insertion-ordered) instead of a list so error messages and - # membership checks see one entry per platform even if the user has - # multiple ``ota:`` items of the same platform; the web_server OTA - # platform's final-validate hook merges duplicates anyway. - available: dict[str, None] = {} - for ota_item in config.get(CONF_OTA, []): - ota_platform = ota_item.get(CONF_PLATFORM) - if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER): - available[ota_platform] = None - + available = _ota_upload_platforms(config) if not available: raise EsphomeError( f"Cannot upload Over the Air as the {CONF_OTA} configuration is not " @@ -1356,7 +1378,12 @@ def _upload_via_native_api( noise_psk = None plaintext_fallback = False allow_plaintext_upload = False - if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: + if ota_key := getattr(args, "ota_key", None): + # The user presents a key the device still runs instead of the + # configured one (a key change in progress); an explicit key never + # downgrades to plaintext + noise_psk = ota_key + elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: noise_psk = encryption_conf.get(CONF_KEY) allow_plaintext_upload = bool( encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD) @@ -1738,7 +1765,60 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 +ENV_OTA_KEY = "ESPHOME_OTA_KEY" +OTA_KEY_PLATFORM_ERROR = ( + f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA " + "platform; to flash by serial instead, name the port with --device" +) + + +def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None: + """Take the key to present to the device for this upload, from the + prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is + not buried in build output.""" + prompt = getattr(args, "prompt_ota_key", False) + env_key = getattr(args, "env_ota_key", None) + if not prompt and env_key is None: + return + if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST: + _LOGGER.warning("A host program runs locally; the presented OTA key is ignored") + return + # The HTTP path has no key handshake; decide before prompting or + # compiling. A serial target is a serial flash: the key is not even + # asked for, and --ota-platform means nothing to it. Every entry must + # be serial: a missing port is skipped and the next entry may be a host + devices = getattr(args, "device", None) or [] + serial_target = bool(devices) and all( + device == "SERIAL" + or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL) + for device in devices + ) + chosen = ( + _choose_ota_platform(config, getattr(args, "ota_platform", None)) + if not serial_target and _ota_upload_platforms(config) + else None + ) + if chosen == CONF_WEB_SERVER: + raise EsphomeError(OTA_KEY_PLATFORM_ERROR) + from esphome.components.noise import validate_encryption_key + from esphome.config_validation import Invalid + + if serial_target: + _LOGGER.warning( + "The presented OTA key only applies to network uploads; ignored for %s", + ", ".join(devices), + ) + return + key = read_secret_line("OTA encryption key: ") if prompt else env_key + try: + validate_encryption_key(key) + except Invalid as err: + raise EsphomeError(f"Invalid OTA encryption key: {err}") from err + args.ota_key = key + + def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: + _read_ota_key(args, config) # Get devices, resolving special identifiers like OTA devices = choose_upload_log_host( default=args.device, @@ -1786,6 +1866,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # Elsewhere the flag is ignored, so serial stays fine there. _LOGGER.error("--skip-bootloader builds cannot be flashed over serial") return 1 + _read_ota_key(args, config) exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -2216,6 +2297,15 @@ def _error_format(value: str) -> ErrorFormat: ) from None +ARGUMENT_HELP_PROMPT_OTA_KEY = ( + "Ask for the OTA encryption key to present to the device instead of using " + "the one in the configuration, for a device that still runs a previous key. " + "The key is read without echo, or as one line from stdin when that is not " + "a terminal; it is never taken from the command line. A calling program " + f"can hand the key over in the {ENV_OTA_KEY} environment variable instead." +) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2384,6 +2474,11 @@ def parse_args(argv): help="Upload as partition table (OTA).", action="store_true", ) + parser_upload.add_argument( + "--prompt-ota-key", + action="store_true", + help=ARGUMENT_HELP_PROMPT_OTA_KEY, + ) parser_upload.add_argument( "--bootloader", help="Upload as bootloader (OTA).", @@ -2464,6 +2559,11 @@ def parse_args(argv): "(HTTP Basic auth) when that is the only configured platform." ), ) + parser_run.add_argument( + "--prompt-ota-key", + action="store_true", + help=ARGUMENT_HELP_PROMPT_OTA_KEY, + ) parser_clean = subparsers.add_parser( "clean-mqtt", @@ -2689,6 +2789,23 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + # Taken out of the environment before any command runs, so no build + # tool inherits a key a calling program handed over + args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None) + if args.command in ("upload", "run"): + # One key is one device's; several configs run in child processes + # that would never see it + if (args.env_ota_key is not None or args.prompt_ota_key) and len( + args.configuration + ) > 1: + raise EsphomeError( + f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; " + f"{len(args.configuration)} were given" + ) + elif args.env_ota_key is not None: + _LOGGER.warning( + "%s is set but the %s command does not use it", ENV_OTA_KEY, args.command + ) _warn_if_source_tree_mismatch() _warn_if_intel_macos() diff --git a/esphome/util.py b/esphome/util.py index dd1998a7c29..d090caeef11 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -130,6 +130,17 @@ def safe_input(prompt=""): return input() +def read_secret_line(prompt: str) -> str: + """Read a secret without echo on a terminal, or one line from a pipe.""" + if sys.stdin.isatty(): + import getpass + + return getpass.getpass(prompt).strip() + # Still say what is awaited, so a pipe held open is not a silent hang + print(prompt, end="", file=sys.stderr, flush=True) + return sys.stdin.readline().strip() + + def shlex_quote(s: str | Path) -> str: # Convert Path objects to strings if isinstance(s, Path): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3b8330f1591..3b10c9cb3c2 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _read_ota_key, _redact_with_legacy_fallback, _resolve_network_devices, _should_subscribe_states, @@ -44,6 +45,7 @@ from esphome.__main__ import ( command_idedata, command_run, command_update_all, + command_upload, command_wizard, compile_program, detect_external_components, @@ -126,6 +128,7 @@ from esphome.espota2 import ( ) from esphome.platformio import toolchain from esphome.types import ConfigType +from esphome.upload_targets import PortType from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1550,6 +1553,9 @@ class MockArgs: bootloader: bool = False states: bool | None = None device: list[str] | None = None + prompt_ota_key: bool = False + ota_key: str | None = None + env_ota_key: str | None = None def test_upload_program_serial_esp32( @@ -2223,6 +2229,384 @@ def test_upload_program_ota_allow_plaintext_upload( } +@pytest.mark.parametrize( + "ota_conf", + [ + { + CONF_ENCRYPTION: { + CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "allow_plaintext_upload": True, + } + }, + {}, + ], + ids=["with_encryption_block", "without_encryption_block"], +) +def test_upload_program_ota_prompted_key_is_presented( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, + ota_conf: dict[str, Any], +) -> None: + """A prompted key replaces whatever the config holds for this upload and + never downgrades, whatever the config allows.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + old_key = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + config = { + CONF_API: { + CONF_ENCRYPTION: {CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + }, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, **ota_conf}], + } + exit_code, _ = upload_program(config, MockArgs(ota_key=old_key), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[5] == old_key + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } + + +def test_upload_program_serial_ignores_prompted_key( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A serial upload has no handshake to present the key to.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = PortType.SERIAL + mock_upload_using_esptool.return_value = 0 + + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]} + with ( + patch("esphome.__main__.check_permissions"), + caplog.at_level(logging.WARNING), + ): + exit_code, _ = upload_program(config, MockArgs(ota_key="key"), ["/dev/ttyUSB0"]) + + assert exit_code == 0 + assert any("ignored for /dev/ttyUSB0" in r.message for r in caplog.records) + + +def test_command_run_prompts_for_ota_key_before_compile() -> None: + """The prompt comes first so it is not buried in minutes of build output.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, platform=PLATFORM_ESP32 + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + calls: list[str] = [] + + def read(prompt: str) -> str: + calls.append("prompt") + return key + + def compile_program(*_: Any) -> int: + calls.append("compile") + assert args.ota_key == key + return 1 # stop before the upload + + with ( + patch("esphome.__main__.read_secret_line", side_effect=read), + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", side_effect=compile_program), + ): + assert command_run(args, CORE.config) == 1 + assert calls == ["prompt", "compile"] + + +def test_upload_program_platform_hook_ignores_prompted_key( + mock_get_port_type: Mock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A platform's own network upload path (nrf52 mcumgr) has no key + handshake; the key is dropped with a warning, never silently.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]} + with ( + patch( + "esphome.__main__.platform_hooks.get_platform_hook", + return_value=lambda *_: True, + ), + caplog.at_level(logging.WARNING), + ): + exit_code, host = upload_program(config, MockArgs(ota_key="key"), ["dev.local"]) + + assert (exit_code, host) == (0, "dev.local") + assert any("ignored for dev.local" in r.message for r in caplog.records) + + +def test_command_upload_reads_the_ota_key_first() -> None: + """The upload command takes the key before choosing a device, like run.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, platform=PLATFORM_ESP32 + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + args.device = None + with ( + patch("esphome.__main__.read_secret_line", return_value=key), + patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]), + patch( + "esphome.__main__.upload_program", return_value=(0, "dev.local") + ) as upload, + ): + assert command_upload(args, CORE.config) == 0 + assert args.ota_key == key + upload.assert_called_once() + + +def test_read_ota_key_stores_prompted_key() -> None: + """The key lands on args and never in argv.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]} + with patch("esphome.__main__.read_secret_line", return_value=key) as read: + _read_ota_key(args, config) + read.assert_called_once_with("OTA encryption key: ") + assert args.ota_key == key + + +def test_read_ota_key_not_requested() -> None: + args = MockArgs() + with patch("esphome.__main__.read_secret_line") as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key is None + + +def test_read_ota_key_from_environment() -> None: + """A calling program hands the key over in the environment.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(env_ota_key=key) + with patch("esphome.__main__.read_secret_line") as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key == key + + +def test_read_ota_key_empty_environment_value_is_an_error() -> None: + """An exported empty value is a failed lookup upstream, not "unset".""" + args = MockArgs(env_ota_key="") + with pytest.raises(EsphomeError, match="Invalid OTA encryption key"): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + + +def test_read_ota_key_prompt_wins_over_environment() -> None: + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs( + prompt_ota_key=True, env_ota_key="AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + ) + with patch("esphome.__main__.read_secret_line", return_value=key): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + assert args.ota_key == key + + +def test_read_ota_key_without_ota_block_is_left_to_the_upload() -> None: + """A serial flash config has no OTA; the key is ignored with a warning + at upload time instead of refusing here.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + with patch("esphome.__main__.read_secret_line", return_value=key): + _read_ota_key(args, {}) + assert args.ota_key == key + + +@pytest.mark.parametrize( + "argv", + [ + ["esphome", "run", "--prompt-ota-key", "a.yaml", "b.yaml"], + ["esphome", "upload", "a.yaml", "b.yaml"], + ], + ids=["prompt", "environment"], +) +def test_run_esphome_refuses_one_key_for_several_configs( + monkeypatch: pytest.MonkeyPatch, argv: list[str] +) -> None: + """Several configs run in child processes that never see the key.""" + from esphome.__main__ import run_esphome + + if "--prompt-ota-key" not in argv: + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + with pytest.raises(EsphomeError, match="take one configuration; 2 were given"): + run_esphome(argv) + + +def test_run_esphome_only_warns_for_a_command_with_one_string_config( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The wizard takes one config as a string; only upload and run count.""" + from esphome.__main__ import run_esphome + + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + wizard = Mock(return_value=0) + with ( + patch.dict("esphome.__main__.PRE_CONFIG_ACTIONS", {"wizard": wizard}), + caplog.at_level(logging.WARNING), + ): + assert run_esphome(["esphome", "wizard", "living-room.yaml"]) == 0 + assert any("does not use it" in r.message for r in caplog.records) + + +def test_run_esphome_scrubs_the_key_from_the_environment( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Every command takes the key out before any tool it runs could see it.""" + from esphome.__main__ import run_esphome + + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + version = Mock(return_value=0) + with ( + patch.dict("esphome.__main__.PRE_CONFIG_ACTIONS", {"version": version}), + caplog.at_level(logging.WARNING), + ): + run_esphome(["esphome", "version"]) + assert "ESPHOME_OTA_KEY" not in os.environ + assert version.call_args.args[0].env_ota_key == "x" + # A command that never uploads says so rather than dropping it silently + assert any("does not use it" in r.message for r in caplog.records) + + +@pytest.mark.parametrize("line", ["", "not-base64"], ids=["empty", "invalid"]) +def test_read_ota_key_rejects_bad_input(line: str) -> None: + args = MockArgs(prompt_ota_key=True) + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]} + with ( + patch("esphome.__main__.read_secret_line", return_value=line), + pytest.raises(EsphomeError, match="Invalid OTA encryption key"), + ): + _read_ota_key(args, config) + assert args.ota_key is None + + +@pytest.mark.parametrize( + ("device", "port_type", "ota_platform"), + [ + ("/dev/ttyUSB0", PortType.SERIAL, None), + ("/dev/ttyUSB0", PortType.SERIAL, CONF_WEB_SERVER), + ("SERIAL", PortType.NETWORK, None), + ], + ids=["port", "port_with_ota_platform", "selector"], +) +def test_read_ota_key_lets_a_serial_target_through( + mock_get_port_type: Mock, + caplog: pytest.LogCaptureFixture, + device: str, + port_type: PortType, + ota_platform: str | None, +) -> None: + """A serial flash of a web_server only config is not refused, whatever + --ota-platform says; the key is not asked for and the user is told it is + ignored. The SERIAL selector only resolves to a port later, so it counts + by name.""" + mock_get_port_type.return_value = port_type + args = MockArgs(prompt_ota_key=True, ota_platform=ota_platform) + args.device = [device] + with ( + patch("esphome.__main__.read_secret_line") as read, + caplog.at_level(logging.WARNING), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}) + read.assert_not_called() + assert args.ota_key is None + assert any(f"ignored for {device}" in r.message for r in caplog.records) + + +def test_read_ota_key_serial_then_network_still_asks( + mock_get_port_type: Mock, +) -> None: + """`--device SERIAL --device OTA` with no port attached ends up on the + network, so the key is read and a web_server only config is refused.""" + mock_get_port_type.side_effect = lambda d: ( + PortType.SERIAL if d.startswith("/dev") else PortType.NETWORK + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + args.device = ["/dev/ttyUSB0", "OTA"] + with patch("esphome.__main__.read_secret_line", return_value=key) as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_called_once() + assert args.ota_key == key + args = MockArgs(prompt_ota_key=True) + args.device = ["SERIAL", "OTA"] + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only apply to the esphome OTA platform"), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}) + read.assert_not_called() + + +def test_read_ota_key_ignored_for_a_host_build( + caplog: pytest.LogCaptureFixture, +) -> None: + """A host run never uploads, so nothing is asked for.""" + setup_core(platform=PLATFORM_HOST) + args = MockArgs(prompt_ota_key=True) + with ( + patch("esphome.__main__.read_secret_line") as read, + caplog.at_level(logging.WARNING), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key is None + assert any("host program" in r.message for r in caplog.records) + + +def test_read_ota_key_reports_a_bad_platform_before_prompting() -> None: + args = MockArgs(prompt_ota_key=True, ota_platform=CONF_WEB_SERVER) + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only provides"), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + + +@pytest.mark.parametrize( + ("ota_platform", "config"), + [ + ( + CONF_WEB_SERVER, + { + CONF_OTA: [ + {CONF_PLATFORM: CONF_ESPHOME}, + {CONF_PLATFORM: CONF_WEB_SERVER}, + ] + }, + ), + (None, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}), + ], + ids=["requested", "only_platform"], +) +@pytest.mark.parametrize("devices", [["192.168.1.100"], None], ids=["host", "chooser"]) +def test_read_ota_key_refuses_web_server_platform( + mock_get_port_type: Mock, + ota_platform: str | None, + config: dict[str, Any], + devices: list[str] | None, +) -> None: + """The HTTP path has no key handshake, so the flag is refused before the + prompt and before any compile, whether web_server was asked for or is the + only platform in the config, for a network target or none named yet.""" + mock_get_port_type.return_value = PortType.NETWORK + args = MockArgs(prompt_ota_key=True, ota_platform=ota_platform) + args.device = devices + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only apply to the esphome OTA"), + ): + _read_ota_key(args, config) + read.assert_not_called() + + def test_upload_program_ota_api_key_opportunistic( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2889,6 +3273,22 @@ def test_upload_program_web_server_only_auto_dispatches( mock_run_ota.assert_not_called() +def test_upload_program_web_server_refuses_a_presented_key( + mock_run_web_server_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """A network target picked in the chooser reaches the HTTP path with the + key; it is refused rather than silently dropped.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + config = {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}], CONF_WEB_SERVER: {}} + args = MockArgs(ota_key="AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=") + with pytest.raises(EsphomeError, match="only apply to the esphome OTA"): + upload_program(config, args, ["192.168.1.100"]) + mock_run_web_server_ota.assert_not_called() + + def test_upload_program_web_server_no_auth( mock_run_web_server_ota: Mock, mock_get_port_type: Mock, @@ -6445,6 +6845,17 @@ def test_parse_args_run_states() -> None: assert args.states is True +@pytest.mark.parametrize("command", ["upload", "run"]) +def test_parse_args_prompt_ota_key(command: str) -> None: + """The flag takes no value: the key is never accepted on the command line.""" + args = parse_args(["esphome", command, "--prompt-ota-key", "device.yaml"]) + assert args.prompt_ota_key is True + args = parse_args(["esphome", command, "device.yaml"]) + assert args.prompt_ota_key is False + with pytest.raises(SystemExit): + parse_args(["esphome", command, "--prompt-ota-key=abc", "device.yaml"]) + + def test_parse_args_run_states_default() -> None: """Test that states defaults to None (unset) for the run command.""" args = parse_args(["esphome", "run", "device.yaml"]) diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index a4b091b7c24..739a0e2ada3 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -1177,3 +1177,26 @@ class TestSafePrint: assert ( capsys.readouterr().out == "Cannot print line because of invalid locale!\n" ) + + +@pytest.mark.parametrize("is_tty", [True, False], ids=["tty", "pipe"]) +def test_read_secret_line(is_tty: bool) -> None: + """A terminal reads without echo, a pipe reads one line; both strip.""" + with ( + patch("esphome.util.sys.stdin") as stdin, + patch("getpass.getpass", return_value=" secret \n") as getpass, + ): + stdin.isatty.return_value = is_tty + stdin.readline.return_value = " secret \r\n" + assert util.read_secret_line("Key: ") == "secret" + assert getpass.called is is_tty + assert stdin.readline.called is not is_tty + + +def test_read_secret_line_empty_pipe(capsys: pytest.CaptureFixture[str]) -> None: + """The prompt still goes to stderr on a pipe so a blocked read is diagnosable.""" + with patch("esphome.util.sys.stdin") as stdin: + stdin.isatty.return_value = False + stdin.readline.return_value = "" + assert util.read_secret_line("Key: ") == "" + assert capsys.readouterr().err == "Key: " From dc719b300b20b07fecebb295327cdbd24cec9036 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:16:29 -1000 Subject: [PATCH 375/435] [udp] Use the socket UDP API on ESP8266 and RP2040 (#20558) --- esphome/components/socket/socket.cpp | 9 +- esphome/components/socket/socket.h | 6 +- esphome/components/udp/automation.h | 2 +- esphome/components/udp/udp_component.cpp | 90 +++++++------------ esphome/components/udp/udp_component.h | 25 ++---- .../fixtures/udp_multicast_join.yaml | 22 +++++ tests/integration/test_udp_multicast.py | 75 ++++++++++++++++ 7 files changed, 149 insertions(+), 80 deletions(-) create mode 100644 tests/integration/fixtures/udp_multicast_join.yaml create mode 100644 tests/integration/test_udp_multicast.py diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index a91c455e0fd..36cd6d6ab7f 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -218,8 +218,9 @@ template static bool foreach_eligible_ipv6_if(F &&callback) { return found_any; #endif } +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS -bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out) { +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out) { if (strchr(ip_address, ':') == nullptr) { struct in_addr ipv4mc {}; #ifdef USE_ZEPHYR @@ -249,7 +250,8 @@ bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_ind } return true; } -#if USE_NETWORK_IPV6 + // The raw lwIP implementation has no IPv6 membership support +#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) struct ipv6_mreq imreq6 {}; #ifdef USE_SOCKET_IMPL_BSD_SOCKETS #ifdef USE_ZEPHYR @@ -300,9 +302,10 @@ bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_ind #else errno = EINVAL; return false; -#endif // USE_NETWORK_IPV6 +#endif // USE_NETWORK_IPV6 && fd based sockets } +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if USE_NETWORK_IPV6 bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { #if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8f5860e26e1..7310e32afb6 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -191,16 +191,16 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) /// Join a multicast group on the given socket. /// @param sock The socket to join on -/// @param ip_address Null-terminated multicast address string (IPv4 or IPv6) +/// @param ip_address Null-terminated multicast address string (IPv4, or IPv6 except on LWIP_TCP) /// @param if_index_out If non-null, receives the interface index used (IPv4 always writes 0). For /// link-local IPv6 groups, the caller must also copy this into sin6_scope_id /// before bind() -- POSIX requires it, LwIP ignores it. /// @return true on success, false on failure (errno set) -bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if USE_NETWORK_IPV6 /// Configure the outgoing IPv6 multicast interface on the given socket. /// @param sock The socket to configure diff --git a/esphome/components/udp/automation.h b/esphome/components/udp/automation.h index a6a40829b11..0936aa3ab50 100644 --- a/esphome/components/udp/automation.h +++ b/esphome/components/udp/automation.h @@ -13,7 +13,7 @@ template class UDPWriteAction final : public Action, publ TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - // One write per packet: WiFiUDP appends all or nothing, so a failed allocation never sends a partial packet + // One sendto per packet, so a failed allocation never sends a partial packet this->data_.template visit( [this](const uint8_t *data, size_t len) { this->parent_->send_packet(data, len); }, x...); } diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 3918ddc1fa8..8e824038341 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -8,9 +8,10 @@ namespace esphome::udp { ESPHOME_LOG_TAG(TAG, "udp"); +static constexpr uint32_t JOIN_POLL_ID = 0; +static constexpr uint32_t JOIN_POLL_INTERVAL_MS = 1000; void UDPComponent::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { const char *address = *it; struct sockaddr saddr {}; @@ -24,7 +25,7 @@ void UDPComponent::setup() { } // set up broadcast socket if (this->should_broadcast_) { - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -44,7 +45,7 @@ void UDPComponent::setup() { // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->listen_socket_ = socket::socket_udp(AF_INET, IPPROTO_IP); if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -69,22 +70,13 @@ void UDPComponent::setup() { server.sin_addr.s_addr = ESPHOME_INADDR_ANY; server.sin_port = htons(this->listen_port_); - if (this->listen_address_.has_value()) { - // Only 16 bytes needed for IPv4, but use standard size for consistency - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - this->listen_address_.value().str_to(addr_buf); - struct ip_mreq imreq = {}; - imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; - inet_aton(addr_buf, &imreq.imr_multiaddr); - server.sin_addr.s_addr = imreq.imr_multiaddr.s_addr; - ESP_LOGD(TAG, "Join multicast %s", addr_buf); - err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)); - if (err < 0) { - ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - this->status_set_error(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); - this->mark_failed(); - return; - } + if (this->listen_address_ != nullptr) { + inet_aton(this->listen_address_, &server.sin_addr); + // Joined once the network is up; RP2040 has no interface to join before then + this->set_interval(JOIN_POLL_ID, JOIN_POLL_INTERVAL_MS, [this]() { + if (network::is_connected() && this->join_multicast_()) + this->cancel_interval(JOIN_POLL_ID); + }); } err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); @@ -95,36 +87,35 @@ void UDPComponent::setup() { return; } } -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - // 8266 and RP2040 `Duino - for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { - const char *address = *it; - auto ipaddr = IPAddress(); - if (!ipaddr.fromString(address)) { - ESP_LOGW(TAG, "Invalid address %s", address); - this->status_set_warning(LOG_STR("invalid address")); - continue; +} + +bool UDPComponent::join_multicast_() { + if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_)) { + ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); + if (this->join_warning_) { + this->join_warning_ = false; + this->status_clear_warning(); } - this->ipaddrs_.push_back(ipaddr); + return true; } - if (this->should_listen_) - this->udp_client_.begin(this->listen_port_); -#endif + // Log the first failure only; the poll keeps retrying every second + if (!this->join_failed_) { + this->join_failed_ = true; + ESP_LOGW(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); + // One warning bit: only a warning raised here is cleared on success, one from setup() stays + if (!this->status_has_warning()) { + this->status_set_warning(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); + this->join_warning_ = true; + } + } + return false; } void UDPComponent::loop() { if (this->should_listen_) { std::array buf; for (;;) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) auto len = this->listen_socket_->read(buf.data(), buf.size()); -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto len = this->udp_client_.parsePacket(); - if (len > 0) - len = this->udp_client_.read(buf.data(), buf.size()); -#endif if (len <= 0) break; size_t packet_len = static_cast(len); @@ -144,9 +135,8 @@ void UDPComponent::dump_config() { const char *address = *it; ESP_LOGCONFIG(TAG, " Address: %s", address); } - if (this->listen_address_.has_value()) { - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_.value().str_to(addr_buf)); + if (this->listen_address_ != nullptr) { + ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_); } ESP_LOGCONFIG(TAG, " Broadcasting: %s\n" @@ -155,26 +145,12 @@ void UDPComponent::dump_config() { } void UDPComponent::send_packet(const uint8_t *data, size_t size) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) for (const auto &saddr : this->sockaddrs_) { auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr)); if (result < 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } } -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto iface = IPAddress(0, 0, 0, 0); - for (const auto &saddr : this->ipaddrs_) { - if (this->udp_client_.beginPacketMulticast(saddr, this->broadcast_port_, iface, 128) != 0) { - this->udp_client_.write(data, size); - auto result = this->udp_client_.endPacket(); - if (result == 0) { - ESP_LOGW(TAG, "udp.write() error"); - } - } - } -#endif } } // namespace esphome::udp diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 17e27fb7704..276fd5adf17 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -3,13 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_NETWORK #include "esphome/core/helpers.h" -#include "esphome/components/network/ip_address.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP -#include -#endif #include #include #include @@ -22,7 +16,8 @@ class UDPComponent final : public Component { public: /// nullptr-terminated table of addresses; must outlive the component. void set_addresses(const char *const *addresses) { this->addresses_ = addresses; } - void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } + /// Multicast group literal; must outlive the component. + void set_listen_address(const char *listen_addr) { this->listen_address_ = listen_addr; } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } @@ -36,24 +31,22 @@ class UDPComponent final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }; protected: + bool join_multicast_(); + uint16_t listen_port_{}; uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; + bool join_failed_{}; + bool join_warning_{}; CallbackManager)> packet_listeners_{}; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_ = nullptr; - std::unique_ptr listen_socket_ = nullptr; + std::unique_ptr broadcast_socket_ = nullptr; + std::unique_ptr listen_socket_ = nullptr; std::vector sockaddrs_{}; -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - std::vector ipaddrs_{}; - WiFiUDP udp_client_{}; -#endif const char *const *addresses_{nullptr}; - optional listen_address_{}; + const char *listen_address_{nullptr}; }; } // namespace esphome::udp diff --git a/tests/integration/fixtures/udp_multicast_join.yaml b/tests/integration/fixtures/udp_multicast_join.yaml new file mode 100644 index 00000000000..e2c6c350bfc --- /dev/null +++ b/tests/integration/fixtures/udp_multicast_join.yaml @@ -0,0 +1,22 @@ +esphome: + name: udp-multicast-test + +host: + +api: + +logger: + level: DEBUG + +udp: + - id: test_udp + listen_address: 239.1.2.3 + addresses: + - "239.1.2.3" + port: + listen_port: UDP_LISTEN_PORT_PLACEHOLDER + broadcast_port: UDP_LISTEN_PORT_PLACEHOLDER + on_receive: + - logger.log: + format: "Received UDP: %d bytes" + args: [data.size()] diff --git a/tests/integration/test_udp_multicast.py b/tests/integration/test_udp_multicast.py new file mode 100644 index 00000000000..98a90591421 --- /dev/null +++ b/tests/integration/test_udp_multicast.py @@ -0,0 +1,75 @@ +"""Integration test for the UDP component's deferred multicast join.""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +MULTICAST_GROUP = "239.1.2.3" + + +def _get_free_udp_port() -> int: + """Get a free UDP port by binding to port 0 and releasing.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +@pytest.mark.asyncio +async def test_udp_multicast_join( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The group is joined from the poll after setup, once, and then receives.""" + log_lines: list[str] = [] + joined_event = asyncio.Event() + receive_event = asyncio.Event() + + def on_log_line(line: str) -> None: + log_lines.append(line) + if f"Joined multicast {MULTICAST_GROUP}" in line: + joined_event.set() + if "Received UDP:" in line: + receive_event.set() + + listen_port = _get_free_udp_port() + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + + async with ( + run_compiled(config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "udp-multicast-test" + + try: + await asyncio.wait_for(joined_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail(f"Multicast group was not joined. Log: {log_lines[-20:]}") + + # The poll cancels itself after the join, so a second poll period must not join again + await asyncio.sleep(2.5) + join_lines = [line for line in log_lines if "Joined multicast" in line] + assert len(join_lines) == 1, f"Expected one join, got: {join_lines}" + assert not any("IP_ADD_MEMBERSHIP" in line for line in log_lines), ( + f"Join reported a failure. Log: {log_lines[-20:]}" + ) + + send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + send_sock.sendto(b"MULTICAST_PAYLOAD", (MULTICAST_GROUP, listen_port)) + finally: + send_sock.close() + + try: + await asyncio.wait_for(receive_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail(f"Multicast datagram was not received. Log: {log_lines[-20:]}") From 4c549d6e92c1ec52096e2c206796a768bc08d376 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:16:47 -1000 Subject: [PATCH 376/435] [e131] Use the socket UDP API on ESP8266 and RP2040 (#20559) --- esphome/components/e131/__init__.py | 18 +++++--- esphome/components/e131/e131.cpp | 24 +++++------ esphome/components/e131/e131.h | 25 ++--------- esphome/components/e131/e131_packet.cpp | 57 ++++++++++++++----------- 4 files changed, 59 insertions(+), 65 deletions(-) diff --git a/esphome/components/e131/__init__.py b/esphome/components/e131/__init__.py index dbe9b1e45c1..2d48f5a5937 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components import socket from esphome.components.light.effects import register_addressable_effect from esphome.components.light.types import AddressableLightEffect import esphome.config_validation as cv @@ -28,12 +29,17 @@ CHANNELS = { CONF_UNIVERSE = "universe" CONF_E131_ID = "e131_id" -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(E131Component), - cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(*METHODS, upper=True), - } -).extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(E131Component), + cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of( + *METHODS, upper=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + socket.consume_sockets(1, "e131", socket.SocketType.UDP), +) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index 09c854c6495..1d847963fec 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -1,6 +1,7 @@ #include "e131.h" #ifdef USE_NETWORK #include "e131_addressable_light_effect.h" +#include "esphome/components/network/util.h" #include "esphome/core/log.h" #include @@ -13,18 +14,18 @@ static const int PORT = 5568; E131Component::E131Component() {} E131Component::~E131Component() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) if (this->socket_) { this->socket_->close(); } -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - this->udp_.stop(); -#endif } void E131Component::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - this->socket_ = socket::socket_ip(SOCK_DGRAM, IPPROTO_IP); + this->socket_ = socket::socket_ip_udp(IPPROTO_IP); + if (!this->socket_) { + ESP_LOGW(TAG, "Could not create socket: errno %d", errno); + this->mark_failed(); + return; + } int enable = 1; int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); @@ -54,18 +55,13 @@ void E131Component::setup() { this->mark_failed(); return; } -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - if (!this->udp_.begin(PORT)) { - ESP_LOGW(TAG, "Cannot bind E1.31 to port %d.", PORT); - this->mark_failed(); - return; - } -#endif join_igmp_groups_(); } void E131Component::loop() { + if (this->igmp_join_pending_ && network::is_connected()) + this->join_igmp_groups_(); E131Packet packet; int universe = 0; uint8_t buf[1460]; @@ -74,7 +70,7 @@ void E131Component::loop() { // Drain all queued packets so multi-universe frames are applied // atomically before the light writes. Without this, each universe // packet would trigger a separate full-strip write causing tearing. - while ((len = this->read_(buf, sizeof(buf))) > 0) { + while ((len = this->socket_->read(buf, sizeof(buf))) > 0) { if (!this->packet_(buf, (size_t) len, universe, packet)) { ESP_LOGV(TAG, "Invalid packet received of size %d.", (int) len); continue; diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index b0a8b4f83fd..7d6983ddc3c 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -1,11 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) -#include -#endif #include "esphome/core/component.h" #include @@ -45,30 +41,17 @@ class E131Component final : public esphome::Component { void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; } protected: - inline ssize_t read_(uint8_t *buf, size_t len) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - return this->socket_->read(buf, len); -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - if (!this->udp_.parsePacket()) - return -1; - return this->udp_.read(buf, len); -#else - return -1; -#endif - } bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); bool process_(int universe, const E131Packet &packet); - bool join_igmp_groups_(); + void join_igmp_groups_(); + void join_universe_(uint16_t universe); UniverseConsumer *find_universe_(int universe); void join_(int universe); void leave_(int universe); E131ListenMethod listen_method_{E131_MULTICAST}; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr socket_; -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - WiFiUDP udp_; -#endif + bool igmp_join_pending_{false}; + std::unique_ptr socket_; std::vector light_effects_; std::vector universe_consumers_; }; diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index e3f2e3940af..14e3b64133d 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -3,6 +3,7 @@ #include "e131.h" #ifdef USE_NETWORK #include "esphome/components/network/ip_address.h" +#include "esphome/components/network/util.h" #include "esphome/core/log.h" #include "esphome/core/util.h" #include "esphome/core/helpers.h" @@ -59,33 +60,35 @@ union E131RawPacket { // Get the offset of `property_values[1]` const size_t E131_MIN_PACKET_SIZE = offsetof(E131RawPacket, property_values) + sizeof(uint8_t); -bool E131Component::join_igmp_groups_() { - if (this->listen_method_ != E131_MULTICAST) - return false; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - if (this->socket_ == nullptr) - return false; -#endif - +void E131Component::join_igmp_groups_() { + if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr) + return; + // Joined from loop() once the network is up; RP2040 has no interface to join before then + if (!network::is_connected()) { + this->igmp_join_pending_ = true; + return; + } + this->igmp_join_pending_ = false; for (auto &entry : this->universe_consumers_) { - if (!entry.consumers) - continue; + if (entry.consumers) + this->join_universe_(entry.universe); + } +} - ip4_addr_t multicast_addr = - network::IPAddress(239, 255, ((entry.universe >> 8) & 0xff), ((entry.universe >> 0) & 0xff)); +void E131Component::join_universe_(uint16_t universe) { + ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff)); - err_t err; - { - LwIPLock lock; - err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); - } - - if (err) { - ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", entry.universe); - } + err_t err; + { + LwIPLock lock; + err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); } - return true; + if (err) { + ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe); + } else { + ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe); + } } UniverseConsumer *E131Component::find_universe_(int universe) { @@ -107,9 +110,15 @@ void E131Component::join_(int universe) { this->universe_consumers_.push_back({static_cast(universe), 1}); } - if (this->join_igmp_groups_()) { - ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe); + if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr) + return; + // A pending batch join covers this universe too; joining it here as well would break leave_() + if (this->igmp_join_pending_ || !network::is_connected()) { + ESP_LOGD(TAG, "IGMP join for %d universe of E1.31 deferred until the network is up.", universe); + this->igmp_join_pending_ = true; + return; } + this->join_universe_(universe); } void E131Component::leave_(int universe) { From 5bb1f0f779b0d3068af05d2301d21d6a0806e146 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:17:01 -1000 Subject: [PATCH 377/435] [statsd] Use the socket UDP API on ESP8266 and RP2040 (#20557) --- esphome/components/statsd/__init__.py | 27 +++++++++++-------- esphome/components/statsd/statsd.cpp | 38 +++++---------------------- esphome/components/statsd/statsd.h | 15 ++--------- 3 files changed, 24 insertions(+), 56 deletions(-) diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index 16df864082d..0a0c4a59fb1 100644 --- a/esphome/components/statsd/__init__.py +++ b/esphome/components/statsd/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor +from esphome.components import binary_sensor, sensor, socket from esphome.components.const import CONF_HOST import esphome.config_validation as cv from esphome.const import ( @@ -35,16 +35,21 @@ CONFIG_BINARY_SENSORS_SCHEMA = cv.Schema( } ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(StatsdComponent), - cv.Required(CONF_HOST): cv.string_strict, - cv.Optional(CONF_PORT, default=8125): cv.port, - cv.Optional(CONF_PREFIX, default=""): cv.string_strict, - cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), - cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list(CONFIG_BINARY_SENSORS_SCHEMA), - } -).extend(cv.polling_component_schema("10s")) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(StatsdComponent), + cv.Required(CONF_HOST): cv.string_strict, + cv.Optional(CONF_PORT, default=8125): cv.port, + cv.Optional(CONF_PREFIX, default=""): cv.string_strict, + cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), + cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list( + CONFIG_BINARY_SENSORS_SCHEMA + ), + } + ).extend(cv.polling_component_schema("10s")), + socket.consume_sockets(1, "statsd", socket.SocketType.UDP), +) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 12af8345f13..1c9474bdb7b 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -13,28 +13,15 @@ static const uint16_t SEND_THRESHOLD = 1024; ESPHOME_LOG_TAG(TAG, "statsD"); void StatsdComponent::setup() { -#ifndef USE_ESP8266 - this->sock_ = esphome::socket::socket(AF_INET, SOCK_DGRAM, 0); - - struct sockaddr_in source; - source.sin_family = AF_INET; - source.sin_addr.s_addr = htonl(INADDR_ANY); - source.sin_port = htons(this->port_); - this->sock_->bind((struct sockaddr *) &source, sizeof(source)); + this->sock_ = esphome::socket::socket_udp_send(AF_INET, 0); + if (!this->sock_) { + this->mark_failed(LOG_STR("Could not create socket")); + return; + } this->destination_.sin_family = AF_INET; this->destination_.sin_port = htons(this->port_); this->destination_.sin_addr.s_addr = inet_addr(this->host_); -#endif -} - -StatsdComponent::~StatsdComponent() { -#ifndef USE_ESP8266 - if (!this->sock_) { - return; - } - this->sock_->close(); -#endif } void StatsdComponent::dump_config() { @@ -141,19 +128,7 @@ void StatsdComponent::update() { } void StatsdComponent::send_(std::string *out) { - if (out->empty()) { - return; - } -#ifdef USE_ESP8266 - IPAddress ip; - ip.fromString(this->host_); - - this->sock_.beginPacket(ip, this->port_); - this->sock_.write((const uint8_t *) out->c_str(), out->length()); - this->sock_.endPacket(); - -#else - if (!this->sock_) { + if (out->empty() || !this->sock_) { return; } @@ -162,7 +137,6 @@ void StatsdComponent::send_(std::string *out) { if (n_bytes != static_cast(out->length())) { ESP_LOGE(TAG, "Failed to send UDP packed (%d of %d)", n_bytes, out->length()); } -#endif } } // namespace esphome::statsd diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 7cbde6d7438..a12cc658106 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -20,17 +20,10 @@ #include "esphome/components/logger/logger.h" #endif -#ifdef USE_ESP8266 -#include "WiFiUdp.h" -#include "IPAddress.h" -#endif - namespace esphome::statsd { class StatsdComponent final : public PollingComponent { public: - ~StatsdComponent(); - void setup() override; void dump_config() override; void update() override; @@ -71,12 +64,8 @@ class StatsdComponent final : public PollingComponent { std::vector sensors_; -#ifdef USE_ESP8266 - WiFiUDP sock_; -#else - std::unique_ptr sock_; - struct sockaddr_in destination_; -#endif + std::unique_ptr sock_; + struct sockaddr_in destination_ {}; void send_(std::string *out); }; From db9138c5509b37b17a1097f559b17242fde396e7 Mon Sep 17 00:00:00 2001 From: Andrei Mateescu Date: Mon, 12 Oct 2026 20:34:25 +0300 Subject: [PATCH 378/435] [bl0939] Add current transformer measurement mode support (#13350) Co-authored-by: ShuishengPeng <63339353+ackPeng@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bl0939/bl0939.cpp | 6 ++- esphome/components/bl0939/bl0939.h | 37 +++++++++++++++++-- esphome/components/bl0939/sensor.py | 15 ++++++++ .../components/bl0939/test-ct.esp32-idf.yaml | 26 +++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 tests/components/bl0939/test-ct.esp32-idf.yaml diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 3a34fad1400..e8dc09abba7 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -129,7 +129,11 @@ void BL0939::received_package_(const DataPacket *data) const { } void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity) - ESP_LOGCONFIG(TAG, "BL0939:"); + ESP_LOGCONFIG(TAG, + "BL0939:\n" + " Mode: %s", + this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer") + : LOG_STR_LITERAL("direct")); LOG_SENSOR("", "Voltage", this->voltage_sensor_); LOG_SENSOR("", "Current 1", this->current_sensor_1_); LOG_SENSOR("", "Current 2", this->current_sensor_2_); diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index a22d96ad406..257d9459b5e 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -8,13 +8,32 @@ namespace esphome::bl0939 { // https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf // (unfortunately chinese, but the formulas can be easily understood) +// The datasheet has one set of formulas for direct mode (current shunt) and one for current transformer mode. + +// Direct mode. // Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm) // and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm) // as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V) -static const float BL0939_IREF = 324004 * 1 / 1.218; -static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51)); -static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51)); -static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_IREF = 324004 * 1 / 1.218; +static constexpr float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51)); + +// Current transformer mode. +// Values from the Seeed Studio XIAO 2-Channel Wi-Fi AC Energy Meter V1.0 schematic (Vref=1.218V). +// Current: 100A:50mA CT (2000:1) into a 0.6Ohm burden (2 * 0.3Ohm), so 0.3mV per primary amp. +// That is the same as a 0.3mOhm shunt in the direct mode formula. +// Voltage: ZMPT107-1 2mA:2mA voltage transformer with R2=5*22kOhm on the primary and R1=24.9Ohm on +// the secondary. The transformer isolates R1 from R2, so the divider is R1/R2 instead of R1/(R1+R2). +static constexpr float BL0939_IREF_CT = 324004 * 0.3 / 1.218; +static constexpr float BL0939_UREF_CT = 79931 * 24.9 * 1000 / (1.218 * 5 * 22000); +static constexpr float BL0939_PREF_CT = 4046 * 0.3 * 24.9 * 1000 / (1.218 * 1.218 * 5 * 22000); +static constexpr float BL0939_EREF_CT = 3.6e6 * 4046 * 0.3 * 24.9 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * 5 * 22000); + +enum class BL0939Mode : uint8_t { + BL0939_MODE_DIRECT = 0, + BL0939_MODE_CURRENT_TRANSFORMER = 1, +}; struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) uint8_t l; @@ -67,6 +86,15 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; } void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; } void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; } + // Inline so a constant mode from codegen folds down to plain stores. + void set_mode(BL0939Mode mode) { + this->mode_ = mode; + const bool current_transformer = mode == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER; + this->power_reference_ = current_transformer ? BL0939_PREF_CT : BL0939_PREF; + this->voltage_reference_ = current_transformer ? BL0939_UREF_CT : BL0939_UREF; + this->current_reference_ = current_transformer ? BL0939_IREF_CT : BL0939_IREF; + this->energy_reference_ = current_transformer ? BL0939_EREF_CT : BL0939_EREF; + } void loop() override; @@ -95,6 +123,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { float current_reference_ = BL0939_IREF; // Divide by this to turn into kWh float energy_reference_ = BL0939_EREF; + BL0939Mode mode_{BL0939Mode::BL0939_MODE_DIRECT}; static uint32_t to_uint32_t(ube24_t input); diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 401fafedd5e..248002d185f 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INTERNAL_TEMPERATURE, + CONF_MODE, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -33,11 +34,22 @@ CONF_ENERGY_TOTAL = "energy_total" bl0939_ns = cg.esphome_ns.namespace("bl0939") BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice) +BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True) +MODE_OPTIONS = { + "direct": BL0939Mode.BL0939_MODE_DIRECT, + "current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER, +} +# Must match the mode_ member initializer in bl0939.h; set_mode() is skipped +# when the config already equals it. +DEFAULT_MODE = "direct" CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(BL0939), + cv.Optional(CONF_MODE, default=DEFAULT_MODE): cv.enum( + MODE_OPTIONS, lower=True + ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, @@ -105,6 +117,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.set_mode(mode)) + sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) await sensors(CONF_CURRENT_1, var.set_current_sensor_1) diff --git a/tests/components/bl0939/test-ct.esp32-idf.yaml b/tests/components/bl0939/test-ct.esp32-idf.yaml new file mode 100644 index 00000000000..8401f221636 --- /dev/null +++ b/tests/components/bl0939/test-ct.esp32-idf.yaml @@ -0,0 +1,26 @@ +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO14 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +sensor: + - platform: bl0939 + mode: current_transformer + voltage: + name: BL0939 Voltage + current_1: + name: BL0939 Current 1 + current_2: + name: BL0939 Current 2 + active_power_1: + name: BL0939 Active Power 1 + active_power_2: + name: BL0939 Active Power 2 + energy_1: + name: BL0939 Energy 1 + energy_2: + name: BL0939 Energy 2 + energy_total: + name: BL0939 Total energy From 99b61760c3e30cc1fa07b106437895d36ff0f71c Mon Sep 17 00:00:00 2001 From: Michael Smith <2015782+mikesmitty@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:37:31 -0400 Subject: [PATCH 379/435] [remote_base] Add the Pioneer WYT transmit protocol (#17175) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 18 ++++++ .../remote_base/pioneer_wyt_protocol.cpp | 30 ++++++++++ .../remote_base/pioneer_wyt_protocol.h | 55 +++++++++++++++++ esphome/core/defines.h | 1 + .../config/byte_payloads.yaml | 5 ++ .../remote_transmitter/test_byte_payloads.py | 8 ++- tests/components/remote_base/__init__.py | 1 + .../remote_base/pioneer_wyt_test.cpp | 60 +++++++++++++++++++ .../remote_transmitter/common-buttons.yaml | 12 ++++ .../remote_base/test_pioneer_wyt.py | 56 +++++++++++++++++ 10 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/pioneer_wyt_protocol.cpp create mode 100644 esphome/components/remote_base/pioneer_wyt_protocol.h create mode 100644 tests/components/remote_base/pioneer_wyt_test.cpp create mode 100644 tests/unit_tests/components/remote_base/test_pioneer_wyt.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 45873f2385c..e347ee96ffb 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1228,6 +1228,24 @@ register_protocol_action( ) +# PioneerWyt +PioneerWytData = ns.class_("PioneerWytData") +PIONEER_WYT_SCHEMA = cv.Schema( + { + # 13 bytes get the checksum appended; a 14th byte is replaced by the recomputed checksum + cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=13, max=14)), + } +) + +register_protocol_action( + "pioneer_wyt", + "PioneerWyt", + PIONEER_WYT_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PioneerWytData}({{}})"), + automation.ApplyCall("data.finalize()"), +) + + # Pronto ( ProntoData, diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.cpp b/esphome/components/remote_base/pioneer_wyt_protocol.cpp new file mode 100644 index 00000000000..0c7d6c7ec92 --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.cpp @@ -0,0 +1,30 @@ +#include "pioneer_wyt_protocol.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pioneer_wyt"); + +// The timings and frame header are shared with tcl112 +static constexpr uint32_t HEADER_MARK_US = 3100; +static constexpr uint32_t HEADER_SPACE_US = 1650; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t BIT_ONE_SPACE_US = 1100; +static constexpr uint32_t BIT_ZERO_SPACE_US = 350; + +void PioneerWytProtocol::encode(RemoteTransmitData *dst, const PioneerWytData &data) { + char hex_buf[format_hex_pretty_size(PioneerWytData::SIZE)]; + ESP_LOGD(TAG, "Transmit PioneerWyt: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); + dst->set_carrier_frequency(38000); + // header mark and space, a mark and space per bit, then the trailing mark + dst->reserve(dst->get_data().size() + 3 + PioneerWytData::SIZE * 8 * 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t i = 0; i < data.size(); i++) { + for (uint8_t b = 0; b < 8; b++) + dst->item(BIT_MARK_US, (data[i] & (1 << b)) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->mark(BIT_MARK_US); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.h b/esphome/components/remote_base/pioneer_wyt_protocol.h new file mode 100644 index 00000000000..9946d4230b4 --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PioneerWytData { + public: + static constexpr uint8_t SIZE = 14; + enum PioneerWytDataType : uint8_t { + PIONEER_WYT_TYPE_GENERAL = 0x01, + PIONEER_WYT_TYPE_FAN = 0x02, + }; + + // Make default + PioneerWytData() {} + // Make from initializer_list + PioneerWytData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + // Make from vector + PioneerWytData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PioneerWytData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + uint8_t size() const { return this->data_.size(); } + // FAN frames add 0x0F to the byte sum + void finalize() { + this->data_[OFFSET_CS] = + sum_bytes(this->data_.data(), OFFSET_CS, this->type() == PIONEER_WYT_TYPE_FAN ? 0x0F : 0x00); + } + PioneerWytDataType type() const { return static_cast(this->data_[3]); } + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + static constexpr uint8_t OFFSET_CS = SIZE - 1; + std::array data_{}; +}; + +class PioneerWytProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PioneerWytData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7c3c7d2861b..46584389341 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -182,6 +182,7 @@ #define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC #define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PIONEER_WYT #define USE_REMOTE_PROTOCOL_PRONTO #define USE_REMOTE_PROTOCOL_RAW #define USE_REMOTE_PROTOCOL_RC5 diff --git a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml index 41eefdad7fa..106ac129e2d 100644 --- a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml +++ b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml @@ -31,3 +31,8 @@ button: on_press: - remote_transmitter.transmit_midea: code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; + - platform: template + name: Pioneer WYT lambda + on_press: + - remote_transmitter.transmit_pioneer_wyt: + code: !lambda return {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80}; diff --git a/tests/component_tests/remote_transmitter/test_byte_payloads.py b/tests/component_tests/remote_transmitter/test_byte_payloads.py index 5bdd60cae0b..0cdde16d4fc 100644 --- a/tests/component_tests/remote_transmitter/test_byte_payloads.py +++ b/tests/component_tests/remote_transmitter/test_byte_payloads.py @@ -11,7 +11,7 @@ def test_byte_payloads( component_config_path: Callable[[str], Path], ) -> None: main_cpp = generate_main(component_config_path("byte_payloads.yaml")) - abb, aeha, midea = protocol_call_bodies(main_cpp) + abb, aeha, midea, pioneer_wyt = protocol_call_bodies(main_cpp) # The address length goes first, a configured id turns the automatic one off assert abb.index("set_three_byte_address(true);") < abb.index("set_source_address(") assert abb.index("auto_message_id = true;") < abb.index( @@ -31,3 +31,9 @@ def test_byte_payloads( in midea ) assert midea.rstrip().endswith("apply_call.data.finalize();") + # A 13-byte code is copied into the frame and the checksum is appended by finalize() + assert ( + "apply_call.data = remote_base::PioneerWytData([]() -> std::vector {" + in pioneer_wyt + ) + assert pioneer_wyt.rstrip().endswith("apply_call.data.finalize();") diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 36060d079ce..2fe959bb161 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -17,6 +17,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "nec", "magiquest", "rc5x", + "pioneer_wyt", "drayton", "panasonic", ): diff --git a/tests/components/remote_base/pioneer_wyt_test.cpp b/tests/components/remote_base/pioneer_wyt_test.cpp new file mode 100644 index 00000000000..38543565465 --- /dev/null +++ b/tests/components/remote_base/pioneer_wyt_test.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include "esphome/components/remote_base/pioneer_wyt_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +using Frame = std::array; + +// Factory remote capture: cool mode, 26°C, beeper on, display on; the last byte is the checksum +constexpr Frame COOL_26_CAPTURE = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1}; + +Frame bytes(const PioneerWytData &data) { + Frame out{}; + std::copy_n(data.data(), out.size(), out.begin()); + return out; +} + +} // namespace + +TEST(PioneerWytProtocolTest, RawCodeGetsTheCaptureChecksum) { + PioneerWytData data(std::vector(COOL_26_CAPTURE.begin(), COOL_26_CAPTURE.end() - 1)); + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +TEST(PioneerWytProtocolTest, GivenChecksumIsRecomputed) { + PioneerWytData data(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size()); + data[PioneerWytData::SIZE - 1] = 0x00; + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +// FAN frames add 0x0F to the byte sum +TEST(PioneerWytProtocolTest, FanFrameChecksumHasOffset) { + PioneerWytData data{0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, 0x10, 0xC0, 0x00, 0x00, 0x00, 0x00}; + EXPECT_EQ(data.type(), PioneerWytData::PIONEER_WYT_TYPE_FAN); + data.finalize(); + EXPECT_EQ(data[PioneerWytData::SIZE - 1], 0xF5); +} + +TEST(PioneerWytProtocolTest, EncodesHeaderAndAllBits) { + RemoteTransmitData dst; + PioneerWytProtocol().encode(&dst, PioneerWytData(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size())); + // header mark and space, a mark and space per bit, then the trailing mark + const auto &raw = dst.get_data(); + ASSERT_EQ(raw.size(), 3u + PioneerWytData::SIZE * 8 * 2); + EXPECT_EQ(dst.get_carrier_frequency(), 38000u); + EXPECT_EQ(raw[0], 3100); + EXPECT_EQ(raw[1], -1650); + // 0x23 LSB first: bit 0 is one, bit 2 is zero + EXPECT_EQ(raw[3], -1100); + EXPECT_EQ(raw[7], -350); + EXPECT_EQ(raw.back(), 500); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 7fa8cd1132d..dff4e8f9c5d 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -127,6 +127,18 @@ button: rc_code_2: 0xA506 repeat: times: 2 + - platform: template + name: Pioneer WYT + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Captured from the factory remote: cool mode, 26°C. The encoder appends the checksum. + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80] + - platform: template + name: Pioneer WYT With Checksum + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Same capture with its checksum byte included + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1] - platform: template name: RC Switch Raw on_press: diff --git a/tests/unit_tests/components/remote_base/test_pioneer_wyt.py b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py new file mode 100644 index 00000000000..fd9e5afe4a3 --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py @@ -0,0 +1,56 @@ +"""Schema checks for the pioneer_wyt transmit action.""" + +import pytest + +from esphome.automation import ACTION_REGISTRY +import esphome.config_validation as cv +from esphome.core import Lambda + +# Factory remote capture: cool mode, 26°C, without the trailing checksum byte +RAW_CODE = [ + 0x23, + 0xCB, + 0x26, + 0x01, + 0x00, + 0x24, + 0x03, + 0x05, + 0x00, + 0x00, + 0x00, + 0x00, + 0x80, +] +RAW_CODE_CHECKSUM = 0xC1 + + +@pytest.fixture +def schema() -> cv.Schema: + return ACTION_REGISTRY["remote_transmitter.transmit_pioneer_wyt"].raw_schema + + +def test_code_checksum_matches_capture() -> None: + assert sum(RAW_CODE) & 0xFF == RAW_CODE_CHECKSUM + + +def test_code_accepts_with_and_without_checksum_byte(schema: cv.Schema) -> None: + with_checksum = [*RAW_CODE, RAW_CODE_CHECKSUM] + assert schema({"code": RAW_CODE})["code"] == RAW_CODE + assert schema({"code": with_checksum})["code"] == with_checksum + + +@pytest.mark.parametrize("code", [RAW_CODE[:-1], [*RAW_CODE, RAW_CODE_CHECKSUM, 0x00]]) +def test_code_rejects_other_lengths(schema: cv.Schema, code: list[int]) -> None: + with pytest.raises(cv.Invalid): + schema({"code": code}) + + +def test_code_accepts_lambda(schema: cv.Schema) -> None: + code = Lambda("return {0x23, 0xCB};") + assert isinstance(schema({"code": code})["code"], Lambda) + + +def test_code_is_required(schema: cv.Schema) -> None: + with pytest.raises(cv.Invalid): + schema({}) From af8a0739f052c4e6ddac0179d0490e24dc552811 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:46:11 -1000 Subject: [PATCH 380/435] [web_server] Self contained AP mode: embedded interface served as a captive portal (#18517) --- esphome/components/captive_portal/__init__.py | 28 +-- .../captive_portal/captive_portal.cpp | 29 +-- .../captive_portal/captive_portal.h | 28 +-- esphome/components/web_server/__init__.py | 132 +++++++++++++- esphome/components/web_server/web_server.cpp | 61 ++++++- esphome/components/web_server/web_server.h | 17 ++ .../components/web_server_base/__init__.py | 31 +++- .../components/web_server_base/captive_dns.h | 63 +++++++ .../dns_server_esp32_idf.cpp | 17 +- .../dns_server_esp32_idf.h | 13 +- .../web_server_idf/web_server_idf.cpp | 4 +- .../web_server_idf/web_server_idf.h | 3 +- esphome/components/wifi/wifi_component.cpp | 78 +++++--- esphome/components/wifi/wifi_component.h | 6 +- .../wifi/wifi_component_esp_idf.cpp | 27 ++- esphome/core/defines.h | 3 + .../web_server/common-ap-fallback.yaml | 18 ++ .../components/web_server/common-ap-mode.yaml | 9 + .../test-ap-fallback.esp32-idf.yaml | 2 + .../web_server/test-ap-mode.esp32-idf.yaml | 2 + .../web_server/test-ap-mode.esp8266-ard.yaml | 2 + .../unit_tests/components/test_web_server.py | 172 ++++++++++++++++++ 22 files changed, 600 insertions(+), 145 deletions(-) create mode 100644 esphome/components/web_server_base/captive_dns.h rename esphome/components/{captive_portal => web_server_base}/dns_server_esp32_idf.cpp (93%) rename esphome/components/{captive_portal => web_server_base}/dns_server_esp32_idf.h (58%) create mode 100644 tests/components/web_server/common-ap-fallback.yaml create mode 100644 tests/components/web_server/common-ap-mode.yaml create mode 100644 tests/components/web_server/test-ap-fallback.esp32-idf.yaml create mode 100644 tests/components/web_server/test-ap-mode.esp32-idf.yaml create mode 100644 tests/components/web_server/test-ap-mode.esp8266-ard.yaml create mode 100644 tests/unit_tests/components/test_web_server.py diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 3bdea03bf52..8187dac0082 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -4,7 +4,6 @@ import esphome.codegen as cg from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.components.wifi import DOMAIN as WIFI_DOMAIN -from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_AP, @@ -16,7 +15,6 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2, PLATFORM_RTL87XX, - PlatformFramework, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -78,17 +76,7 @@ def _final_validate(config: ConfigType) -> None: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) - # Register socket needs for DNS server and additional HTTP connections - # - 1 UDP socket for DNS server - # - 3 TCP sockets for captive portal detection probes + configuration requests - # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. - # Need headroom for actual user configuration requests. - # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. - # The listening socket is registered by web_server_base (shared HTTP server). - from esphome.components import socket - - socket.consume_sockets(3, "captive_portal")(config) - socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) + web_server_base.consume_captive_dns_sockets(config, DOMAIN) FINAL_VALIDATE_SCHEMA = _final_validate @@ -108,16 +96,4 @@ async def to_code(config: ConfigType) -> None: if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): - cg.add_library("DNSServer", None) - - -# Only compile the ESP-IDF DNS server when using ESP-IDF framework -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "dns_server_esp32_idf.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - } -) + web_server_base.add_captive_dns_library() diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 36e9ec406d1..0aa48bc3ea0 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,9 +6,6 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/scan_list.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_PROVISIONING -#include "esphome/components/provisioning/provisioning.h" -#endif #include "captive_index.h" namespace esphome::captive_portal { @@ -81,20 +78,6 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { void CaptivePortal::setup() { // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); -#ifdef USE_PROVISIONING - // The captive portal is a provisioning surface: once the provisioning window - // has closed, stop serving it. WiFi's own closed-callback shuts down the - // access point the portal runs on, and the gated fallback in WiFiComponent's - // loop() ensures neither is started again afterwards. - if (provisioning::global_provisioning_manager != nullptr) { - provisioning::global_provisioning_manager->add_on_closed_callback([this]() { - if (this->active_) { - ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal"); - this->end(); - } - }); - } -#endif } void CaptivePortal::start() { this->base_->init(); @@ -102,17 +85,7 @@ void CaptivePortal::start() { this->base_->add_handler_without_auth(this); } - network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - -#if defined(USE_ESP32) - // Create DNS server instance for ESP-IDF - this->dns_server_ = make_unique(); - this->dns_server_->start(ip); -#elif defined(USE_ARDUINO) - this->dns_server_ = make_unique(); - this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - this->dns_server_->start(53, ESPHOME_F("*"), ip); -#endif + this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip()); this->initialized_ = true; this->active_ = true; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index b47af9d978a..013ba25b5fb 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -1,16 +1,11 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_CAPTIVE_PORTAL -#include -#if defined(USE_ESP32) -#include "dns_server_esp32_idf.h" -#elif defined(USE_ARDUINO) -#include -#endif #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.h" namespace esphome::captive_portal { @@ -19,17 +14,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { CaptivePortal(web_server_base::WebServerBase *base); void setup() override; void dump_config() override; - void loop() override { -#if defined(USE_ESP32) - if (this->dns_server_ != nullptr) { - this->dns_server_->process_next_request(); - } -#elif defined(USE_ARDUINO) - if (this->dns_server_ != nullptr) { - this->dns_server_->processNextRequest(); - } -#endif - } + void loop() override { this->dns_.loop(); } float get_setup_priority() const override; void start(); bool is_active() const { return this->active_; } @@ -37,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { this->active_ = false; this->disable_loop(); // Stop processing DNS requests this->base_->deinit(); - if (this->dns_server_ != nullptr) { - this->dns_server_->stop(); - this->dns_server_ = nullptr; - } + this->dns_.stop(); } bool canHandle(AsyncWebServerRequest *request) const override { @@ -60,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { web_server_base::WebServerBase *base_; bool initialized_{false}; bool active_{false}; -#if defined(USE_ARDUINO) || defined(USE_ESP32) - std::unique_ptr dns_server_{nullptr}; -#endif + web_server_base::CaptiveDNS dns_; }; extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 4f233e99655..c48c72c06e4 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -13,6 +13,7 @@ from esphome.components.logger import request_log_listener from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import ( + CONF_AP, CONF_AUTH, CONF_COMPRESSION, CONF_CSS_INCLUDE, @@ -24,15 +25,19 @@ from esphome.const import ( CONF_JS_URL, CONF_LOCAL, CONF_LOG, + CONF_MANUAL_IP, CONF_NAME, + CONF_NETWORKS, CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_STATIC_IP, CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, CONF_WEB_SERVER_ID, + CONF_WIFI, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -47,7 +52,21 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["json", "web_server_base"] + +def AUTO_LOAD() -> list[str]: + # No config parameter: a dynamic auto-load would satisfy ota.web_server's dependency too late + auto_load = ["json", "web_server_base"] + # The AP mode DNS server needs socket; CORE.raw_config already has a wifi block from a package + wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config else None + if ( + CORE.is_esp32 + and wifi is not None + and (not isinstance(wifi, dict) or CONF_AP in wifi) + ): + auto_load.append("socket") + return auto_load + + DOMAIN = "web_server" AUTH_TYPE_BASIC = "basic" @@ -211,9 +230,6 @@ def _final_validate_sorting(config: ConfigType) -> None: ) -FINAL_VALIDATE_SCHEMA = _final_validate_sorting - - def _consume_web_server_sockets(config: ConfigType) -> ConfigType: """Register socket needs for web_server component.""" from esphome.components import socket @@ -340,6 +356,108 @@ async def add_entity_config(entity: MockObj, config: ConfigType) -> None: ) +def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool: + """AP only: an access point and no network to join, so the device is only ever reached + through its own AP.""" + return ( + wifi_config is not None + and CONF_AP in wifi_config + and not wifi_config.get(CONF_NETWORKS) + ) + + +def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool: + """Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet + for the hosted page. Version 1 has no local mode.""" + if (local := config.get(CONF_LOCAL)) is not None: + return local + return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config) + + +def _captive_portal_configured(full_config: ConfigType) -> bool: + # Imported here: captive_portal pulls in wifi, which sensor (through web_server) must not + from esphome.components.captive_portal import DOMAIN as CAPTIVE_PORTAL_DOMAIN + + return CAPTIVE_PORTAL_DOMAIN in full_config + + +def serve_captive(config: ConfigType, full_config: ConfigType) -> bool: + """Serve the embedded interface as a captive portal while the AP is up, unless captive_portal + owns that role. Port 80 only: the OS probes and the DHCP portal URI never use another port.""" + wifi_config = full_config.get(CONF_WIFI) + return ( + not _captive_portal_configured(full_config) + and config[CONF_PORT] == 80 + and wifi_config is not None + and CONF_AP in wifi_config + and serve_local(config, wifi_config) + ) + + +def _final_validate_ap_mode(config: ConfigType) -> None: + full_config = fv.full_config.get() + wifi_config = full_config.get(CONF_WIFI) + captive = serve_captive(config, full_config) + local = serve_local(config, wifi_config) + ap_only = wifi_is_ap_only(wifi_config) + if captive: + web_server_base.consume_captive_dns_sockets(config, DOMAIN) + # Surface behavior that the config does not spell out. + if local and CONF_LOCAL not in config: + _LOGGER.info( + "WiFi is AP only: embedding the web interface in the firmware " + "(local: true, roughly 13 KB of flash for version 2, 78 KB for version 3)%s. " + "Set 'local: false' to load it from the internet instead.", + " and serving it as a captive portal on the access point" + if captive + else "", + ) + elif captive: + _LOGGER.info( + "web_server will act as a captive portal while the %saccess point is active.", + "" if ap_only else "fallback ", + ) + # An embedded interface on an AP (AP only, or a fallback with local: true) that is not + # on port 80 cannot be a captive portal; say so, unless captive_portal has that role. + if ( + local + and config[CONF_PORT] != 80 + and wifi_config is not None + and CONF_AP in wifi_config + and not _captive_portal_configured(full_config) + ): + ap_ip = "192.168.4.1" + if (manual_ip := wifi_config[CONF_AP].get(CONF_MANUAL_IP)) is not None: + ap_ip = str(manual_ip[CONF_STATIC_IP]) + _LOGGER.warning( + "web_server uses port %d, so the interface cannot open automatically on the " + "%saccess point (captive portal detection only works on port 80); open " + "http://%s:%d/ manually, or remove 'port:' to use 80.", + config[CONF_PORT], + "" if ap_only else "fallback ", + ap_ip, + config[CONF_PORT], + ) + if ap_only and not local: + _LOGGER.warning( + "WiFi is AP only and the web_server interface is loaded from the internet, " + "which browsers on the access point usually cannot reach; the page stays " + "blank. %s so the interface is embedded in the firmware.", + "Remove 'local: false'" + if config.get(CONF_LOCAL) is False + else "Migrate to version 2 or 3", + ) + + +def _final_validate(config: ConfigType) -> None: + # Called one after the other rather than via cv.All: these return None. + _final_validate_sorting(config) + _final_validate_ap_mode(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def build_index_html(config: ConfigType) -> str: html = "" css_include = config.get(CONF_CSS_INCLUDE) @@ -459,8 +577,12 @@ async def to_code(config: ConfigType) -> None: # include_internal_ is false in C++; only emit the setter to turn it on. if config[CONF_INCLUDE_INTERNAL]: cg.add(var.set_include_internal(True)) - if CONF_LOCAL in config and config[CONF_LOCAL]: + if serve_local(config, CORE.config.get(CONF_WIFI)): cg.add_define("USE_WEBSERVER_LOCAL") + if serve_captive(config, CORE.config): + # AP mode: DNS server plus redirect of unknown URLs so phones open the interface + cg.add_define("USE_WEBSERVER_CAPTIVE") + web_server_base.add_captive_dns_library() if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_WEBSERVER_GZIP") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5fa3ea054b5..b0da010561e 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -45,6 +45,10 @@ #include "esphome/components/radio_frequency/radio_frequency.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/wifi/wifi_component.h" +#endif + #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 #include "server_index_v2.h" @@ -388,7 +392,15 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou } #endif -WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +#ifdef USE_WEBSERVER_CAPTIVE +WebServer *global_web_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + +WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) { +#ifdef USE_WEBSERVER_CAPTIVE + global_web_server = this; +#endif +} // Kept out of the callers so the 64 bit division is emitted once __attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } @@ -429,6 +441,11 @@ void WebServer::setup() { this->base_->add_handler(&this->events_); #endif this->base_->add_handler(this); +#ifdef USE_WEBSERVER_CAPTIVE + // Not-found fallback (outside the auth middleware): the OS captive portal probes hit + // arbitrary URLs and must get the redirect without credentials. + this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *request) { this->handle_not_found_(request); }); +#endif // OTA is now handled by the web_server OTA platform @@ -444,16 +461,54 @@ void WebServer::setup() { }); } void WebServer::loop() { - // No SSE clients connected; stop looping until a new client connects via + bool keep_looping = this->events_.loop(); +#ifdef USE_WEBSERVER_CAPTIVE + this->dns_.loop(); + keep_looping |= this->dns_.is_running(); +#endif + // No SSE clients connected (and no captive DNS to serve); stop looping until a new client connects via // enable_loop_soon_any_context(). This is safe because: // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() // - deferrable_send_state early-outs when no clients are connected // - try_send_nodefer (log, ping) iterates sessions which are empty // - REST API handlers use defer() which runs via the Scheduler - if (!this->events_.loop()) + if (!keep_looping) this->disable_loop(); } +#ifdef USE_WEBSERVER_CAPTIVE +void WebServer::start_captive() { + // Avoid repeating the log and enable_loop on a second start + if (this->dns_.is_running()) + return; + network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + if (!this->dns_.start(ip)) { + ESP_LOGW(TAG, "AP mode: captive DNS failed to start; open http://%s/ manually", ip.str_to(ip_buf)); + return; + } + this->enable_loop(); + ESP_LOGI(TAG, "AP mode: serving the web interface as captive portal at http://%s/", ip.str_to(ip_buf)); +} + +void WebServer::end_captive() { this->dns_.stop(); } + +void WebServer::handle_not_found_(AsyncWebServerRequest *request) { + // OS captive portal probe while the AP is up: a redirect rather than the page itself, because + // the interface resolves its /events and REST paths relative to the page URL. + if (this->dns_.is_running() && request->method() == HTTP_GET) { + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + wifi::global_wifi_component->wifi_soft_ap_ip().str_to(ip_buf); + // Captive mode requires port 80 (enforced at validation), so no port suffix + char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1]; + buf_append_printf(location, sizeof(location), 0, "http://%s/", ip_buf); + request->redirect(location); + return; + } + request->send(404); +} +#endif + #ifdef USE_LOGGER void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 1f9b6cb9348..d3bd1ae0da6 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -4,6 +4,7 @@ #include "esphome/components/json/json_util.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.h" #ifdef USE_WEBSERVER #include "esphome/core/component.h" #include "esphome/core/entity_base.h" @@ -277,6 +278,14 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle an index request under '/'. void handle_index_request(AsyncWebServerRequest *request); +#ifdef USE_WEBSERVER_CAPTIVE + /// AP mode: DNS answers every name with the AP address and unknown URLs redirect to the interface. + /// wifi calls start before setup(), so it may touch nothing but dns_ (enable_loop is a no-op then). + void start_captive(); + void end_captive(); + bool is_captive() const { return this->dns_.is_running(); } +#endif + /// Return the webserver configuration as JSON. json::SerializationBuffer<> get_config_json(); @@ -606,6 +615,10 @@ class WebServer final : public Component, public AsyncWebHandler { #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif +#ifdef USE_WEBSERVER_CAPTIVE + void handle_not_found_(AsyncWebServerRequest *request); + web_server_base::CaptiveDNS dns_; +#endif #if USE_WEBSERVER_VERSION == 1 const char *css_url_{nullptr}; @@ -704,5 +717,9 @@ class WebServer final : public Component, public AsyncWebHandler { #endif }; +#ifdef USE_WEBSERVER_CAPTIVE +extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index c94eea11902..16251537497 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,8 +1,9 @@ from pathlib import Path import esphome.codegen as cg +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, PlatformFramework from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.helpers import copy_file_if_changed @@ -27,6 +28,22 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +def consume_captive_dns_sockets(config: ConfigType, name: str) -> None: + """Register the sockets a captive portal needs on top of the shared HTTP server: + 1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes, + which make several requests that linger in TIME_WAIT.""" + from esphome.components import socket + + socket.consume_sockets(3, name)(config) + socket.consume_sockets(1, name, socket.SocketType.UDP)(config) + + +def add_captive_dns_library() -> None: + """Pull in the Arduino DNSServer library used by CaptiveDNS off ESP32.""" + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): + cg.add_library("DNSServer", None) + + def _consume_web_server_base_sockets(config: ConfigType) -> ConfigType: """Register the shared listening socket for the HTTP server. @@ -82,3 +99,15 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + + +# The DNS server used for captive portals on ESP32; other platforms use the Arduino +# DNSServer library. Its source is also guarded by USE_CAPTIVE_PORTAL / USE_WEBSERVER_CAPTIVE. +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + } +) diff --git a/esphome/components/web_server_base/captive_dns.h b/esphome/components/web_server_base/captive_dns.h new file mode 100644 index 00000000000..0d027d3c406 --- /dev/null +++ b/esphome/components/web_server_base/captive_dns.h @@ -0,0 +1,63 @@ +#pragma once +#include "esphome/core/defines.h" +// DNS server that answers every name with the access point address, so a phone joining the +// AP runs its captive portal check against the device. Shared by captive_portal and the +// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split. +#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE) +#include + +#include "esphome/components/network/ip_address.h" +#include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" +#if defined(USE_ESP32) +#include "dns_server_esp32_idf.h" +#elif defined(USE_ARDUINO) +#include +#endif + +namespace esphome::web_server_base { + +// The server object only exists while running, so an idle owner (AP not up) pays one pointer. +class CaptiveDNS { + public: + /// Returns false when the DNS server could not start; nothing is kept, so is_running() stays false. + bool start(const network::IPAddress &ip) { + if (this->dns_server_ != nullptr) + return true; + auto server = make_unique(); +#if defined(USE_ESP32) + const bool started = server->start(ip); +#elif defined(USE_ARDUINO) + server->setErrorReplyCode(DNSReplyCode::NoError); + const bool started = server->start(53, ESPHOME_F("*"), ip); +#endif + if (!started) + return false; + this->dns_server_ = std::move(server); + return true; + } + void stop() { + if (this->dns_server_ == nullptr) + return; + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } + /// Answer one pending query; call from the owner's loop() while running. + void loop() { + if (this->dns_server_ == nullptr) + return; +#if defined(USE_ESP32) + this->dns_server_->process_next_request(); +#elif defined(USE_ARDUINO) + this->dns_server_->processNextRequest(); +#endif + } + bool is_running() const { return this->dns_server_ != nullptr; } + + protected: + // ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class. + std::unique_ptr dns_server_; +}; + +} // namespace esphome::web_server_base +#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/web_server_base/dns_server_esp32_idf.cpp similarity index 93% rename from esphome/components/captive_portal/dns_server_esp32_idf.cpp rename to esphome/components/web_server_base/dns_server_esp32_idf.cpp index d7a46373b86..c87433fa56b 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/web_server_base/dns_server_esp32_idf.cpp @@ -1,5 +1,5 @@ #include "dns_server_esp32_idf.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -7,9 +7,9 @@ #include #include -namespace esphome::captive_portal { +namespace esphome::web_server_base { -ESPHOME_LOG_TAG(TAG, "captive_portal.dns"); +ESPHOME_LOG_TAG(TAG, "web_server_base.dns"); // DNS constants static constexpr uint16_t DNS_PORT = 53; @@ -46,7 +46,7 @@ struct DNSAnswer { uint32_t ip_addr; } __attribute__((packed)); -void DNSServer::start(const network::IPAddress &ip) { +bool DNSServer::start(const network::IPAddress &ip) { this->server_ip_ = ip; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; @@ -57,7 +57,7 @@ void DNSServer::start(const network::IPAddress &ip) { this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP).release(); if (this->socket_ == nullptr) { ESP_LOGE(TAG, "Socket create failed"); - return; + return false; } // Set socket options @@ -72,9 +72,10 @@ void DNSServer::start(const network::IPAddress &ip) { if (err != 0) { ESP_LOGE(TAG, "Bind failed: %d", errno); this->destroy_socket_(); - return; + return false; } ESP_LOGV(TAG, "Bound to port %d", DNS_PORT); + return true; } void DNSServer::stop() { @@ -202,6 +203,6 @@ void DNSServer::process_next_request() { } } -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/web_server_base/dns_server_esp32_idf.h similarity index 58% rename from esphome/components/captive_portal/dns_server_esp32_idf.h rename to esphome/components/web_server_base/dns_server_esp32_idf.h index b30856c2041..2b10af332a3 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/web_server_base/dns_server_esp32_idf.h @@ -1,15 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// ESP32 backend for CaptiveDNS: answers every A query with the access point address. +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/helpers.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/socket/socket.h" -namespace esphome::captive_portal { +namespace esphome::web_server_base { class DNSServer { public: - void start(const network::IPAddress &ip); + /// Returns false when the socket could not be created or bound. + bool start(const network::IPAddress &ip); void stop(); void process_next_request(); @@ -27,6 +30,6 @@ class DNSServer { uint8_t buffer_[DNS_BUFFER_SIZE]; }; -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 953351f259b..2153e232a05 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -335,9 +335,9 @@ StringRef AsyncWebServerRequest::url_to(std::span buffer) co return StringRef(buffer.data(), decoded_len); } -void AsyncWebServerRequest::redirect(const std::string &url) { +void AsyncWebServerRequest::redirect(const char *url) { httpd_resp_set_status(*this, "302 Found"); - httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Location", url); httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 581ba8323b5..f9094b05c50 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,8 @@ class AsyncWebServerRequest { void requestAuthentication() const; #endif - void redirect(const std::string &url); + void redirect(const char *url); + void redirect(const std::string &url) { this->redirect(url.c_str()); } inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) { httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f973d146e54..62732e09153 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -36,6 +36,9 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/components/captive_portal/captive_portal.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/web_server/web_server.h" +#endif #ifdef USE_IMPROV_BLE #include "esphome/components/improv_ble/improv_ble_component.h" @@ -653,12 +656,12 @@ void WiFiComponent::setup() { #endif #if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) - // The access point is a provisioning surface: once the provisioning window has - // closed, shut it down (mirrors the teardown done on a successful connection). - // The captive portal registers its own closed-callback, and the fallback block - // in loop() is gated so neither is started again afterwards. + // The access point and the portals on it are provisioning surfaces: once the window has + // closed, end them (mirrors the teardown on a successful connection). The fallback block in + // loop() is gated so none of them is started again afterwards. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); #ifdef USE_WIFI_AP_EXCLUSIVE @@ -778,15 +781,13 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { - // Where the radio scans alongside the AP, the portal can list networks. - if (!WIFI_AP_EXCLUSIVE) { - this->wifi_sta_pre_setup_(); - this->start_scanning(); - } - captive_portal::global_captive_portal->start(); + // Where the radio scans alongside the AP, the portal can list networks. + if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) { + this->wifi_sta_pre_setup_(); + this->start_scanning(); } #endif + this->start_ap_portal_(); #endif // USE_WIFI_AP } #ifdef USE_IMPROV_BLE @@ -847,8 +848,8 @@ void WiFiComponent::loop() { this->check_connecting_finished(now); break; } - // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); + // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP + bool portal_active = this->is_ap_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -944,16 +945,15 @@ void WiFiComponent::loop() { this->ap_exclusive_changed_ = now; #endif this->setup_ap_config_(); + // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. + if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) { #ifdef USE_CAPTIVE_PORTAL - // Where the AP runs on its own, a portal with no AP behind it would - // only stretch the cooldowns. - if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) { // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) this->has_completed_scan_after_captive_portal_start_ = false; - captive_portal::global_captive_portal->start(); - } #endif + this->start_ap_portal_(); + } } } #endif // USE_WIFI_AP @@ -1118,11 +1118,7 @@ void WiFiComponent::pause_exclusive_ap_() { #endif void WiFiComponent::disable_ap_() { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif + this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } @@ -2073,10 +2069,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { break; case WiFiRetryPhase::RESTARTING_ADAPTER: - // Skip actual adapter restart if captive portal/improv is active + // Skip actual adapter restart if a portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan - // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { + // without disrupting the portal/improv connection + if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2335,6 +2331,36 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } + +bool WiFiComponent::is_ap_portal_active_() { +#ifdef USE_WEBSERVER_CAPTIVE + if (web_server::global_web_server->is_captive()) + return true; +#endif + return this->is_captive_portal_active_(); +} + +// global_web_server needs no null check: codegen always instantiates WebServer when +// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global. +void WiFiComponent::start_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (captive_portal::global_captive_portal != nullptr) + captive_portal::global_captive_portal->start(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->start_captive(); +#endif +} + +void WiFiComponent::end_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) + captive_portal::global_captive_portal->end(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->end_captive(); +#endif +} bool WiFiComponent::is_improv_ble_active_() { #ifdef USE_IMPROV_BLE return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 73344f66fa6..c1f4061a534 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -713,7 +713,7 @@ class WiFiComponent final : public Component { protected: #ifdef USE_WIFI_AP void setup_ap_config_(); - /// End the captive portal and turn the AP off. + /// End the portals and turn the AP off. void disable_ap_(); #ifdef USE_WIFI_AP_EXCLUSIVE /// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout. @@ -832,6 +832,10 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); + /// captive_portal or the web_server AP mode is serving a user on the access point + bool is_ap_portal_active_(); + void start_ap_portal_(); + void end_ap_portal_(); bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 388e3edb98d..bdca350ddeb 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1334,21 +1334,18 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { return false; } -#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) - // Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled - // This provides a standards-compliant way for clients to discover the captive portal - if (captive_portal::global_captive_portal != nullptr) { - // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy - static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null - memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates - network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); - err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, - strlen(captive_portal_uri)); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); - } else { - ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); - } +#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) + // DHCP option 114 (captive portal URI) lets clients find the portal; either define means one exists + // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy + static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null + memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates + network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); + err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, + strlen(captive_portal_uri)); + if (err != ESP_OK) { + ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); + } else { + ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); } #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 46584389341..5ae701c5526 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -460,6 +460,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -597,6 +598,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -656,6 +658,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/components/web_server/common-ap-fallback.yaml b/tests/components/web_server/common-ap-fallback.yaml new file mode 100644 index 00000000000..0a4e1bc42de --- /dev/null +++ b/tests/components/web_server/common-ap-fallback.yaml @@ -0,0 +1,18 @@ +# STA with AP fallback plus local: true opts the fallback into captive AP mode; exercises +# the runtime start (fallback branch in wifi loop) and end (on STA connect, and when the +# provisioning window closes) paths. +provisioning: + timeout: 1min + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/common-ap-mode.yaml b/tests/components/web_server/common-ap-mode.yaml new file mode 100644 index 00000000000..dc522e3178f --- /dev/null +++ b/tests/components/web_server/common-ap-mode.yaml @@ -0,0 +1,9 @@ +# AP mode: with a WiFi access point and the interface embedded in the firmware, web_server +# runs its own captive portal (DNS server, unknown URLs redirect to the page). +wifi: + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/test-ap-fallback.esp32-idf.yaml b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml new file mode 100644 index 00000000000..172fb7de711 --- /dev/null +++ b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-fallback.yaml diff --git a/tests/components/web_server/test-ap-mode.esp32-idf.yaml b/tests/components/web_server/test-ap-mode.esp32-idf.yaml new file mode 100644 index 00000000000..077a7da009d --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-mode.yaml diff --git a/tests/components/web_server/test-ap-mode.esp8266-ard.yaml b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml new file mode 100644 index 00000000000..077a7da009d --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-mode.yaml diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py new file mode 100644 index 00000000000..3015527c18c --- /dev/null +++ b/tests/unit_tests/components/test_web_server.py @@ -0,0 +1,172 @@ +"""Tests for the web_server AP mode helpers.""" + +import logging + +import pytest + +from esphome.components.web_server import ( + _final_validate_ap_mode, + serve_captive, + serve_local, +) +from esphome.const import ( + CONF_AP, + CONF_LOCAL, + CONF_NETWORKS, + CONF_PORT, + CONF_SSID, + CONF_VERSION, + CONF_WIFI, +) +import esphome.final_validate as fv + +AP_ONLY = {CONF_AP: {}} +AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]} +STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]} + + +@pytest.mark.parametrize( + ("web_server_config", "wifi_config", "expected"), + [ + # AP only: embed the interface, the AP has no internet. + ({CONF_VERSION: 2}, AP_ONLY, True), + ({CONF_VERSION: 3}, AP_ONLY, True), + # Explicit setting always wins. + ({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, True), + # AP fallback, no AP, no wifi, or version 1 (no local mode): hosted page. + ({CONF_VERSION: 2}, AP_FALLBACK, False), + ({CONF_VERSION: 2}, STA_ONLY, False), + ({CONF_VERSION: 2}, None, False), + ({CONF_VERSION: 1}, AP_ONLY, False), + ], +) +def test_serve_local( + web_server_config: dict, wifi_config: dict | None, expected: bool +) -> None: + """The interface is embedded for AP only WiFi unless local is set explicitly.""" + assert serve_local(web_server_config, wifi_config) is expected + + +@pytest.mark.parametrize( + ("web_server_config", "full_config", "expected"), + [ + # AP only: local is implied, web_server is the captive portal. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True), + # Captive portal probes only work on port 80. + ({CONF_VERSION: 2, CONF_PORT: 8080}, {CONF_WIFI: AP_ONLY}, False), + # AP fallback needs an explicit local: true to be captive. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True), + # captive_portal owns the role when configured. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False), + # No AP, no wifi, hosted page, or version 1: never captive. + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False), + ({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, False), + ({CONF_VERSION: 1}, {CONF_WIFI: AP_ONLY}, False), + ], +) +def test_serve_captive( + web_server_config: dict, full_config: dict, expected: bool +) -> None: + web_server_config.setdefault(CONF_PORT, 80) + assert serve_captive(web_server_config, full_config) is expected + + +@pytest.mark.parametrize( + ("web_server_config", "expect_warning"), + [ + # Explicit local: false on an AP only device: the hosted page will stay blank. + ({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True), + # Default: embedded and captive, nothing to warn about. + ({CONF_VERSION: 2, CONF_PORT: 80}, False), + ], +) +def test_final_validate_ap_mode_warns_for_hosted_page( + web_server_config: dict, expect_warning: bool, caplog: pytest.LogCaptureFixture +) -> None: + token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + assert ("stays blank" in caplog.text) is expect_warning + + +@pytest.mark.parametrize( + ("web_server_config", "wifi_config", "expected"), + [ + # AP only: local is implied, so the port alone blocks captive mode. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_ONLY, "on the access point"), + # Fallback AP with local: true opted into captive mode; say why it is skipped. + ( + {CONF_VERSION: 2, CONF_PORT: 8080, CONF_LOCAL: True}, + AP_FALLBACK, + "on the fallback access point", + ), + # Fallback AP without local: hosted page, nothing was promised. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_FALLBACK, None), + ], +) +def test_final_validate_ap_mode_warns_for_non_default_port( + web_server_config: dict, + wifi_config: dict, + expected: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Captive portal detection needs port 80; other ports get a hint, not captive mode.""" + token = fv.full_config.set( + {"web_server": web_server_config, CONF_WIFI: wifi_config} + ) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + if expected is None: + assert "cannot open automatically" not in caplog.text + return + assert expected in caplog.text + assert "http://192.168.4.1:8080/" in caplog.text + + +def test_final_validate_ap_mode_port_warning_uses_manual_ip( + caplog: pytest.LogCaptureFixture, +) -> None: + """The manual URL in the port warning honors wifi.ap.manual_ip.""" + from esphome.const import CONF_MANUAL_IP, CONF_STATIC_IP + + config = {CONF_VERSION: 2, CONF_PORT: 8080} + wifi = {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}} + token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(config) + finally: + fv.full_config.reset(token) + assert "http://10.0.0.1:8080/" in caplog.text + + +@pytest.mark.parametrize( + ("wifi_config", "expected"), + [ + # Explicit local: true on a fallback AP: announce the captive fallback role. + (AP_FALLBACK, "fallback access point"), + # Explicit local: true on AP only skips the implied-local info; still announce. + (AP_ONLY, "captive portal while the access point"), + ], +) +def test_final_validate_ap_mode_informs_explicit_local_captive( + wifi_config: dict, expected: str, caplog: pytest.LogCaptureFixture +) -> None: + """Explicit local: true logs that web_server becomes the captive portal.""" + config = {CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: True} + token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi_config}) + try: + with caplog.at_level(logging.INFO): + _final_validate_ap_mode(config) + finally: + fv.full_config.reset(token) + assert expected in caplog.text From 9c35bb7426a10307292a015bb1058c9159e3bdbb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:48:33 -0400 Subject: [PATCH 381/435] [esp32] Check the TLS option the active IDF version writes in tests (#20584) --- tests/component_tests/esp32/test_esp32.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bb751dd677c..bd8c099c09f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -31,6 +31,7 @@ from esphome.components.esp32 import ( _reconcile_vfs_fatfs_sdkconfig, _user_sdkconfig_wants_tls, allow_insecure_tls, + idf_version, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -981,6 +982,17 @@ def test_user_sdkconfig_wants_tls(options: dict[str, Any], wants_tls: bool) -> N assert _user_sdkconfig_wants_tls(options) is wants_tls +def _tls_compiled_out(sdkconfig: dict[str, Any]) -> bool: + """IDF 6 compiles TLS out with CONFIG_MBEDTLS_TLS_ENABLED=n; IDF 5 has no + prompt for that and uses CONFIG_MBEDTLS_TLS_DISABLED=y instead.""" + if idf_version() >= cv.Version(6, 0, 0): + value = sdkconfig.get("CONFIG_MBEDTLS_TLS_ENABLED") + if isinstance(value, RawSdkconfigValue): + return value.value == "n" + return value is False + return sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + + @pytest.mark.parametrize( ("config_file", "tls_off", "ecp_off", "esp_tls_excluded"), [ @@ -1034,7 +1046,7 @@ def test_tls_disabled_sdkconfig( """TLS is compiled out unless a component or a raw sdkconfig option asks for it.""" generate_main(component_config_path(config_file)) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert (sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True) is tls_off + assert _tls_compiled_out(sdkconfig) is tls_off assert sdkconfig.get("CONFIG_MBEDTLS_ECP_C") is (False if ecp_off else None) assert ( "esp-tls" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] @@ -2168,7 +2180,7 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( deterministic ECDSA are plain crypto, and OpenThread keeps those two.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert _tls_compiled_out(sdkconfig) # require_mbedtls_ecp() keeps ECP for the SRP host key while TLS is off assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) @@ -2185,7 +2197,7 @@ def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( deterministic ECDSA.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert _tls_compiled_out(sdkconfig) assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) From aa0bd9af72f6787a116ff56390a56672e3ea6727 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:55:38 -1000 Subject: [PATCH 382/435] [core] Add YAML secret reference and registration helpers (#20343) --- esphome/bundle.py | 20 ++--- esphome/yaml_util.py | 78 ++++++++++++++++++- tests/unit_tests/conftest.py | 13 ++++ tests/unit_tests/test_bundle.py | 11 ++- tests/unit_tests/test_yaml_util.py | 118 ++++++++++++++++++++++++++--- 5 files changed, 215 insertions(+), 25 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index b633c5ca4f0..45d9a74b49a 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset( ) -# Matches !secret references in YAML text. An optional surrounding -# quote pair around the key is allowed and ignored: YAML treats -# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is -# intentionally a simple regex scan rather than a YAML parse — it may -# match inside comments or multi-line strings, which is the conservative -# direction (include more secrets rather than fewer). -_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""") - - def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: """Scan YAML files for ``!secret `` references.""" keys: set[str] = set() for fpath in yaml_files: try: text = fpath.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError) as err: + _LOGGER.warning( + "Could not scan %s for !secret references (%s); the bundled " + "secret set may be incomplete", + fpath, + err, + ) continue - for match in _SECRET_RE.finditer(text): - keys.add(match.group(1)) + keys |= yaml_util.find_secret_references(text) return keys diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index f550bb543b3..5882c133f6a 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -11,6 +11,7 @@ import logging import math import os from pathlib import Path +import re from typing import Any import uuid @@ -46,6 +47,12 @@ _LOGGER = logging.getLogger(__name__) SECRET_YAML = "secrets.yaml" _SECRET_CACHE = {} _SECRET_VALUES = {} +# Every !secret name the loader resolved; _SECRET_VALUES is keyed by value, so +# names sharing a value collide there. +_LOADED_SECRET_NAMES: set[str] = set() +# Set while a secret_values_registered context is active; the dumper records +# every emitted `!secret` name into it. +_EMITTED_SECRET_NAMES: set[str] | None = None # Not thread-safe — config processing is single-threaded today. _load_listeners: list[Callable[[Path], None]] = [] @@ -456,6 +463,16 @@ def _load_include_candidates( ) +# `!secret key`, quoted or not, also inside flow collections. A text scan, not a +# YAML parse, so it can also match comments; that errs toward more secrets. +_SECRET_REFERENCE_RE = re.compile(r"""!secret\s+['"]?([^\s'",\]}]+)""") + + +def find_secret_references(text: str) -> set[str]: + """Return the ``!secret `` names referenced in a YAML document text.""" + return {match.group(1) for match in _SECRET_REFERENCE_RE.finditer(text)} + + def force_load_include_files( obj: Any, *, @@ -858,6 +875,7 @@ class ESPHomeLoaderMixin: ) val = secrets[node.value] _SECRET_VALUES[str(val)] = node.value + _LOADED_SECRET_NAMES.add(node.value) return val @_add_data_ref @@ -1030,6 +1048,7 @@ def load_yaml( if clear_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + _LOADED_SECRET_NAMES.clear() return _load_yaml_internal(fname, track_document_range=track_document_range) @@ -1135,6 +1154,38 @@ def _load_yaml_internal_with_type( loader.dispose() +def registered_secret_names() -> set[str]: + """Names of all ``!secret`` keys the loader has seen since the last clear.""" + return set(_LOADED_SECRET_NAMES) + + +@contextmanager +def secret_values_registered(values: dict[str, str]) -> Generator[set[str]]: + """Make :func:`dump` write these values as ``!secret `` while active. + + Real secrets already registered win. Yields the set of ``!secret`` names the + dumper emits. A wrapped value (``!extend``, ``!remove``, ``!include``) loses its + tag rather than leaking. + """ + global _EMITTED_SECRET_NAMES # noqa: PLW0603 + added = {v: n for v, n in values.items() if v not in _SECRET_VALUES} + _SECRET_VALUES.update(added) + outer = _EMITTED_SECRET_NAMES + emitted: set[str] = set() + _EMITTED_SECRET_NAMES = emitted + try: + yield emitted + finally: + _EMITTED_SECRET_NAMES = outer + if outer is not None: + outer |= emitted + # Only drop mappings this context still owns; a real !secret loaded + # meanwhile may have registered the same value under its own name. + for value, name in added.items(): + if _SECRET_VALUES.get(value) == name: + del _SECRET_VALUES[value] + + def dump( dict_, show_secrets=False, @@ -1152,6 +1203,7 @@ def dump( if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + _LOADED_SECRET_NAMES.clear() # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML @@ -1349,7 +1401,10 @@ class ESPHomeDumper(yaml.SafeDumper): return node def represent_secret(self, value): - return self.represent_scalar(tag="!secret", value=_SECRET_VALUES[str(value)]) + name = _SECRET_VALUES[str(value)] + if _EMITTED_SECRET_NAMES is not None: + _EMITTED_SECRET_NAMES.add(name) + return self.represent_scalar(tag="!secret", value=name) def represent_stringify(self, value): if is_secret(value): @@ -1433,14 +1488,33 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value.value) return self.represent_scalar(tag="!lambda", value=value.value, style="|") + def _represent_tagged_secret(self, tag: str, value: Any) -> yaml.ScalarNode: + # Writing the secret beats leaking the value, but the tag is lost, so + # the dumped config no longer means the same thing when loaded back. + _LOGGER.warning( + "%s value matches secret '%s'; written as !secret, dropping the %s tag", + tag, + _SECRET_VALUES[str(value)], + tag, + ) + return self.represent_secret(value) + def represent_extend(self, value): + # Consult is_secret like the other scalar representers so a payload + # equal to a registered secret is never written out in cleartext. + if is_secret(value.value): + return self._represent_tagged_secret("!extend", value.value) return self.represent_scalar(tag="!extend", value=value.value) def represent_remove(self, value): + if is_secret(value.value): + return self._represent_tagged_secret("!remove", value.value) return self.represent_scalar(tag="!remove", value=value.value) def represent_include_file(self, value): if value.vars or value.condition is not None: + # The mapping values route through the regular representers, + # which already consult is_secret. mapping = {"file": value.file} if value.vars: mapping["vars"] = value.vars @@ -1449,6 +1523,8 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_mapping( tag="!include", mapping=mapping, flow_style=False ) + if is_secret(value.file): + return self._represent_tagged_secret("!include", value.file) return self.represent_scalar(tag="!include", value=value.file) def represent_id(self, value): diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 7835156b01f..6bffc434eef 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -17,6 +17,7 @@ from unittest.mock import Mock, patch import pytest +from esphome import yaml_util from esphome.core import CORE from esphome.espidf import toolchain @@ -49,6 +50,18 @@ def reset_core(): CORE.reset() +@pytest.fixture(autouse=True) +def clear_yaml_secrets() -> Generator[None]: + """Isolate the yaml_util secrets registry between tests.""" + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + yaml_util._LOADED_SECRET_NAMES.clear() + yield + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + yaml_util._LOADED_SECRET_NAMES.clear() + + @pytest.fixture def fixture_path() -> Path: """ diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 8a2510cf0a0..6a086767840 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -139,10 +139,17 @@ def test_find_used_secret_keys_no_secrets(tmp_path: Path) -> None: assert keys == set() -def test_find_used_secret_keys_missing_file(tmp_path: Path) -> None: +def test_find_used_secret_keys_missing_file( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: missing = tmp_path / "does_not_exist.yaml" - keys = _find_used_secret_keys([missing]) + with caplog.at_level("WARNING", logger="esphome.bundle"): + keys = _find_used_secret_keys([missing]) assert keys == set() + assert any( + "Could not scan" in r.message and "does_not_exist.yaml" in r.message + for r in caplog.records + ) def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 60b05b0b2bf..24a431b0c05 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -26,16 +26,6 @@ from esphome.yaml_util import ( ) -@pytest.fixture(autouse=True) -def clear_secrets_cache() -> None: - """Clear the secrets cache before each test.""" - yaml_util._SECRET_VALUES.clear() - yaml_util._SECRET_CACHE.clear() - yield - yaml_util._SECRET_VALUES.clear() - yaml_util._SECRET_CACHE.clear() - - @pytest.fixture(autouse=True) def clear_core_frontmatter() -> None: """Reset CORE.frontmatter between tests.""" @@ -1973,6 +1963,72 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None: assert "\\033[8m" in redacted_again +def test_secret_values_registered_swaps_scalars_in_dump() -> None: + """Registered value→name mappings make dump() emit `!secret ` for + matching scalars, and are removed again on exit.""" + with yaml_util.secret_values_registered({"hunter2": "wifi_password"}): + out = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "password: !secret 'wifi_password'" in out + assert "hunter2" not in out + out_after = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "hunter2" in out_after + assert "!secret" not in out_after + assert yaml_util.is_secret("hunter2") is None + + +def test_secret_values_registered_collects_emitted_names() -> None: + """The context yields a set recording exactly the `!secret` names the + dumper emitted, including real secrets, and not names never swapped.""" + yaml_util._SECRET_VALUES["real_value"] = "real_name" + with yaml_util.secret_values_registered({"hunter2": "wifi_password"}) as emitted: + yaml_util.dump( + { + "password": make_data_base("hunter2"), + "key": make_data_base("real_value"), + "plain": make_data_base("nothing"), + } + ) + assert emitted == {"wifi_password", "real_name"} + + +def test_secret_values_registered_does_not_clobber_real_secrets() -> None: + """A value already mapped by a real `!secret` keeps its original name.""" + yaml_util._SECRET_VALUES["hunter2"] = "original_name" + with yaml_util.secret_values_registered({"hunter2": "generated_name"}): + out = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "!secret 'original_name'" in out + # The pre-existing mapping survives the context exit. + assert yaml_util.is_secret("hunter2") == "original_name" + + +def test_secret_values_registered_keeps_a_real_secret_loaded_meanwhile() -> None: + """A real `!secret` registered inside the context survives its exit.""" + with yaml_util.secret_values_registered({"hunter2": "generated_name"}): + yaml_util._SECRET_VALUES["hunter2"] = "real_name" + assert yaml_util.is_secret("hunter2") == "real_name" + + +def test_secret_values_registered_nested_keeps_the_outer_collector() -> None: + """The outer context also collects names emitted inside an inner one.""" + with yaml_util.secret_values_registered({"outer_value": "outer"}) as outer: + with yaml_util.secret_values_registered({"inner_value": "inner"}) as inner: + yaml_util.dump({"a": make_data_base("inner_value")}) + yaml_util.dump({"b": make_data_base("outer_value")}) + assert inner == {"inner"} + assert outer == {"inner", "outer"} + + +def test_registered_secret_names(tmp_path: Path) -> None: + """Every loaded name is returned, even two sharing a value, but not names + registered only for a dump.""" + (tmp_path / "secrets.yaml").write_text("wifi_password: same\nap_password: same\n") + main = tmp_path / "main.yaml" + main.write_text("a: !secret wifi_password\nb: !secret ap_password\n") + yaml_util.load_yaml(main) + with yaml_util.secret_values_registered({"other": "temp_name"}): + assert yaml_util.registered_secret_names() == {"wifi_password", "ap_password"} + + @pytest.fixture(autouse=True) def clear_dropped_merge_keys() -> None: """Reset the dropped-merge-key queue between tests.""" @@ -2018,6 +2074,42 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: assert yaml_util.take_dropped_merge_keys() == [] +def test_wrapper_representers_consult_is_secret( + caplog: pytest.LogCaptureFixture, +) -> None: + """!extend / !remove payloads and scalar !include paths equal to a + registered secret are swapped, never written in cleartext, and the lost + tag is logged.""" + from esphome.config_helpers import Extend, Remove + + with ( + caplog.at_level("WARNING", logger="esphome.yaml_util"), + yaml_util.secret_values_registered({"hunter2": "the_secret"}), + ): + out = yaml_util.dump( + { + "a": Extend("hunter2"), + "b": Remove("hunter2"), + "c": Extend("plain_id"), + } + ) + assert out.count("!secret 'the_secret'") == 2 + assert "hunter2" not in out + assert "!extend 'plain_id'" in out + lost = [r.message for r in caplog.records if "dropping the" in r.message] + assert len(lost) == 2 + assert all("hunter2" not in m for m in lost) + + +def test_scalar_include_path_equal_to_secret_is_swapped() -> None: + """A scalar !include whose path equals a registered secret is swapped, + matching the other wrapper representers.""" + include = yaml_util.IncludeFile(Path("/fake/main.yaml"), "hunter2", lambda _: {}) + with yaml_util.secret_values_registered({"hunter2": "the_secret"}): + out = yaml_util.dump({"key": include}) + assert out == "key: !secret 'the_secret'\n" + + # --------------------------------------------------------------------------- # track_document_range=False (validated-config-cache fast path) # --------------------------------------------------------------------------- @@ -2135,3 +2227,9 @@ def test_load_yaml_fast_mode_records_dropped_merge_keys( yaml_util.load_yaml(yaml_file, track_document_range=False) assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))] + + +def test_find_secret_references_in_flow_collections() -> None: + """Flow mapping and sequence punctuation is not part of the secret name.""" + text = "a: {password: !secret wifi_pw}\nb: [!secret one, !secret 'two']\n" + assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"} From ca69c33530f88ceb0f26c9da7bf6f290f4c1502a Mon Sep 17 00:00:00 2001 From: arpena <15361812+arpena@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:57:23 -0300 Subject: [PATCH 383/435] [whirlpool] Add the HISENSE_ARG model for Hisense and BGH units sold in Argentina (#17352) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/whirlpool/climate.py | 6 +- esphome/components/whirlpool/whirlpool.cpp | 108 ++++- esphome/components/whirlpool/whirlpool.h | 19 +- esphome/core/defines.h | 1 + tests/component_tests/whirlpool/__init__.py | 0 .../whirlpool/test_model_define.py | 27 ++ .../whirlpool/test_model_define.yaml | 16 + .../whirlpool/test_model_define_dg11j1.yaml | 16 + tests/components/whirlpool/__init__.py | 16 + tests/components/whirlpool/benchmark.yaml | 4 + tests/components/whirlpool/common-dg11j1.yaml | 5 + tests/components/whirlpool/common.yaml | 6 + .../whirlpool/test-dg11j1.esp32-idf.yaml | 3 + .../components/whirlpool/test.esp32-idf.yaml | 4 +- .../whirlpool/test.esp8266-ard.yaml | 4 +- .../components/whirlpool/whirlpool_tests.cpp | 392 ++++++++++++++++++ 17 files changed, 603 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/whirlpool/__init__.py create mode 100644 tests/component_tests/whirlpool/test_model_define.py create mode 100644 tests/component_tests/whirlpool/test_model_define.yaml create mode 100644 tests/component_tests/whirlpool/test_model_define_dg11j1.yaml create mode 100644 tests/components/whirlpool/__init__.py create mode 100644 tests/components/whirlpool/benchmark.yaml create mode 100644 tests/components/whirlpool/common-dg11j1.yaml create mode 100644 tests/components/whirlpool/test-dg11j1.esp32-idf.yaml create mode 100644 tests/components/whirlpool/whirlpool_tests.cpp diff --git a/CODEOWNERS b/CODEOWNERS index c75693d2d3f..7e263a45141 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -638,7 +638,7 @@ esphome/components/web_server_idf/* @dentra esphome/components/weikai/* @DrCoolZic esphome/components/weikai_i2c/* @DrCoolZic esphome/components/weikai_spi/* @DrCoolZic -esphome/components/whirlpool/* @glmnet +esphome/components/whirlpool/* @arpena @glmnet esphome/components/whynter/* @aeonsablaze esphome/components/wiegand/* @ssieb esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index 3435a6c0bf6..efc7e6ca570 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -5,7 +5,7 @@ from esphome.const import CONF_MODEL from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] -CODEOWNERS = ["@glmnet"] +CODEOWNERS = ["@glmnet", "@arpena"] whirlpool_ns = cg.esphome_ns.namespace("whirlpool") WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR) @@ -14,6 +14,7 @@ Model = whirlpool_ns.enum("Model") MODELS = { "DG11J1-3A": Model.MODEL_DG11J1_3A, "DG11J1-91": Model.MODEL_DG11J1_91, + "HISENSE_ARG": Model.MODEL_HISENSE_ARG, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend( @@ -26,3 +27,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).ext async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) + # cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared + if config[CONF_MODEL] == "HISENSE_ARG": + cg.add_define("USE_WHIRLPOOL_HISENSE_ARG") diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index 73fab843fc4..e341ce04bbc 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -27,10 +27,20 @@ const uint8_t WHIRLPOOL_FAN_HIGH = 1; const uint8_t WHIRLPOOL_FAN_MED = 2; const uint8_t WHIRLPOOL_FAN_LOW = 3; -const uint8_t WHIRLPOOL_SWING_MASK = 128; +const uint8_t WHIRLPOOL_FAN_MASK = 0x03; + +const uint8_t WHIRLPOOL_SWING_MASK = 128; // byte 2 +const uint8_t WHIRLPOOL_SWING_BYTE8 = 0x40; // byte 8 const uint8_t WHIRLPOOL_POWER = 0x04; +// Hisense ARG model; unused constexpr values cost nothing in DG11J1-only builds +static constexpr uint8_t HISENSE_ARG_DISPLAY_ON = 0x80; // byte 11 +static constexpr uint8_t HISENSE_ARG_AMBIENT_TEMP = 0x18; // byte 12, 24 C +static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15 +static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15 +static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C + WhirlpoolClimate::WhirlpoolClimate() : climate_ir::ClimateIR( WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true, @@ -44,11 +54,11 @@ void WhirlpoolClimate::transmit_state() { remote_state[0] = 0x83; remote_state[1] = 0x06; remote_state[6] = 0x80; - // MODEL DG11J191 - remote_state[18] = 0x08; + remote_state[18] = 0x08; // the same on every supported model auto powered_on = this->mode != climate::CLIMATE_MODE_OFF; - if (powered_on != this->powered_on_assumed) { + const bool power_toggle = powered_on != this->powered_on_assumed; + if (power_toggle) { // Set power toggle command remote_state[2] = 4; remote_state[15] = 1; @@ -83,9 +93,16 @@ void WhirlpoolClimate::transmit_state() { break; } + const bool hisense_arg = this->is_hisense_arg_(); + // Temperature auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_())); - remote_state[3] |= (uint8_t) (temp - this->temperature_min_()) << 4; + const auto temp_step = (uint8_t) (temp - this->temperature_min_()); + if (hisense_arg && this->fixed_temp_mode_()) { + remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4; + } else { + remote_state[3] |= temp_step << 4; + } // Fan speed switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { @@ -104,10 +121,17 @@ void WhirlpoolClimate::transmit_state() { // Swing ESP_LOGV(TAG, "send swing %s", this->send_swing_cmd_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false")); - if (this->send_swing_cmd_) { + if (hisense_arg) { + // This model sends swing as a state on every frame; the DG11J1 remotes send a toggle + if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL) { + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] = WHIRLPOOL_SWING_BYTE8; + } + this->encode_hisense_arg_(remote_state, power_toggle, temp_step); + } else if (this->send_swing_cmd_) { if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) { - remote_state[2] |= 128; - remote_state[8] |= 64; + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] |= WHIRLPOOL_SWING_BYTE8; } } @@ -155,6 +179,49 @@ void WhirlpoolClimate::transmit_state() { transmit.perform(); } +bool WhirlpoolClimate::fixed_temp_mode_() const { + return this->mode == climate::CLIMATE_MODE_HEAT_COOL || this->mode == climate::CLIMATE_MODE_FAN_ONLY; +} + +void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + remote_state[11] = HISENSE_ARG_DISPLAY_ON; + remote_state[12] = HISENSE_ARG_AMBIENT_TEMP; + remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY; + + switch (this->mode) { + case climate::CLIMATE_MODE_OFF: + // The remote keeps the cool code in the power-off frame + remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL; + break; + case climate::CLIMATE_MODE_DRY: + // The unit runs these fan speeds itself; the user's choice is kept for the next mode change + remote_state[2] &= ~WHIRLPOOL_FAN_MASK; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + if ((remote_state[2] & WHIRLPOOL_FAN_MASK) == WHIRLPOOL_FAN_AUTO) { + remote_state[2] |= WHIRLPOOL_FAN_MED; + } + break; + case climate::CLIMATE_MODE_HEAT_COOL: + // Auto sends the target as one of four range codes in byte 2 bits 6:4 instead of the temperature nibble. + // Taken from the HPA-4911 firmware: 16 to 21 C, 22 C, 24 C, and everything else (23 C and 25 C up) share a code. + if (temp_step < 6) { + remote_state[2] |= 0x30; + } else if (temp_step == 6) { + remote_state[2] |= 0x50; + } else if (temp_step == 8) { + remote_state[2] |= 0x40; + } else { + remote_state[2] |= 0x20; + } + break; + default: + break; + } +#endif +} + bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { // Check if the esp isn't currently transmitting. if (millis() - this->last_transmit_time_ < 500) { @@ -265,16 +332,19 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } } - // Set received temp - int temp = remote_state[3] & 0xF0; - ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); - temp = (uint8_t) temp >> 4; - temp += static_cast(this->temperature_min_()); - ESP_LOGVV(TAG, "Temperature Climate: %u", temp); - this->target_temperature = temp; + // Set received temp; Hisense ARG sends fixed values in auto and fan-only + const bool hisense_arg = this->is_hisense_arg_(); + if (!hisense_arg || !this->fixed_temp_mode_()) { + int temp = remote_state[3] & 0xF0; + ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); + temp = (uint8_t) temp >> 4; + temp += static_cast(this->temperature_min_()); + ESP_LOGVV(TAG, "Temperature Climate: %u", temp); + this->target_temperature = temp; + } // Set received fan speed - auto fan = remote_state[2] & 0x03; + auto fan = remote_state[2] & WHIRLPOOL_FAN_MASK; ESP_LOGVV(TAG, "Fan: %02X", fan); switch (fan) { case WHIRLPOOL_FAN_HIGH: @@ -293,7 +363,11 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } // Set received swing status - if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && remote_state[8] == 0x40) { + if (hisense_arg) { + this->swing_mode = + (remote_state[8] & WHIRLPOOL_SWING_BYTE8) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + } else if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && + remote_state[8] == WHIRLPOOL_SWING_BYTE8) { ESP_LOGVV(TAG, "Swing toggle pressed "); if (this->swing_mode == climate::CLIMATE_SWING_OFF) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index b705ee95fa7..8b5fc7519a1 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -6,8 +6,9 @@ namespace esphome::whirlpool { /// Simple enum to represent models. enum Model { - MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 - MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 + MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30 }; // Temperature @@ -46,10 +47,22 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR { void transmit_state() override; /// Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; + /// False when the model is compiled out, so the compiler drops the Hisense ARG branches. + bool is_hisense_arg_() const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + return this->model_ == MODEL_HISENSE_ARG; +#else + return false; +#endif + } + /// Modes in which the Hisense ARG model sends a fixed temperature. + bool fixed_temp_mode_() const; + /// Add the Hisense ARG bytes on top of the common whirlpool frame. + void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const; /// Set the time of the last transmission. uint32_t last_transmit_time_{}; - bool send_swing_cmd_{false}; + bool send_swing_cmd_{false}; // the DG11J1 remotes send swing as a toggle, only on the call that changed it Model model_; float temperature_min_() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5ae701c5526..628435a4d84 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -265,6 +265,7 @@ #define USE_VALVE #define USE_WATER_HEATER #define USE_WATER_HEATER_VISUAL_OVERRIDES +#define USE_WHIRLPOOL_HISENSE_ARG #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr diff --git a/tests/component_tests/whirlpool/__init__.py b/tests/component_tests/whirlpool/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/whirlpool/test_model_define.py b/tests/component_tests/whirlpool/test_model_define.py new file mode 100644 index 00000000000..1f6840f3006 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.py @@ -0,0 +1,27 @@ +"""The Hisense ARG code is compiled in only when that model is configured.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +DEFINE = "USE_WHIRLPOOL_HISENSE_ARG" +HERE = Path(__file__).parent + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_hisense_arg_model_emits_the_define( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(HERE / "test_model_define.yaml") + assert DEFINE in _define_names() + + +def test_dg11j1_model_does_not_emit_the_define( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(HERE / "test_model_define_dg11j1.yaml") + assert DEFINE not in _define_names() diff --git a/tests/component_tests/whirlpool/test_model_define.yaml b/tests/component_tests/whirlpool/test_model_define.yaml new file mode 100644 index 00000000000..80c0bc142ba --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + id: xmitr + pin: GPIO4 + carrier_duty_percent: 50% + +climate: + - platform: whirlpool + name: Hisense + model: HISENSE_ARG + transmitter_id: xmitr diff --git a/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml new file mode 100644 index 00000000000..37a80362654 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + id: xmitr + pin: GPIO4 + carrier_duty_percent: 50% + +climate: + - platform: whirlpool + name: Whirlpool + model: DG11J1-91 + transmitter_id: xmitr diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py new file mode 100644 index 00000000000..badd604322d --- /dev/null +++ b/tests/components/whirlpool/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # Compiles in the Hisense ARG model, which climate.py adds only when it is configured + cg.add_define("USE_WHIRLPOOL_HISENSE_ARG") + + manifest.to_code = to_code_testing diff --git a/tests/components/whirlpool/benchmark.yaml b/tests/components/whirlpool/benchmark.yaml new file mode 100644 index 00000000000..7f0bd4e07ca --- /dev/null +++ b/tests/components/whirlpool/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The whirlpool, climate_ir and remote_transmitter sources come in through the +# dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/whirlpool/common-dg11j1.yaml b/tests/components/whirlpool/common-dg11j1.yaml new file mode 100644 index 00000000000..4c0280c9ff3 --- /dev/null +++ b/tests/components/whirlpool/common-dg11j1.yaml @@ -0,0 +1,5 @@ +climate: + - platform: whirlpool + name: Whirlpool DG11J1-91 Climate + model: DG11J1-91 + transmitter_id: xmitr diff --git a/tests/components/whirlpool/common.yaml b/tests/components/whirlpool/common.yaml index 6d55db1f08a..8551f78c26a 100644 --- a/tests/components/whirlpool/common.yaml +++ b/tests/components/whirlpool/common.yaml @@ -2,3 +2,9 @@ climate: - platform: whirlpool name: Whirlpool Climate transmitter_id: xmitr + - platform: whirlpool + id: whirlpool_hisense_arg + name: Whirlpool Hisense ARG Climate + model: HISENSE_ARG + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml new file mode 100644 index 00000000000..7d6fa086efb --- /dev/null +++ b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + whirlpool: !include common-dg11j1.yaml diff --git a/tests/components/whirlpool/test.esp32-idf.yaml b/tests/components/whirlpool/test.esp32-idf.yaml index e891f9dc85c..39e62c0664e 100644 --- a/tests/components/whirlpool/test.esp32-idf.yaml +++ b/tests/components/whirlpool/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + whirlpool: !include common.yaml diff --git a/tests/components/whirlpool/test.esp8266-ard.yaml b/tests/components/whirlpool/test.esp8266-ard.yaml index 4bed2f03e55..6e2dfff6dda 100644 --- a/tests/components/whirlpool/test.esp8266-ard.yaml +++ b/tests/components/whirlpool/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + whirlpool: !include common.yaml diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp new file mode 100644 index 00000000000..ef628abe54d --- /dev/null +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -0,0 +1,392 @@ +#include +#include +#include +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/whirlpool/whirlpool.h" +#include "esphome/core/preferences.h" + +namespace esphome::whirlpool::testing { + +static constexpr size_t FRAME_LEN = 21; +using Frame = std::array; + +static constexpr int32_t HEADER_MARK = 9000; +static constexpr int32_t HEADER_SPACE = 4494; +static constexpr int32_t BIT_MARK = 572; +static constexpr int32_t ONE_SPACE = 1659; +static constexpr int32_t ZERO_SPACE = 553; +static constexpr int32_t GAP_SPACE = 7960; + +static bool divider_after(size_t bytes_sent) { return bytes_sent == 6 || bytes_sent == 14; } + +/// The waveform for a frame: header, bytes LSB first, a divider after bytes 6 and 14, final mark +static remote_base::RawTimings waveform(const Frame &bytes) { + remote_base::RawTimings raw; + raw.push_back(HEADER_MARK); + raw.push_back(-HEADER_SPACE); + for (size_t i = 0; i < FRAME_LEN; i++) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(BIT_MARK); + raw.push_back((bytes[i] >> bit) & 1 ? -ONE_SPACE : -ZERO_SPACE); + } + if (divider_after(i + 1)) { + raw.push_back(BIT_MARK); + raw.push_back(-GAP_SPACE); + } + } + raw.push_back(BIT_MARK); + return raw; +} + +/// Decodes a captured waveform back into bytes, checking header, dividers and footer positions +static ::testing::AssertionResult decode(const remote_base::RawTimings &raw, Frame &out) { + static constexpr size_t EXPECTED_LEN = 2 + FRAME_LEN * 16 + 2 * 2 + 1; + if (raw.size() != EXPECTED_LEN) + return ::testing::AssertionFailure() << "timing count " << raw.size() << ", expected " << EXPECTED_LEN; + if (raw[0] != HEADER_MARK || raw[1] != -HEADER_SPACE) + return ::testing::AssertionFailure() << "bad header " << raw[0] << " " << raw[1]; + size_t pos = 2; + for (size_t i = 0; i < FRAME_LEN; i++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + if (raw[pos] != BIT_MARK) + return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " mark " << raw[pos]; + if (raw[pos + 1] == -ONE_SPACE) { + value |= 1 << bit; + } else if (raw[pos + 1] != -ZERO_SPACE) { + return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " space " << raw[pos + 1]; + } + pos += 2; + } + out[i] = value; + if (divider_after(i + 1)) { + if (raw[pos] != BIT_MARK || raw[pos + 1] != -GAP_SPACE) + return ::testing::AssertionFailure() << "missing divider after byte " << i + 1; + pos += 2; + } + } + if (raw[pos] != BIT_MARK) + return ::testing::AssertionFailure() << "bad footer " << raw[pos]; + return ::testing::AssertionSuccess(); +} + +static uint8_t xor_range(const Frame &f, size_t from, size_t to) { + uint8_t x = 0; + for (size_t i = from; i < to; i++) + x ^= f[i]; + return x; +} + +static ::testing::AssertionResult checksums_ok(const Frame &f) { + if (xor_range(f, 2, 13) != f[13]) + return ::testing::AssertionFailure() << "byte 13 checksum"; + if (xor_range(f, 14, 20) != f[20]) + return ::testing::AssertionFailure() << "byte 20 checksum"; + return ::testing::AssertionSuccess(); +} + +/// Fills in both checksums of a frame +static Frame with_checksums(Frame f) { + f[13] = xor_range(f, 2, 13); + f[20] = xor_range(f, 14, 20); + return f; +} + +static std::string hex(const Frame &f) { + std::string out; + for (uint8_t b : f) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", b); + out += buf; + } + return out; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + WhirlpoolClimate climate; + + explicit Fixture(Model model) { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + this->climate.set_model(model); + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + optional swing = {}) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + if (swing.has_value()) { + call.set_swing_mode(*swing); + } + call.perform(); + } + + /// Decodes the last transmitted frame, failing the test if it is malformed or has bad checksums + Frame last_frame() const { + Frame f{}; + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return f; + EXPECT_TRUE(decode(this->transmitter.frames.back(), f)); + EXPECT_TRUE(checksums_ok(f)) << hex(f); + return f; + } +}; + +static void expect_frame(const Frame &actual, const Frame &expected) { + EXPECT_EQ(actual, expected) << "actual " << hex(actual) << "\nexpected " << hex(expected); +} + +// The receive path ignores frames for 500 ms after a transmit, so these run on a fixture that has not transmitted +static bool receive(WhirlpoolClimate &climate, const Frame &bytes) { + const remote_base::RawTimings raw = waveform(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + return static_cast(climate).on_receive(data); +} + +TEST(WhirlpoolDG11J13A, CoolFromOffMatchesKnownFrame) { + Fixture f(MODEL_DG11J1_3A); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + // byte 2: power toggle 0x04 | fan low 3; byte 3: (24 - 18) << 4 | cool 2; byte 15: cool overwrites toggle with 6 + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x62, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE5, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E}); +} + +TEST(WhirlpoolDG11J191, CoolFromOffUsesSixteenDegreeBase) { + Fixture f(MODEL_DG11J1_91); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + // byte 3: (24 - 16) << 4 | cool 2 + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E}); +} + +TEST(WhirlpoolHisenseArg, PowerToggleThenSettingsOnly) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF); + // byte 11 display on, byte 12 ambient 24 C, byte 15 power toggle + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x9D, 0x00, 0x01, 0x00, 0x00, 0x08, 0x00, 0x09}); + + // Only the fan changed: no power bit, byte 15 settings only + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_MEDIUM); + expect_frame(f.last_frame(), {0x83, 0x06, 0x02, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x98, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x0A}); +} + +TEST(WhirlpoolHisenseArg, ModeSpecificFanAndTemperature) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + f.command(climate::CLIMATE_MODE_DRY, 24, climate::CLIMATE_FAN_HIGH); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x03, 0) << "dry forces fan auto"; + EXPECT_EQ(frame[3], 0x83); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_HIGH) << "the user's choice survives the dry frame"; + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_AUTO); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x03, 2) << "fan-only with fan auto sends medium"; + EXPECT_EQ(frame[3], 0x74) << "fixed temperature nibble 7, fan mode 4"; + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(f.last_frame()[2] & 0x03, 1); + + struct RangeCase { + float target; + uint8_t range; + }; + // 23 C (step 7) shares the catch-all code with the high end of the range + for (const RangeCase &c : + {RangeCase{16, 0x30}, RangeCase{22, 0x50}, RangeCase{23, 0x20}, RangeCase{24, 0x40}, RangeCase{30, 0x20}}) { + f.command(climate::CLIMATE_MODE_HEAT_COOL, c.target, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[3], 0x71) << "target " << c.target; + EXPECT_EQ(frame[2] & 0x70, c.range) << "target " << c.target; + EXPECT_EQ(frame[2] & 0x03, 3) << "target " << c.target; + EXPECT_EQ(frame[15], 0x02) << "target " << c.target; + } +} + +TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + // A later command that does not mention swing still carries it + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); + + f.command(climate::CLIMATE_MODE_COOL, 26, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); +} + +TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) { + Fixture f(MODEL_DG11J1_3A); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); +} + +// Cool 24 C (step 8), fan low, powered on assumed +static Frame hisense_cool_frame() { + return with_checksums({0x83, 0x06, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x00, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x00}); +} + +static void assume_on(WhirlpoolClimate &climate, climate::ClimateMode mode, float target) { + climate.mode = mode; + climate.target_temperature = target; + climate.powered_on_assumed = true; +} + +TEST(WhirlpoolHisenseArg, ReceiveSwingIsAbsolute) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + Frame frame = hisense_cool_frame(); + frame[8] = 0x40; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // The same frame again keeps vertical; the DG11J1 path would toggle back + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + frame[8] = 0x00; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(WhirlpoolHisenseArg, ReceiveTemperature) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + EXPECT_TRUE(receive(f.climate, hisense_cool_frame())); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + + // Heat-cool carries a fixed temperature nibble, which must not overwrite the target + Frame frame = hisense_cool_frame(); + frame[2] = 0x43; + frame[3] = 0x71; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + + // Fan-only likewise + frame[2] = 0x02; + frame[3] = 0x74; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); +} + +TEST(WhirlpoolHisenseArg, ReceivePowerBitTogglesAssumption) { + Fixture f(MODEL_HISENSE_ARG); + ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + ASSERT_FALSE(f.climate.powered_on_assumed); + + Frame frame = hisense_cool_frame(); + frame[2] |= 0x04; + frame[15] = 0x01; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_TRUE(f.climate.powered_on_assumed); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_FALSE(f.climate.powered_on_assumed); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(WhirlpoolHisenseArg, ReceiveRejectsBadChecksum) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + Frame frame = hisense_cool_frame(); + frame[13] ^= 0x01; + EXPECT_FALSE(receive(f.climate, frame)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); + + frame = hisense_cool_frame(); + frame[20] ^= 0x01; + EXPECT_FALSE(receive(f.climate, frame)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); +} + +TEST(WhirlpoolHisenseArg, ReceiveIgnoredRightAfterTransmit) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_LOW); + EXPECT_FALSE(receive(f.climate, hisense_cool_frame())); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); +} + +TEST(WhirlpoolHisenseArg, PowerOffKeepsTheCoolModeCode) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW); + const Frame frame = f.last_frame(); + // The hardware-tested remote sends the cool code with the power toggle, not the heat code 0 + EXPECT_EQ(frame[3] & 0x07, 2); + EXPECT_EQ(frame[2] & 0x04, 0x04); + EXPECT_EQ(frame[15], 0x01); +} + +TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_HEAT, 30, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + f.command(climate::CLIMATE_MODE_HEAT_COOL, 22, climate::CLIMATE_FAN_AUTO); + f.command(climate::CLIMATE_MODE_DRY, 18, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + f.command(climate::CLIMATE_MODE_FAN_ONLY, 25, climate::CLIMATE_FAN_LOW); + f.command(climate::CLIMATE_MODE_OFF, 25, climate::CLIMATE_FAN_LOW); + ASSERT_EQ(f.transmitter.frames.size(), 5u); + for (const auto &raw : f.transmitter.frames) { + Frame frame{}; + ASSERT_TRUE(decode(raw, frame)); + EXPECT_TRUE(checksums_ok(frame)) << hex(frame); + EXPECT_EQ(frame[0], 0x83); + EXPECT_EQ(frame[1], 0x06); + EXPECT_EQ(frame[11], 0x80); + EXPECT_EQ(frame[12], 0x18); + } +} + +} // namespace esphome::whirlpool::testing From e0436e26006a90d443cc0f1724d4e19a479096e3 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 384/435] [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 76dd240f7ac..d46858d3977 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -195,6 +195,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 1b2236ae8e9..bfdff910da0 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -8,9 +8,9 @@ DEPENDENCIES = ["rp2"] DOMAIN = "rp2_pio" -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", @@ -21,8 +21,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 216e1bee5c9f1f3dfb001227230cdd6238003459 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 14:15:44 -0400 Subject: [PATCH 385/435] [socket] Read the LwIP interface list under the core lock (#20583) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/socket/socket.cpp | 37 +++++++++++++------ .../components/socket/test_multicast_host.cpp | 32 ++++++++++------ 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 36cd6d6ab7f..dd9387785f7 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -8,6 +8,7 @@ #include #include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" #ifdef USE_HOST #include "esphome/core/wake.h" #endif @@ -198,24 +199,36 @@ template static bool foreach_eligible_ipv6_if(F &&callback) { } return ctx.found_any; #else - bool found_any = false; - struct netif *netif; - NETIF_FOREACH(netif) { - if (netif->name[0] == 'l' && netif->name[1] == 'o') { - continue; + // The netif list needs the core lock, but the callback's setsockopt() takes that lock itself, so the + // indices are collected first. One that goes away before the callback runs is rejected by setsockopt(). + // Devices have only a few interfaces, so the cap drops nothing in practice. + static constexpr size_t MAX_IFS = 8; + StaticVector indices; + { + LwIPLock lock; + struct netif *netif; + NETIF_FOREACH(netif) { + if (indices.size() == MAX_IFS) { + break; + } + if (netif->name[0] == 'l' && netif->name[1] == 'o') { + continue; + } + if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { + continue; + } + indices.push_back(netif_get_index(netif)); } - if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { - continue; - } - found_any = true; - if (callback(netif_get_index(netif))) { + } + for (uint8_t index : indices) { + if (callback(index)) { break; } } - if (!found_any) { + if (indices.empty()) { errno = EADDRNOTAVAIL; } - return found_any; + return !indices.empty(); #endif } #endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp index 31fe286a865..d730e3a4a61 100644 --- a/tests/components/socket/test_multicast_host.cpp +++ b/tests/components/socket/test_multicast_host.cpp @@ -46,10 +46,14 @@ TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) { } TEST(JoinMulticastGroup, IPv4JoinNullIfIndexOut) { - auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - ASSERT_NE(sock, nullptr); - errno = 0; - EXPECT_TRUE(join_multicast_group(sock.get(), "239.0.60.53", nullptr) || errno != 0); + // Whether the host accepts the join or not, a null if_index_out must give the same answer + auto sock_with = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + auto sock_without = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock_with, nullptr); + ASSERT_NE(sock_without, nullptr); + uint32_t if_index = 0; + const bool with_out = join_multicast_group(sock_with.get(), "239.0.60.53", &if_index); + EXPECT_EQ(join_multicast_group(sock_without.get(), "239.0.60.53", nullptr), with_out); } // ========================================================================= @@ -62,11 +66,13 @@ TEST(JoinMulticastGroup, IPv6JoinSucceedsOrNoInterface) { auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); ASSERT_NE(sock, nullptr); uint32_t if_index = 0; - bool ok = join_multicast_group(sock.get(), "ff12::cafe", &if_index); - if (ok) { + errno = 0; + if (join_multicast_group(sock.get(), "ff12::cafe", &if_index)) { EXPECT_GT(if_index, 0u); - } else { - EXPECT_EQ(errno, EADDRNOTAVAIL); + } else if (errno != EADDRNOTAVAIL) { + // EINVAL can only come from our own parsing, since the address literal is valid + ASSERT_NE(errno, EINVAL); + GTEST_SKIP() << "Host refused the IPv6 multicast join, errno " << errno; } } @@ -74,7 +80,9 @@ TEST(SetIPv6MulticastIf, ProbeWithZeroIndexSucceedsOrNoInterface) { auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); ASSERT_NE(sock, nullptr); errno = 0; - EXPECT_TRUE(set_ipv6_multicast_if(sock.get(), 0) || errno == EADDRNOTAVAIL); + if (!set_ipv6_multicast_if(sock.get(), 0) && errno != EADDRNOTAVAIL) { + GTEST_SKIP() << "Host refused IPV6_MULTICAST_IF, errno " << errno; + } } TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { @@ -83,10 +91,10 @@ TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { ASSERT_NE(sock_join, nullptr); ASSERT_NE(sock_send, nullptr); uint32_t if_index = 0; - bool joined = join_multicast_group(sock_join.get(), "ff12::cafe", &if_index); - if (joined && if_index != 0) { - EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); + if (!join_multicast_group(sock_join.get(), "ff12::cafe", &if_index) || if_index == 0) { + GTEST_SKIP() << "Host has no interface that accepts an IPv6 multicast join"; } + EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); } #endif // USE_NETWORK_IPV6 From 66ba258baa5f82743eec28f8ff4ad6c59f1da823 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 14:53:22 -0400 Subject: [PATCH 386/435] [udp] Wake the main loop when a datagram arrives (#20585) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/udp/udp_component.cpp | 4 +- .../fixtures/socket_wake_gate_udp.yaml | 27 ++++++++ .../integration/test_socket_wake_gate_udp.py | 64 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/socket_wake_gate_udp.yaml create mode 100644 tests/integration/test_socket_wake_gate_udp.py diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 8e824038341..e6046ebc36c 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -45,7 +45,7 @@ void UDPComponent::setup() { // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket_udp(AF_INET, IPPROTO_IP); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -115,6 +115,8 @@ void UDPComponent::loop() { if (this->should_listen_) { std::array buf; for (;;) { + if (!this->listen_socket_->ready()) + break; auto len = this->listen_socket_->read(buf.data(), buf.size()); if (len <= 0) break; diff --git a/tests/integration/fixtures/socket_wake_gate_udp.yaml b/tests/integration/fixtures/socket_wake_gate_udp.yaml new file mode 100644 index 00000000000..27d6df90944 --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_udp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-udp + on_boot: + priority: -100 + then: + - lambda: |- + // Without a loop-monitored socket a datagram waits for loop_interval_ to expire. + App.set_loop_interval(2000); + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + +logger: + level: INFO + +udp: + - id: test_udp + port: UDP_LISTEN_PORT_PLACEHOLDER + addresses: ["127.0.0.1"] + on_receive: + - logger.log: + format: "PONG" + level: INFO diff --git a/tests/integration/test_socket_wake_gate_udp.py b/tests/integration/test_socket_wake_gate_udp.py new file mode 100644 index 00000000000..4edd8f14c2c --- /dev/null +++ b/tests/integration/test_socket_wake_gate_udp.py @@ -0,0 +1,64 @@ +"""Test that an incoming UDP datagram wakes the main loop at once.""" + +from __future__ import annotations + +import asyncio +import socket +import time + +import pytest + +from .types import RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_udp( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """A datagram must be handled well before loop_interval_ (2000ms) expires.""" + # Hold the port with SO_REUSEADDR so nothing else claims it while the firmware builds. The + # device's listen socket sets SO_REUSEADDR too and binds the wildcard address alongside it. + hold_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + hold_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + hold_sock.bind(("127.0.0.1", 0)) + listen_port = hold_sock.getsockname()[1] + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + try: + async with run_compiled(config, line_callback=on_log_line): + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared") + + # Datagrams go to the more specific bind, so release the hold before sending + hold_sock.close() + + # No API client is connected, so this datagram is the only thing that can wake select() + t_send = time.monotonic() + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as send_sock: + send_sock.sendto(b"PING", ("127.0.0.1", listen_port)) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the wake the receive waits for loop_interval_; 500ms leaves CI headroom + assert elapsed_ms < 500, ( + f"UDP receive took {elapsed_ms:.0f}ms with loop_interval_=2000ms" + ) + finally: + hold_sock.close() From 5a3bee638f0475b3619dc42b884a516f0660d01a Mon Sep 17 00:00:00 2001 From: Jozsef Kiraly <371847+fonix232@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:33:33 +0100 Subject: [PATCH 387/435] [siemens_ira211] Add the Siemens IRA211 protocol and climate platform (#15154) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/remote_base/__init__.py | 93 ++++++ .../remote_base/ira211_protocol.cpp | 292 ++++++++++++++++++ .../components/remote_base/ira211_protocol.h | 65 ++++ esphome/components/siemens_ira211/__init__.py | 2 + esphome/components/siemens_ira211/climate.py | 17 + .../siemens_ira211/siemens_ira211.cpp | 167 ++++++++++ .../siemens_ira211/siemens_ira211.h | 29 ++ esphome/core/defines.h | 1 + .../config/ira211_action.yaml | 25 ++ .../remote_transmitter/test_ira211_action.py | 20 ++ tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/ira211_test.cpp | 254 +++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_receiver/esp32-common.yaml | 7 + .../remote_transmitter/common-buttons.yaml | 8 + tests/components/siemens_ira211/common.yaml | 5 + .../siemens_ira211/test.esp32-idf.yaml | 4 + .../siemens_ira211/test.esp8266-ard.yaml | 4 + 19 files changed, 1000 insertions(+) create mode 100644 esphome/components/remote_base/ira211_protocol.cpp create mode 100644 esphome/components/remote_base/ira211_protocol.h create mode 100644 esphome/components/siemens_ira211/__init__.py create mode 100644 esphome/components/siemens_ira211/climate.py create mode 100644 esphome/components/siemens_ira211/siemens_ira211.cpp create mode 100644 esphome/components/siemens_ira211/siemens_ira211.h create mode 100644 tests/component_tests/remote_transmitter/config/ira211_action.yaml create mode 100644 tests/component_tests/remote_transmitter/test_ira211_action.py create mode 100644 tests/components/remote_base/ira211_test.cpp create mode 100644 tests/components/siemens_ira211/common.yaml create mode 100644 tests/components/siemens_ira211/test.esp32-idf.yaml create mode 100644 tests/components/siemens_ira211/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7e263a45141..db9dcccd7f6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -502,6 +502,7 @@ esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny esphome/components/shutdown/* @esphome/core @jsuanet +esphome/components/siemens_ira211/* @fonix232 esphome/components/sigma_delta_output/* @Cat-Ion esphome/components/sim800l/* @glmnet esphome/components/sm10bit_base/* @Cossid diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e347ee96ffb..9cf9493cd0e 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( CONF_DELTA, CONF_DEVICE, CONF_FAMILY, + CONF_FAN_MODE, CONF_FORCE_UPDATE, CONF_GROUP, CONF_HUMIDITY, @@ -30,6 +31,7 @@ from esphome.const import ( CONF_KEY, CONF_LEVEL, CONF_MAGNITUDE, + CONF_MODE, CONF_NBITS, CONF_ONE, CONF_PROTOCOL, @@ -2771,3 +2773,94 @@ register_protocol_action( (CONF_COMMAND, "command", cg.uint8), validate=_toto_default_repeat, ) + + +# IRA211 +( + IRA211Data, + IRA211BinarySensor, + IRA211Trigger, + _, + IRA211Dumper, +) = declare_protocol("IRA211") + +IRA211CommandEnum = ns.enum("IRA211Command", is_class=True) +IRA211_COMMANDS = { + "TEMP_UP": IRA211CommandEnum.IRA211_COMMAND_TEMP_UP, + "TEMP_DOWN": IRA211CommandEnum.IRA211_COMMAND_TEMP_DOWN, + "MODE": IRA211CommandEnum.IRA211_COMMAND_MODE, + "FAN": IRA211CommandEnum.IRA211_COMMAND_FAN, + "POWER": IRA211CommandEnum.IRA211_COMMAND_POWER, + "SYNC": IRA211CommandEnum.IRA211_COMMAND_SYNC, +} + +IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True) +IRA211_MODES = { + "PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION, + "TIMER": IRA211ModeEnum.IRA211_MODE_TIMER, + "COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT, +} + +IRA211FanEnum = ns.enum("IRA211Fan", is_class=True) +IRA211_FAN_SPEEDS = { + "AUTO": IRA211FanEnum.IRA211_FAN_AUTO, + "LOW": IRA211FanEnum.IRA211_FAN_LOW, + "MEDIUM": IRA211FanEnum.IRA211_FAN_MEDIUM, + "HIGH": IRA211FanEnum.IRA211_FAN_HIGH, +} + + +def _half_degree_step(value: float) -> float: + if value * 2 != int(value * 2): + raise cv.Invalid("Temperature must be a multiple of 0.5") + return value + + +IRA211_SCHEMA = cv.Schema( + { + cv.Required(CONF_COMMAND): cv.enum(IRA211_COMMANDS, upper=True), + cv.Optional(CONF_TEMPERATURE, default=20): cv.All( + cv.float_range(min=5, max=35), _half_degree_step + ), + cv.Optional(CONF_MODE, default="COMFORT"): cv.enum(IRA211_MODES, upper=True), + cv.Optional(CONF_FAN_MODE, default="AUTO"): cv.enum( + IRA211_FAN_SPEEDS, upper=True + ), + } +) + + +@register_binary_sensor("ira211", IRA211BinarySensor, IRA211_SCHEMA) +def ira211_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + IRA211Data, + ("command", config[CONF_COMMAND]), + ("half_degrees", int(config[CONF_TEMPERATURE] * 2)), + ("mode", config[CONF_MODE]), + ("fan", config[CONF_FAN_MODE]), + ) + ) + ) + + +@register_trigger("ira211", IRA211Trigger, IRA211Data) +def ira211_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("ira211", IRA211Dumper) +def ira211_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "ira211", + "IRA211", + IRA211_SCHEMA, + (CONF_COMMAND, "command", IRA211CommandEnum), + automation.ApplyField(CONF_TEMPERATURE, "data.set_temperature({})", cg.float_), + (CONF_MODE, "mode", IRA211ModeEnum), + (CONF_FAN_MODE, "fan", IRA211FanEnum), +) diff --git a/esphome/components/remote_base/ira211_protocol.cpp b/esphome/components/remote_base/ira211_protocol.cpp new file mode 100644 index 00000000000..b9cd6ec886e --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.cpp @@ -0,0 +1,292 @@ +#include "ira211_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.ira211"); + +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint32_t T_US = 800; +static constexpr uint32_t HEADER_US = 19 * T_US / 2; // 7600 us, also the gap that separates frames +// Midway between the longest data run (9 bits, 7200 us) and the gap, so a gap captured a little short still +// ends the frame +static constexpr uint32_t GAP_MIN_US = HEADER_US - T_US / 4; +static constexpr uint8_t MIN_HALF_DEGREES = static_cast(IRA211_MIN_TEMPERATURE * 2); +static constexpr uint8_t MAX_HALF_DEGREES = static_cast(IRA211_MAX_TEMPERATURE * 2); + +static constexpr uint8_t DEVICE_ID = 0x66; +static constexpr uint8_t BITS_PER_PACKET = 10; +static constexpr uint8_t MIN_PACKETS = 4; +static constexpr uint8_t MAX_PACKETS = 7; +static constexpr uint8_t MAX_BITS = MAX_PACKETS * BITS_PER_PACKET; + +// Fields each command carries, in wire order after the command byte: temperature (whole degrees +// then tenths), mode, fan. +static constexpr uint8_t FIELD_TEMPERATURE = 1 << 0; +static constexpr uint8_t FIELD_MODE = 1 << 1; +static constexpr uint8_t FIELD_FAN = 1 << 2; + +struct CommandLayout { + IRA211Command command; + uint8_t fields; +}; + +static const CommandLayout LAYOUTS[] PROGMEM = { + {IRA211Command::IRA211_COMMAND_TEMP_UP, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_TEMP_DOWN, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_MODE, FIELD_MODE}, + {IRA211Command::IRA211_COMMAND_FAN, FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_POWER, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_SYNC, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, +}; + +static optional find_layout(IRA211Command command) { + for (const auto &slot : LAYOUTS) { + CommandLayout layout; + progmem_memcpy(&layout, &slot, sizeof(layout)); + if (layout.command == command) + return layout; + } + return {}; +} + +// Device ID, command, fields, checksum +static uint8_t packet_count(const CommandLayout &layout) { + return 3 + ((layout.fields & FIELD_TEMPERATURE) ? 2 : 0) + ((layout.fields & FIELD_MODE) ? 1 : 0) + + ((layout.fields & FIELD_FAN) ? 1 : 0); +} + +// Value to wire byte and back; the transform is its own inverse +static uint8_t wire_xform(uint8_t value) { return reverse_bits(static_cast(~value)); } + +static bool is_known_mode(uint8_t value) { + switch (static_cast(value)) { + case IRA211Mode::IRA211_MODE_PROTECTION: + case IRA211Mode::IRA211_MODE_TIMER: + case IRA211Mode::IRA211_MODE_COMFORT: + return true; + default: + return false; + } +} + +static bool is_known_fan(uint8_t value) { + switch (static_cast(value)) { + case IRA211Fan::IRA211_FAN_AUTO: + case IRA211Fan::IRA211_FAN_LOW: + case IRA211Fan::IRA211_FAN_MEDIUM: + case IRA211Fan::IRA211_FAN_HIGH: + return true; + default: + return false; + } +} + +static uint8_t checksum(const uint8_t *bytes, uint8_t count) { + uint8_t sum = count; + for (uint8_t i = 0; i < count; i++) + sum += reverse_bits(bytes[i]); + return reverse_bits(sum); +} + +bool IRA211Data::operator==(const IRA211Data &rhs) const { + if (this->command != rhs.command) + return false; + const optional layout = find_layout(this->command); + if (!layout.has_value()) + return true; + if ((layout->fields & FIELD_TEMPERATURE) && this->half_degrees != rhs.half_degrees) + return false; + if ((layout->fields & FIELD_MODE) && this->mode != rhs.mode) + return false; + return !(layout->fields & FIELD_FAN) || this->fan == rhs.fan; +} + +void IRA211Protocol::encode(RemoteTransmitData *dst, const IRA211Data &data) { + const optional layout = find_layout(data.command); + if (!layout.has_value()) + return; + + uint8_t bytes[MAX_PACKETS]; + uint8_t count = 0; + bytes[count++] = DEVICE_ID; + bytes[count++] = wire_xform(static_cast(data.command)); + if (layout->fields & FIELD_TEMPERATURE) { + bytes[count++] = wire_xform(data.half_degrees / 2); + bytes[count++] = wire_xform((data.half_degrees & 1) ? 5 : 0); + } + if (layout->fields & FIELD_MODE) + bytes[count++] = wire_xform(static_cast(data.mode)); + if (layout->fields & FIELD_FAN) + bytes[count++] = wire_xform(static_cast(data.fan)); + bytes[count] = checksum(bytes, count); + count++; + + dst->set_carrier_frequency(CARRIER_FREQUENCY); + // Preamble, at most one item per bit, trailing mark and gap + dst->reserve(4 + count * BITS_PER_PACKET + 2); + dst->item(HEADER_US, T_US); + dst->item(T_US, HEADER_US); + + // Each packet is a 1 bit, the byte MSB first, then a 0 bit; runs of equal bits become one mark or space + bool level = true; + uint32_t run = 0; + for (uint8_t p = 0; p < count; p++) { + const uint16_t packet = (1 << 9) | (bytes[p] << 1); + for (int8_t i = BITS_PER_PACKET - 1; i >= 0; i--) { + const bool bit = (packet >> i) & 1; + if (bit != level && run != 0) { + if (level) { + dst->mark(run * T_US); + } else { + dst->space(run * T_US); + } + run = 0; + } + level = bit; + run++; + } + } + // Every packet ends with a 0 bit, so the frame ends with a space, closed by a trailing mark + dst->space(run * T_US); + dst->mark(T_US); + dst->space(HEADER_US); +} + +optional IRA211Protocol::decode(RemoteReceiveData src) { + if (!src.expect_item(HEADER_US, T_US) || !src.expect_item(T_US, HEADER_US)) + return {}; + + // Read bits until the end-of-frame gap, checking packet framing and collecting bytes as they arrive + uint8_t bytes[MAX_PACKETS]{}; + uint8_t bits = 0; + while (src.is_valid()) { + const int32_t raw = src.peek(); + const bool is_mark = raw > 0; + const uint32_t duration = is_mark ? raw : -raw; + // A mark followed by the gap or the end of the data is the trailing mark, not a data bit + if (is_mark && (!src.is_valid(1) || src.peek(1) <= -static_cast(GAP_MIN_US))) + break; + if (!is_mark && duration >= GAP_MIN_US) + break; + const uint32_t run = (duration + T_US / 2) / T_US; + if (run == 0 || bits + run > MAX_BITS) + return {}; + for (uint32_t i = 0; i < run; i++, bits++) { + const uint8_t pos = bits % BITS_PER_PACKET; + if ((pos == 0 && !is_mark) || (pos == BITS_PER_PACKET - 1 && is_mark)) + return {}; + if (pos != 0 && pos != BITS_PER_PACKET - 1) { + uint8_t &byte = bytes[bits / BITS_PER_PACKET]; + byte = (byte << 1) | (is_mark ? 1 : 0); + } + } + src.advance(); + } + + const uint8_t count = bits / BITS_PER_PACKET; + if (bits % BITS_PER_PACKET != 0 || count < MIN_PACKETS || bytes[0] != DEVICE_ID) + return {}; + IRA211Data out{}; + out.command = static_cast(wire_xform(bytes[1])); + const optional layout = find_layout(out.command); + if (!layout.has_value() || packet_count(*layout) != count || checksum(bytes, count - 1) != bytes[count - 1]) { + ESP_LOGV(TAG, "Invalid frame (%u bits)", bits); + return {}; + } + + // A checksum-valid frame can still carry values outside what the thermostat accepts + uint8_t index = 2; + if (layout->fields & FIELD_TEMPERATURE) { + const uint8_t whole = wire_xform(bytes[index++]); + const uint8_t tenths = wire_xform(bytes[index++]); + const uint16_t half_degrees = whole * 2 + (tenths >= 5 ? 1 : 0); + if (half_degrees < MIN_HALF_DEGREES || half_degrees > MAX_HALF_DEGREES) + return {}; + out.half_degrees = static_cast(half_degrees); + } + if (layout->fields & FIELD_MODE) { + const uint8_t mode = wire_xform(bytes[index++]); + if (!is_known_mode(mode)) + return {}; + out.mode = static_cast(mode); + } + if (layout->fields & FIELD_FAN) { + const uint8_t fan = wire_xform(bytes[index++]); + if (!is_known_fan(fan)) + return {}; + out.fan = static_cast(fan); + } + return out; +} + +static const LogString *command_to_string(IRA211Command command) { + switch (command) { + case IRA211Command::IRA211_COMMAND_TEMP_UP: + return LOG_STR("TEMP_UP"); + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + return LOG_STR("TEMP_DOWN"); + case IRA211Command::IRA211_COMMAND_MODE: + return LOG_STR("MODE"); + case IRA211Command::IRA211_COMMAND_FAN: + return LOG_STR("FAN"); + case IRA211Command::IRA211_COMMAND_POWER: + return LOG_STR("POWER"); + case IRA211Command::IRA211_COMMAND_SYNC: + return LOG_STR("SYNC"); + default: + return LOG_STR("UNKNOWN"); + } +} + +static const LogString *mode_to_string(IRA211Mode mode) { + switch (mode) { + case IRA211Mode::IRA211_MODE_PROTECTION: + return LOG_STR("Protection"); + case IRA211Mode::IRA211_MODE_TIMER: + return LOG_STR("Timer"); + case IRA211Mode::IRA211_MODE_COMFORT: + return LOG_STR("Comfort"); + default: + return LOG_STR("Unknown"); + } +} + +static const LogString *fan_to_string(IRA211Fan fan) { + switch (fan) { + case IRA211Fan::IRA211_FAN_AUTO: + return LOG_STR("Auto"); + case IRA211Fan::IRA211_FAN_LOW: + return LOG_STR("1/3"); + case IRA211Fan::IRA211_FAN_MEDIUM: + return LOG_STR("2/3"); + case IRA211Fan::IRA211_FAN_HIGH: + return LOG_STR("3/3"); + default: + return LOG_STR("Unknown"); + } +} + +void IRA211Protocol::dump(const IRA211Data &data) { + const optional layout = find_layout(data.command); + const uint8_t fields = layout.has_value() ? layout->fields : 0; + const unsigned whole = data.half_degrees / 2; + const unsigned tenths = (data.half_degrees & 1) ? 5 : 0; + const auto *command = LOG_STR_ARG(command_to_string(data.command)); + if (fields == (FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN)) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC, mode=%s, fan=%s", command, whole, tenths, + LOG_STR_ARG(mode_to_string(data.mode)), LOG_STR_ARG(fan_to_string(data.fan))); + } else if (fields == FIELD_TEMPERATURE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC", command, whole, tenths); + } else if (fields == FIELD_MODE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, mode=%s", command, LOG_STR_ARG(mode_to_string(data.mode))); + } else if (fields == FIELD_FAN) { + ESP_LOGI(TAG, "Received IRA211: command=%s, fan=%s", command, LOG_STR_ARG(fan_to_string(data.fan))); + } else { + ESP_LOGI(TAG, "Received IRA211: command=%s", command); + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/ira211_protocol.h b/esphome/components/remote_base/ira211_protocol.h new file mode 100644 index 00000000000..205d09215ad --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.h @@ -0,0 +1,65 @@ +#pragma once + +#include "remote_base.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::remote_base { + +/* Siemens IRA211 thermostat remote: 38 kHz carrier, NRZ runs of 800 us bits after a fixed preamble. + A frame is 4, 5 or 7 ten-bit packets (start bit 1, 8 data bits, end bit 0): device ID, command, + the command's fields, then a checksum. Field bytes go over the wire as reverse_bits(~value). */ + +enum class IRA211Command : uint8_t { + IRA211_COMMAND_TEMP_UP = 1, + IRA211_COMMAND_TEMP_DOWN = 2, + IRA211_COMMAND_MODE = 4, + IRA211_COMMAND_FAN = 5, + IRA211_COMMAND_POWER = 6, + IRA211_COMMAND_SYNC = 7, +}; + +enum class IRA211Mode : uint8_t { + IRA211_MODE_PROTECTION = 0, + IRA211_MODE_TIMER = 85, + IRA211_MODE_COMFORT = 170, +}; + +enum class IRA211Fan : uint8_t { + IRA211_FAN_AUTO = 3, + IRA211_FAN_LOW = 12, + IRA211_FAN_MEDIUM = 48, + IRA211_FAN_HIGH = 192, +}; + +static constexpr float IRA211_MIN_TEMPERATURE = 5.0f; +static constexpr float IRA211_MAX_TEMPERATURE = 35.0f; + +struct IRA211Data { + IRA211Command command{IRA211Command::IRA211_COMMAND_SYNC}; + uint8_t half_degrees{40}; // target temperature in 0.5 C steps + IRA211Mode mode{IRA211Mode::IRA211_MODE_COMFORT}; + IRA211Fan fan{IRA211Fan::IRA211_FAN_AUTO}; + + float get_temperature() const { return this->half_degrees * 0.5f; } + /// Rounds to the nearest half degree and clamps to the 5 to 35 C range the thermostat accepts. + void set_temperature(float temperature) { + this->half_degrees = + static_cast(lroundf(clamp(temperature, IRA211_MIN_TEMPERATURE, IRA211_MAX_TEMPERATURE) * 2.0f)); + } + + /// Compares the command and only the fields that command carries. + bool operator==(const IRA211Data &rhs) const; +}; + +class IRA211Protocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const IRA211Data &data); + optional decode(RemoteReceiveData src); + void dump(const IRA211Data &data); +}; + +DECLARE_REMOTE_PROTOCOL(IRA211) + +} // namespace esphome::remote_base diff --git a/esphome/components/siemens_ira211/__init__.py b/esphome/components/siemens_ira211/__init__.py new file mode 100644 index 00000000000..1407aff81ec --- /dev/null +++ b/esphome/components/siemens_ira211/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@fonix232"] +DOMAIN = "siemens_ira211" diff --git a/esphome/components/siemens_ira211/climate.py b/esphome/components/siemens_ira211/climate.py new file mode 100644 index 00000000000..16345e70f19 --- /dev/null +++ b/esphome/components/siemens_ira211/climate.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +siemens_ira211_ns = cg.esphome_ns.namespace("siemens_ira211") +SiemensIRA211Climate = siemens_ira211_ns.class_( + "SiemensIRA211Climate", climate_ir.ClimateIR +) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SiemensIRA211Climate) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("ira211") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/siemens_ira211/siemens_ira211.cpp b/esphome/components/siemens_ira211/siemens_ira211.cpp new file mode 100644 index 00000000000..e332134eac2 --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.cpp @@ -0,0 +1,167 @@ +#include "siemens_ira211.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::siemens_ira211 { + +using namespace remote_base; + +struct PresetMapping { + climate::ClimatePreset preset; + IRA211Mode mode; +}; + +static const PresetMapping PRESETS[] PROGMEM = { + {climate::CLIMATE_PRESET_COMFORT, IRA211Mode::IRA211_MODE_COMFORT}, + {climate::CLIMATE_PRESET_ECO, IRA211Mode::IRA211_MODE_TIMER}, +}; + +struct FanMapping { + climate::ClimateFanMode fan_mode; + IRA211Fan fan; +}; + +static const FanMapping FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, IRA211Fan::IRA211_FAN_AUTO}, + {climate::CLIMATE_FAN_LOW, IRA211Fan::IRA211_FAN_LOW}, + {climate::CLIMATE_FAN_MEDIUM, IRA211Fan::IRA211_FAN_MEDIUM}, + {climate::CLIMATE_FAN_HIGH, IRA211Fan::IRA211_FAN_HIGH}, +}; + +// The tables live in flash, so entries are copied out before use +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +// The first entry of each table is the fallback for values the other side does not know +static IRA211Mode to_ira211_mode(climate::ClimatePreset preset) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.preset == preset) + return entry.mode; + } + return load_entry(PRESETS[0]).mode; +} + +static climate::ClimatePreset to_climate_preset(IRA211Mode mode) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.mode == mode) + return entry.preset; + } + return load_entry(PRESETS[0]).preset; +} + +static IRA211Fan to_ira211_fan(climate::ClimateFanMode fan_mode) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan_mode == fan_mode) + return entry.fan; + } + return load_entry(FANS[0]).fan; +} + +static climate::ClimateFanMode to_climate_fan_mode(IRA211Fan fan) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan == fan) + return entry.fan_mode; + } + return load_entry(FANS[0]).fan_mode; +} + +void SiemensIRA211Climate::setup() { + // The thermostat decides between heating and cooling itself, so one mode stands for both + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) { + this->modes_.erase(climate::CLIMATE_MODE_HEAT); + this->modes_.erase(climate::CLIMATE_MODE_COOL); + } + climate_ir::ClimateIR::setup(); + // Match the restored state, so the first command after a reboot does not send a needless POWER frame + this->is_on_ = this->mode != climate::CLIMATE_MODE_OFF; +} + +climate::ClimateMode SiemensIRA211Climate::active_mode_() const { + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) + return climate::CLIMATE_MODE_HEAT_COOL; + if (this->modes_.count(climate::CLIMATE_MODE_HEAT)) + return climate::CLIMATE_MODE_HEAT; + return climate::CLIMATE_MODE_COOL; +} + +void SiemensIRA211Climate::transmit_frame_(IRA211Command command, IRA211Mode mode, IRA211Fan fan) { + IRA211Data data{}; + data.command = command; + data.set_temperature(this->target_temperature); + data.mode = mode; + data.fan = fan; + this->transmit_(data); +} + +void SiemensIRA211Climate::transmit_state() { + const IRA211Mode mode = to_ira211_mode(this->preset.value_or(climate::CLIMATE_PRESET_COMFORT)); + const IRA211Fan fan = to_ira211_fan(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + // Show the preset that is actually sent, so a fresh entity does not stay on "none" + this->preset = to_climate_preset(mode); + + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, IRA211Mode::IRA211_MODE_PROTECTION, fan); + this->is_on_ = false; + return; + } + if (!this->is_on_) { + // Turning on takes a POWER frame before the state is sent + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, mode, fan); + this->is_on_ = true; + } + this->transmit_frame_(IRA211Command::IRA211_COMMAND_SYNC, mode, fan); +} + +void SiemensIRA211Climate::apply_mode_(IRA211Mode mode) { + // The thermostat reports "off" as the PROTECTION mode + if (mode == IRA211Mode::IRA211_MODE_PROTECTION) { + this->mode = climate::CLIMATE_MODE_OFF; + this->is_on_ = false; + return; + } + if (!this->is_on_) { + this->mode = this->active_mode_(); + this->is_on_ = true; + } + this->preset = to_climate_preset(mode); +} + +bool SiemensIRA211Climate::on_receive(RemoteReceiveData data) { + auto decoded = IRA211Protocol().decode(data); + if (!decoded.has_value()) + return false; + + const IRA211Data &frame = *decoded; + switch (frame.command) { + case IRA211Command::IRA211_COMMAND_SYNC: + case IRA211Command::IRA211_COMMAND_POWER: + this->apply_mode_(frame.mode); + this->target_temperature = frame.get_temperature(); + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + case IRA211Command::IRA211_COMMAND_TEMP_UP: + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + this->target_temperature = frame.get_temperature(); + break; + case IRA211Command::IRA211_COMMAND_MODE: + this->apply_mode_(frame.mode); + break; + case IRA211Command::IRA211_COMMAND_FAN: + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + default: + return false; + } + + this->publish_state(); + return true; +} + +} // namespace esphome::siemens_ira211 diff --git a/esphome/components/siemens_ira211/siemens_ira211.h b/esphome/components/siemens_ira211/siemens_ira211.h new file mode 100644 index 00000000000..dcba2ddcb92 --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/ira211_protocol.h" + +namespace esphome::siemens_ira211 { + +class SiemensIRA211Climate final : public climate_ir::ClimateIR { + public: + void setup() override; + SiemensIRA211Climate() + : climate_ir::ClimateIR(remote_base::IRA211_MIN_TEMPERATURE, remote_base::IRA211_MAX_TEMPERATURE, 0.5f, false, + false, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {}, {climate::CLIMATE_PRESET_COMFORT, climate::CLIMATE_PRESET_ECO}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + void transmit_frame_(remote_base::IRA211Command command, remote_base::IRA211Mode mode, remote_base::IRA211Fan fan); + void apply_mode_(remote_base::IRA211Mode mode); + climate::ClimateMode active_mode_() const; + + bool is_on_{false}; +}; + +} // namespace esphome::siemens_ira211 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 628435a4d84..3168d269cf4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -170,6 +170,7 @@ #define USE_REMOTE_PROTOCOL_GOBOX #define USE_REMOTE_PROTOCOL_HAIER #define USE_REMOTE_PROTOCOL_HOB2HOOD +#define USE_REMOTE_PROTOCOL_IRA211 #define USE_REMOTE_PROTOCOL_JVC #define USE_REMOTE_PROTOCOL_KEELOQ #define USE_REMOTE_PROTOCOL_LG diff --git a/tests/component_tests/remote_transmitter/config/ira211_action.yaml b/tests/component_tests/remote_transmitter/config/ira211_action.yaml new file mode 100644 index 00000000000..3cba9245abb --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/ira211_action.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: IRA211 constant + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: 22.5 + mode: timer + fan_mode: high + - platform: template + name: IRA211 lambda temperature + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: !lambda return 21.0f; diff --git a/tests/component_tests/remote_transmitter/test_ira211_action.py b/tests/component_tests/remote_transmitter/test_ira211_action.py new file mode 100644 index 00000000000..2d202998f16 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_ira211_action.py @@ -0,0 +1,20 @@ +"""transmit_ira211 sets the temperature through IRA211Data::set_temperature().""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_ira211_temperature_is_a_statement_on_the_data( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("ira211_action.yaml")) + constant, lambda_ = protocol_call_bodies(main_cpp) + assert "apply_call.data.set_temperature(22.5f);" in constant + assert ( + "apply_call.data.mode = remote_base::IRA211Mode::IRA211_MODE_TIMER;" in constant + ) + assert "apply_call.data.fan = remote_base::IRA211Fan::IRA211_FAN_HIGH;" in constant + assert "apply_call.data.set_temperature(static_cast(21.0f));" in lambda_ diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 2fe959bb161..f1045572656 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "aeha", "keeloq", "nexus", + "ira211", "nec", "magiquest", "rc5x", diff --git a/tests/components/remote_base/ira211_test.cpp b/tests/components/remote_base/ira211_test.cpp new file mode 100644 index 00000000000..f23d68640d8 --- /dev/null +++ b/tests/components/remote_base/ira211_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/remote_base/ira211_protocol.h" + +#include +#include +#include +#include +#include + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t T_US = 800; +constexpr uint32_t HEADER_US = 7600; +constexpr size_t PREAMBLE_ITEMS = 4; // 7600 mark, 800 space, 800 mark, 7600 space +constexpr size_t BITS_PER_PACKET = 10; + +constexpr std::array ALL_COMMANDS = { + IRA211Command::IRA211_COMMAND_TEMP_UP, IRA211Command::IRA211_COMMAND_TEMP_DOWN, IRA211Command::IRA211_COMMAND_MODE, + IRA211Command::IRA211_COMMAND_FAN, IRA211Command::IRA211_COMMAND_POWER, IRA211Command::IRA211_COMMAND_SYNC, +}; +constexpr std::array ALL_MODES = { + IRA211Mode::IRA211_MODE_PROTECTION, + IRA211Mode::IRA211_MODE_TIMER, + IRA211Mode::IRA211_MODE_COMFORT, +}; +constexpr std::array ALL_FANS = { + IRA211Fan::IRA211_FAN_AUTO, + IRA211Fan::IRA211_FAN_LOW, + IRA211Fan::IRA211_FAN_MEDIUM, + IRA211Fan::IRA211_FAN_HIGH, +}; +constexpr std::array TEMPERATURES = {5.0f, 20.5f, 35.0f}; + +bool carries_temperature(IRA211Command command) { + return command != IRA211Command::IRA211_COMMAND_MODE && command != IRA211Command::IRA211_COMMAND_FAN; +} + +IRA211Data make_data(IRA211Command command, float temperature, IRA211Mode mode, IRA211Fan fan) { + IRA211Data data{}; + data.command = command; + data.set_temperature(temperature); + data.mode = mode; + data.fan = fan; + return data; +} + +RawTimings encode(const IRA211Data &data) { + RemoteTransmitData transmit; + IRA211Protocol().encode(&transmit, data); + return transmit.get_data(); +} + +optional decode(const RawTimings &timings) { + return IRA211Protocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +std::string describe(const IRA211Data &data) { + char buf[64]; + snprintf(buf, sizeof(buf), "command %d temperature %.1f mode %d fan %d", int(data.command), data.get_temperature(), + int(data.mode), int(data.fan)); + return buf; +} + +void expect_round_trip(const RawTimings &timings, const IRA211Data &expected) { + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << describe(expected); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_TRUE(*decoded == expected) << describe(expected) << " decoded as " << describe(*decoded); + if (carries_temperature(expected.command)) + EXPECT_EQ(decoded->get_temperature(), expected.get_temperature()) << describe(expected); + } +} + +// Index of the first run of packet `packet`, and of the run after its last one. Packets start with a 1 bit +// after a 0 end bit and end with a 0 bit before a 1 start bit, so no run crosses a packet boundary. +std::pair packet_run_range(const RawTimings &timings, size_t packet) { + size_t begin = 0, end = 0, bits = 0; + for (size_t i = PREAMBLE_ITEMS; i < timings.size(); i++) { + if (bits == packet * BITS_PER_PACKET) + begin = i; + if (bits == (packet + 1) * BITS_PER_PACKET) { + end = i; + break; + } + bits += (std::abs(timings[i]) + T_US / 2) / T_US; + } + return {begin, end}; +} + +} // namespace + +TEST(IRA211ProtocolTest, RoundTripsEveryCommandTemperatureModeAndFan) { + for (auto command : ALL_COMMANDS) { + for (auto temperature : TEMPERATURES) { + for (auto mode : ALL_MODES) { + for (auto fan : ALL_FANS) { + const IRA211Data data = make_data(command, temperature, mode, fan); + expect_round_trip(encode(data), data); + } + } + } + } +} + +TEST(IRA211ProtocolTest, EveryFrameEndsWithTheTrailingMarkAndGap) { + for (auto command : ALL_COMMANDS) { + const RawTimings timings = + encode(make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO)); + ASSERT_GE(timings.size(), PREAMBLE_ITEMS + 2) << "command " << int(command); + EXPECT_EQ(timings[timings.size() - 2], int32_t(T_US)) << "command " << int(command); + EXPECT_EQ(timings.back(), -int32_t(HEADER_US)) << "command " << int(command); + } +} + +TEST(IRA211ProtocolTest, SetTemperatureClampsAndRoundsToHalfDegrees) { + const struct { + float input; + float expected; + } cases[] = { + {4.0f, 5.0f}, {36.0f, 35.0f}, {22.26f, 22.5f}, {22.24f, 22.0f}, + {-1.0f, 5.0f}, {140.0f, 35.0f}, {5.0f, 5.0f}, {35.0f, 35.0f}, + }; + for (const auto &c : cases) { + IRA211Data data{}; + data.set_temperature(c.input); + EXPECT_EQ(data.get_temperature(), c.expected) << "input " << c.input; + } +} + +// A receiver that stops capturing at the frame gap never sees the trailing mark nor the gap after it. +TEST(IRA211ProtocolTest, DecodesWithoutTheTrailingMarkAndGap) { + for (auto command : ALL_COMMANDS) { + for (auto fan : ALL_FANS) { + const IRA211Data data = make_data(command, 35.0f, IRA211Mode::IRA211_MODE_TIMER, fan); + RawTimings timings = encode(data); + timings.pop_back(); // 7600 us gap + timings.pop_back(); // 800 us trailing mark + expect_round_trip(timings, data); + } + } +} + +// Move one bit between two neighbouring runs inside the checksum packet: the bit count and the framing bits +// stay valid, so only the checksum byte changes. +TEST(IRA211ProtocolTest, RejectsACorruptedChecksum) { + for (auto command : ALL_COMMANDS) { + const IRA211Data data = make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + RawTimings timings = encode(data); + ASSERT_TRUE(decode(timings).has_value()) << describe(data); + size_t bits = 0; + for (size_t i = PREAMBLE_ITEMS; i + 2 < timings.size(); i++) + bits += (std::abs(timings[i]) + T_US / 2) / T_US; + ASSERT_EQ(bits % BITS_PER_PACKET, 0u) << describe(data); + const auto [begin, end] = packet_run_range(timings, bits / BITS_PER_PACKET - 1); + ASSERT_GT(end, begin) << describe(data); + // Shrink the first run that is at least two bits long and grow its neighbour; a run that keeps one bit + // keeps the start or end bit it may hold. + bool corrupted = false; + for (size_t i = begin; i < end && !corrupted; i++) { + if (std::abs(timings[i]) < int32_t(2 * T_US)) + continue; + const size_t neighbour = i + 1 < end ? i + 1 : i - 1; + timings[i] -= timings[i] > 0 ? int32_t(T_US) : -int32_t(T_US); + timings[neighbour] += timings[neighbour] > 0 ? int32_t(T_US) : -int32_t(T_US); + corrupted = true; + } + ASSERT_TRUE(corrupted) << describe(data) << ": checksum packet has no run longer than one bit"; + EXPECT_FALSE(decode(timings).has_value()) << describe(data); + } +} + +// A SYNC frame carries seven packets; dropping a middle one leaves a well formed six packet frame whose +// command still claims seven, so it must be rejected. +TEST(IRA211ProtocolTest, RejectsAFrameOfTheWrongLengthForItsCommand) { + const IRA211Data data = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH); + RawTimings timings = encode(data); + ASSERT_TRUE(decode(timings).has_value()); + const auto [begin, end] = packet_run_range(timings, 3); // the tenths byte + ASSERT_GT(begin, PREAMBLE_ITEMS - 1); + ASSERT_GT(end, begin); + timings.erase(timings.begin() + begin, timings.begin() + end); + EXPECT_FALSE(decode(timings).has_value()); +} + +// transmit_state() sends POWER then SYNC back to back, so a receiver with a long idle sees both in one capture. +// The gap may be captured a little short; it must still end the first frame. +TEST(IRA211ProtocolTest, DecodesTheFirstOfTwoFramesWithAShortenedGap) { + const IRA211Data power = + make_data(IRA211Command::IRA211_COMMAND_POWER, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + const IRA211Data sync = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + RawTimings timings = encode(power); + timings.back() = -int32_t(HEADER_US - 100); + const RawTimings second = encode(sync); + timings.insert(timings.end(), second.begin(), second.end()); + expect_round_trip(timings, power); +} + +// Values are encoded as given, so a frame with an out-of-range field still gets a valid checksum. +TEST(IRA211ProtocolTest, RejectsAnOutOfRangeTemperature) { + for (uint8_t half_degrees : {uint8_t(9), uint8_t(71), uint8_t(72), uint8_t(255)}) { + IRA211Data data = make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, + IRA211Fan::IRA211_FAN_AUTO); + data.half_degrees = half_degrees; + EXPECT_FALSE(decode(encode(data)).has_value()) << "half degrees " << int(half_degrees); + } +} + +TEST(IRA211ProtocolTest, RejectsAnUnknownModeOrFan) { + IRA211Data mode = + make_data(IRA211Command::IRA211_COMMAND_MODE, 20.5f, static_cast(1), IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(decode(encode(mode)).has_value()); + IRA211Data fan = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, static_cast(1)); + EXPECT_FALSE(decode(encode(fan)).has_value()); +} + +TEST(IRA211ProtocolTest, RejectsAForeignFrame) { + const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560}; + EXPECT_FALSE(decode(nec_like).has_value()); + EXPECT_FALSE(decode({}).has_value()); +} + +TEST(IRA211ProtocolTest, EqualityIgnoresFieldsTheCommandDoesNotCarry) { + const IRA211Data temp_up_auto = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + const IRA211Data temp_up_high = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f, IRA211Mode::IRA211_MODE_TIMER, + IRA211Fan::IRA211_FAN_HIGH); + EXPECT_TRUE(temp_up_auto == temp_up_high); + const IRA211Data temp_down = make_data(IRA211Command::IRA211_COMMAND_TEMP_DOWN, 20.5f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(temp_up_auto == temp_down); + const IRA211Data temp_up_colder = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.0f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(temp_up_auto == temp_up_colder); + + const IRA211Data mode_low = + make_data(IRA211Command::IRA211_COMMAND_MODE, 5.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_LOW); + const IRA211Data mode_high = + make_data(IRA211Command::IRA211_COMMAND_MODE, 35.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_HIGH); + EXPECT_TRUE(mode_low == mode_high); + + const IRA211Data sync_auto = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + const IRA211Data sync_high = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH); + EXPECT_FALSE(sync_auto == sync_high); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index cd01690344b..6aa7ae37bb9 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -192,3 +192,8 @@ on_hob2hood: - logger.log: format: "on_hob2hood: %u" args: ["x.command"] +on_ira211: + then: + - logger.log: + format: "on_ira211: %u %.1f" + args: ["static_cast(x.command)", "x.get_temperature()"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index d2b135f0340..50623190406 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -43,6 +43,13 @@ binary_sensor: name: Hob2Hood Light On hob2hood: command: light_on + - platform: remote_receiver + name: IRA211 Sync + ira211: + command: sync + temperature: 21.5 + mode: comfort + fan_mode: low - platform: remote_receiver name: MagiQuest Wand magiquest: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index dff4e8f9c5d..b3583ffe12d 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -92,6 +92,14 @@ button: - remote_transmitter.transmit_toshiba_ac: rc_code_1: 0xB24DBF4050AF rc_code_2: 0xD5660001003C + - platform: template + name: IRA211 + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: 22.5 + mode: timer + fan_mode: high - platform: template name: Sony on_press: diff --git a/tests/components/siemens_ira211/common.yaml b/tests/components/siemens_ira211/common.yaml new file mode 100644 index 00000000000..fc2c53f9673 --- /dev/null +++ b/tests/components/siemens_ira211/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: siemens_ira211 + name: Siemens IRA211 + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/siemens_ira211/test.esp32-idf.yaml b/tests/components/siemens_ira211/test.esp32-idf.yaml new file mode 100644 index 00000000000..b217e0f0d9d --- /dev/null +++ b/tests/components/siemens_ira211/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + siemens_ira211: !include common.yaml diff --git a/tests/components/siemens_ira211/test.esp8266-ard.yaml b/tests/components/siemens_ira211/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b5b3d45c10d --- /dev/null +++ b/tests/components/siemens_ira211/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + siemens_ira211: !include common.yaml From 833c6634ad2b7f16d82060dd01cf74e9c4298dd3 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 20:44:51 +0000 Subject: [PATCH 388/435] [lis2dw12] Add ST LIS2DW12 accelerometer component (#14780) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/lis2dw12/__init__.py | 181 +++++++++ esphome/components/lis2dw12/binary_sensor.py | 43 +++ esphome/components/lis2dw12/lis2dw12.cpp | 350 ++++++++++++++++++ esphome/components/lis2dw12/lis2dw12.h | 198 ++++++++++ esphome/components/lis2dw12/sensor.py | 43 +++ esphome/components/lis2dw12/text_sensor.py | 27 ++ esphome/components/lis2dw12_i2c/__init__.py | 35 ++ .../components/lis2dw12_i2c/lis2dw12_i2c.cpp | 13 + .../components/lis2dw12_i2c/lis2dw12_i2c.h | 17 + esphome/components/lis2dw12_spi/__init__.py | 37 ++ .../components/lis2dw12_spi/lis2dw12_spi.cpp | 34 ++ .../components/lis2dw12_spi/lis2dw12_spi.h | 18 + tests/components/lis2dw12_i2c/common.yaml | 60 +++ .../lis2dw12_i2c/test.esp32-idf.yaml | 3 + .../lis2dw12_i2c/test.esp8266-ard.yaml | 3 + .../lis2dw12_i2c/test.rp2040-ard.yaml | 3 + tests/components/lis2dw12_spi/common.yaml | 61 +++ .../lis2dw12_spi/test.esp32-idf.yaml | 3 + .../lis2dw12_spi/test.esp8266-ard.yaml | 3 + .../lis2dw12_spi/test.rp2040-ard.yaml | 3 + 21 files changed, 1138 insertions(+) create mode 100644 esphome/components/lis2dw12/__init__.py create mode 100644 esphome/components/lis2dw12/binary_sensor.py create mode 100644 esphome/components/lis2dw12/lis2dw12.cpp create mode 100644 esphome/components/lis2dw12/lis2dw12.h create mode 100644 esphome/components/lis2dw12/sensor.py create mode 100644 esphome/components/lis2dw12/text_sensor.py create mode 100644 esphome/components/lis2dw12_i2c/__init__.py create mode 100644 esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp create mode 100644 esphome/components/lis2dw12_i2c/lis2dw12_i2c.h create mode 100644 esphome/components/lis2dw12_spi/__init__.py create mode 100644 esphome/components/lis2dw12_spi/lis2dw12_spi.cpp create mode 100644 esphome/components/lis2dw12_spi/lis2dw12_spi.h create mode 100644 tests/components/lis2dw12_i2c/common.yaml create mode 100644 tests/components/lis2dw12_i2c/test.esp32-idf.yaml create mode 100644 tests/components/lis2dw12_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/lis2dw12_i2c/test.rp2040-ard.yaml create mode 100644 tests/components/lis2dw12_spi/common.yaml create mode 100644 tests/components/lis2dw12_spi/test.esp32-idf.yaml create mode 100644 tests/components/lis2dw12_spi/test.esp8266-ard.yaml create mode 100644 tests/components/lis2dw12_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index db9dcccd7f6..192147f2902 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -311,6 +311,9 @@ esphome/components/libretiny_pwm/* @kuba2k2 esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz +esphome/components/lis2dw12/* @latonita +esphome/components/lis2dw12_i2c/* @latonita +esphome/components/lis2dw12_spi/* @latonita esphome/components/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882h_ble_tracker/* @Bl00d-B0b diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py new file mode 100644 index 00000000000..fcd885e5db4 --- /dev/null +++ b/esphome/components/lis2dw12/__init__.py @@ -0,0 +1,181 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_ID, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_OFFSET_Z, + CONF_POWER_MODE, + CONF_RANGE, + CONF_SWAP_XY, + CONF_TRANSFORM, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DOMAIN = "lis2dw12" + +CONF_LIS2DW12_ID = "lis2dw12_id" + +CONF_MIRROR_Z = "mirror_z" +CONF_ON_ACTIVE = "on_active" +CONF_ON_DOUBLE_TAP = "on_double_tap" +CONF_ON_FREEFALL = "on_freefall" +CONF_ON_ORIENTATION = "on_orientation" +CONF_ON_TAP = "on_tap" + +CONF_LOW_NOISE = "low_noise" +CONF_FILTER_BANDWIDTH = "filter_bandwidth" +CONF_OUTPUT_DATA_RATE = "output_data_rate" + +lis2dw12_ns = cg.esphome_ns.namespace("lis2dw12") +LIS2DW12Component = lis2dw12_ns.class_("LIS2DW12Component", cg.PollingComponent) + +LIS2DW12Range = lis2dw12_ns.enum("Range", True) +RANGES = { + "2G": LIS2DW12Range.RANGE_2G, + "4G": LIS2DW12Range.RANGE_4G, + "8G": LIS2DW12Range.RANGE_8G, + "16G": LIS2DW12Range.RANGE_16G, +} + +LIS2DW12PowerMode = lis2dw12_ns.enum("PowerMode", True) +POWER_MODES = { + "high_performance": LIS2DW12PowerMode.HIGH_PERF, + "low_power_1": LIS2DW12PowerMode.LOW_POWER_1, + "low_power_2": LIS2DW12PowerMode.LOW_POWER_2, + "low_power_3": LIS2DW12PowerMode.LOW_POWER_3, + "low_power_4": LIS2DW12PowerMode.LOW_POWER_4, +} + +LIS2DW12DataRate = lis2dw12_ns.enum("DataRate", True) +DATA_RATES = { + "1.6Hz": LIS2DW12DataRate.RATE_1_6HZ, + "12.5Hz": LIS2DW12DataRate.RATE_12_5HZ, + "25Hz": LIS2DW12DataRate.RATE_25HZ, + "50Hz": LIS2DW12DataRate.RATE_50HZ, + "100Hz": LIS2DW12DataRate.RATE_100HZ, + "200Hz": LIS2DW12DataRate.RATE_200HZ, + "400Hz": LIS2DW12DataRate.RATE_400HZ, + "800Hz": LIS2DW12DataRate.RATE_800HZ, + "1600Hz": LIS2DW12DataRate.RATE_1600HZ, +} + +LIS2DW12FilterBandwidth = lis2dw12_ns.enum("FilterBandwidth", True) +FILTER_BANDWIDTHS = { + "odr_div_2": LIS2DW12FilterBandwidth.BW_ODR_DIV_2, + "odr_div_4": LIS2DW12FilterBandwidth.BW_ODR_DIV_4, + "odr_div_10": LIS2DW12FilterBandwidth.BW_ODR_DIV_10, + "odr_div_20": LIS2DW12FilterBandwidth.BW_ODR_DIV_20, +} + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback"), + automation.CallbackAutomation(CONF_ON_DOUBLE_TAP, "add_on_double_tap_callback"), + automation.CallbackAutomation(CONF_ON_FREEFALL, "add_on_freefall_callback"), + automation.CallbackAutomation(CONF_ON_ACTIVE, "add_on_active_callback"), + automation.CallbackAutomation(CONF_ON_ORIENTATION, "add_on_orientation_callback"), +) + +_OFFSET = cv.float_range(min=-4.5, max=4.5) + +# The ODR code means different rates per power mode: 0001 is 1.6 Hz only in low-power modes +# (12.5 Hz in high performance) and every code above 200 Hz runs at 200 Hz in low-power modes +_LOW_POWER_ONLY_RATES = ("1.6Hz",) +_HIGH_PERF_ONLY_RATES = ("400Hz", "800Hz", "1600Hz") + + +def validate_data_rate(config: ConfigType) -> ConfigType: + """Reject rates the chip cannot run in the chosen power mode; apply after extending the base schema.""" + rate = str(config[CONF_OUTPUT_DATA_RATE]) + mode = str(config[CONF_POWER_MODE]) + high_perf = mode == "high_performance" + if (high_perf and rate in _LOW_POWER_ONLY_RATES) or ( + not high_perf and rate in _HIGH_PERF_ONLY_RATES + ): + raise cv.Invalid( + f"{CONF_OUTPUT_DATA_RATE} {rate} is not available with {CONF_POWER_MODE} {mode}", + [CONF_OUTPUT_DATA_RATE], + ) + return config + + +CONFIG_SCHEMA_BASE = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LIS2DW12Component), + cv.Optional(CONF_RANGE, default="2G"): cv.enum(RANGES, upper=True), + cv.Optional(CONF_POWER_MODE, default="high_performance"): cv.enum( + POWER_MODES, lower=True + ), + cv.Optional(CONF_OUTPUT_DATA_RATE, default="100Hz"): cv.enum(DATA_RATES), + cv.Optional(CONF_FILTER_BANDWIDTH, default="odr_div_2"): cv.enum( + FILTER_BANDWIDTHS, lower=True + ), + cv.Optional(CONF_LOW_NOISE, default=False): cv.boolean, + cv.Optional(CONF_CALIBRATION): cv.Schema( + { + cv.Optional(CONF_OFFSET_X, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Y, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Z, default=0): _OFFSET, + } + ), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Z, default=False): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional(CONF_ON_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_DOUBLE_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_FREEFALL): automation.validate_automation({}), + cv.Optional(CONF_ON_ACTIVE): automation.validate_automation({}), + cv.Optional(CONF_ON_ORIENTATION): automation.validate_automation({}), + } +).extend(cv.polling_component_schema("10s")) + +LIS2DW12_SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LIS2DW12_ID): cv.use_id(LIS2DW12Component), + } +) + + +async def to_code_base(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_range(config[CONF_RANGE])) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) + cg.add(var.set_output_data_rate(config[CONF_OUTPUT_DATA_RATE])) + cg.add(var.set_filter_bandwidth(config[CONF_FILTER_BANDWIDTH])) + cg.add(var.set_low_noise(config[CONF_LOW_NOISE])) + + if transform := config.get(CONF_TRANSFORM): + cg.add( + var.set_transform( + transform[CONF_MIRROR_X], + transform[CONF_MIRROR_Y], + transform[CONF_MIRROR_Z], + transform[CONF_SWAP_XY], + ) + ) + + if calibration_config := config.get(CONF_CALIBRATION): + cg.add( + var.set_offset( + calibration_config[CONF_OFFSET_X], + calibration_config[CONF_OFFSET_Y], + calibration_config[CONF_OFFSET_Z], + ) + ) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + return var diff --git a/esphome/components/lis2dw12/binary_sensor.py b/esphome/components/lis2dw12/binary_sensor.py new file mode 100644 index 00000000000..3ff0e8f15c2 --- /dev/null +++ b/esphome/components/lis2dw12/binary_sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_DOUBLE_TAP = "double_tap" +CONF_FREEFALL = "freefall" +CONF_TAP = "tap" + +ICON_DOUBLE_TAP = "mdi:gesture-double-tap" +ICON_FREEFALL = "mdi:parachute" +ICON_TAP = "mdi:gesture-tap" + +EVENT_SENSORS = (CONF_TAP, CONF_DOUBLE_TAP, CONF_FREEFALL, CONF_ACTIVE) +ICONS = (ICON_TAP, ICON_DOUBLE_TAP, ICON_FREEFALL, ICON_VIBRATE) + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(event): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_VIBRATION, + icon=icon, + ), + key=CONF_NAME, + ) + for event, icon in zip(EVENT_SENSORS, ICONS, strict=True) + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_TAP, hub.set_tap_binary_sensor) + await sensors(CONF_DOUBLE_TAP, hub.set_double_tap_binary_sensor) + await sensors(CONF_FREEFALL, hub.set_freefall_binary_sensor) + await sensors(CONF_ACTIVE, hub.set_active_binary_sensor) diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp new file mode 100644 index 00000000000..38065aa7859 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -0,0 +1,350 @@ +#include "lis2dw12.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12 { + +ESPHOME_LOG_TAG(TAG, "lis2dw12"); + +static constexpr uint8_t LIS2DW12_WHO_AM_I_VALUE = 0x44; +static constexpr float GRAVITY_EARTH = 9.80665f; +static constexpr uint32_t EVENT_COOLDOWN_MS = 500; +static constexpr uint32_t POLL_INTERVAL_MS = 50; +static constexpr uint8_t SIXD_POSITION_MASK = 0x3F; // XL..ZH, leaving out D6D_IA + +static const LogString *power_mode_to_string(PowerMode mode) { + switch (mode) { + case PowerMode::HIGH_PERF: + return LOG_STR("High Performance"); + case PowerMode::LOW_POWER_1: + return LOG_STR("Low Power 1 (12-bit)"); + case PowerMode::LOW_POWER_2: + return LOG_STR("Low Power 2"); + case PowerMode::LOW_POWER_3: + return LOG_STR("Low Power 3"); + case PowerMode::LOW_POWER_4: + return LOG_STR("Low Power 4"); + } + return LOG_STR("Unknown"); +} + +static const LogString *range_to_string(Range range) { + switch (range) { + case Range::RANGE_2G: + return LOG_STR("±2g"); + case Range::RANGE_4G: + return LOG_STR("±4g"); + case Range::RANGE_8G: + return LOG_STR("±8g"); + case Range::RANGE_16G: + return LOG_STR("±16g"); + } + return LOG_STR("Unknown"); +} + +static const LogString *data_rate_to_string(DataRate rate) { + switch (rate) { + case DataRate::RATE_1_6HZ: + return LOG_STR("1.6 Hz"); + case DataRate::RATE_12_5HZ: + return LOG_STR("12.5 Hz"); + case DataRate::RATE_25HZ: + return LOG_STR("25 Hz"); + case DataRate::RATE_50HZ: + return LOG_STR("50 Hz"); + case DataRate::RATE_100HZ: + return LOG_STR("100 Hz"); + case DataRate::RATE_200HZ: + return LOG_STR("200 Hz"); + case DataRate::RATE_400HZ: + return LOG_STR("400 Hz"); + case DataRate::RATE_800HZ: + return LOG_STR("800 Hz"); + case DataRate::RATE_1600HZ: + return LOG_STR("1600 Hz"); + } + return LOG_STR("Unknown"); +} + +static const LogString *filter_bandwidth_to_string(FilterBandwidth bw) { + switch (bw) { + case FilterBandwidth::BW_ODR_DIV_2: + return LOG_STR("ODR/2"); + case FilterBandwidth::BW_ODR_DIV_4: + return LOG_STR("ODR/4"); + case FilterBandwidth::BW_ODR_DIV_10: + return LOG_STR("ODR/10"); + case FilterBandwidth::BW_ODR_DIV_20: + return LOG_STR("ODR/20"); + } + return LOG_STR("Unknown"); +} + +void LIS2DW12Component::setup() { + uint8_t who_am_i{0}; + if (!this->read_byte(static_cast(RegisterMap::WHO_AM_I), &who_am_i) || who_am_i != LIS2DW12_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "WHO_AM_I check failed. Got 0x%02X, expected 0x%02X", who_am_i, LIS2DW12_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // Soft reset + if (!this->write_reg_(RegisterMap::CTRL2, 0x40)) { + this->mark_failed(LOG_STR("Soft reset failed")); + return; + } + delay(1); // NOLINT - wait for reset to complete + + uint8_t ctrl2{0}; + if (!this->read_byte(static_cast(RegisterMap::CTRL2), &ctrl2) || (ctrl2 & 0x40)) { + this->mark_failed(LOG_STR("Soft reset did not complete")); + return; + } + + if (!this->configure_registers_()) { + this->mark_failed(LOG_STR("Register configuration failed")); + return; + } + + // 0.244 mg/LSB at ±2g (14-bit), doubling per range step; 12-bit LP1 is 4x coarser + const bool lp1 = this->power_mode_ == PowerMode::LOW_POWER_1; + const uint8_t range_idx = static_cast(this->range_); + const float mg = 0.244f * (1u << range_idx) * (lp1 ? 4 : 1); + this->scale_ = mg * 0.001f * GRAVITY_EARTH; + this->shift_ = lp1 ? 4 : 2; + + bool events_used = !this->tap_callback_.empty() || !this->double_tap_callback_.empty() || + !this->freefall_callback_.empty() || !this->active_callback_.empty() || + !this->orientation_callback_.empty(); +#ifdef USE_BINARY_SENSOR + for (auto *sensor : this->event_binary_sensors_) { + if (sensor != nullptr) { + sensor->publish_initial_state(false); + events_used = true; + } + } +#endif +#ifdef USE_TEXT_SENSOR + RegSixdSrc sixd; + if (this->orientation_text_sensor_ != nullptr && + this->read_byte(static_cast(RegisterMap::SIXD_SRC), &sixd.raw)) { + this->publish_orientation_(sixd); + events_used = true; + } +#endif + if (!events_used) { + this->disable_loop(); // nothing consumes the events, so skip polling the chip + } +} + +bool LIS2DW12Component::configure_registers_() { + // CTRL1: ODR[7:4], MODE[3:2] (00=low power, 01=high performance), LP_MODE[1:0] (LP1..LP4) + const uint8_t odr_bits = static_cast(this->data_rate_) << 4; + uint8_t ctrl1; + if (this->power_mode_ == PowerMode::HIGH_PERF) { + ctrl1 = odr_bits | 0x04; + } else { + ctrl1 = odr_bits | ((static_cast(this->power_mode_) - 1) & 0x03); + } + + // CTRL6: BW_FILT[7:6], FS[5:4], LOW_NOISE[2] + uint8_t ctrl6 = (static_cast(this->filter_bandwidth_) << 6) | (static_cast(this->range_) << 4); + if (this->low_noise_) { + ctrl6 |= 0x04; + } + + return this->write_reg_(RegisterMap::CTRL2, 0x0C) && // BDU, IF_ADD_INC + this->write_reg_(RegisterMap::CTRL1, ctrl1) && // ODR and power mode + this->write_reg_(RegisterMap::CTRL6, ctrl6) && // bandwidth, range, low noise + this->write_reg_(RegisterMap::TAP_THS_X, 0x29) && // 6D_THS=70 degrees, TAP_THSX=9 + this->write_reg_(RegisterMap::TAP_THS_Y, 0x09) && // TAP_THSY=9 + this->write_reg_(RegisterMap::TAP_THS_Z, 0xE9) && // TAP_THSZ=9, tap on X/Y/Z + this->write_reg_(RegisterMap::INT_DUR, 0x76) && // LATENCY=7, QUIET=1, SHOCK=2 + this->write_reg_(RegisterMap::WAKE_UP_THS, 0x82) && // double tap enabled, WU_THS=2 + this->write_reg_(RegisterMap::WAKE_UP_DUR, 0x00) && // no wake/sleep durations + this->write_reg_(RegisterMap::FREE_FALL, 0x33) && // FF_DUR=6, FF_THS=312 mg + this->write_reg_(RegisterMap::CTRL4_INT1, 0xF8) && // 6D, single tap, wake-up, free-fall, double tap to INT1 + this->write_reg_(RegisterMap::CTRL3, 0x10) && // LIR: latched interrupts + this->write_reg_(RegisterMap::CTRL7, 0x20); // INTERRUPTS_ENABLE +} + +void LIS2DW12Component::dump_config() { + ESP_LOGCONFIG(TAG, "LIS2DW12:"); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + ESP_LOGCONFIG(TAG, + " Power Mode: %s\n" + " Data Rate: %s\n" + " Range: %s\n" + " Filter Bandwidth: %s\n" + " Low Noise: %s\n" + " Offsets: {%.3f m/s², %.3f m/s², %.3f m/s²}\n" + " Transform: {mirror_x=%s, mirror_y=%s, mirror_z=%s, swap_xy=%s}", + LOG_STR_ARG(power_mode_to_string(this->power_mode_)), + LOG_STR_ARG(data_rate_to_string(this->data_rate_)), LOG_STR_ARG(range_to_string(this->range_)), + LOG_STR_ARG(filter_bandwidth_to_string(this->filter_bandwidth_)), YESNO(this->low_noise_), + this->offset_x_, this->offset_y_, this->offset_z_, YESNO(this->mirror_x_), YESNO(this->mirror_y_), + YESNO(this->mirror_z_), YESNO(this->swap_xy_)); + LOG_UPDATE_INTERVAL(this); + +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Tap", this->event_binary_sensors_[EVENT_TAP]); + LOG_BINARY_SENSOR(" ", "Double Tap", this->event_binary_sensors_[EVENT_DOUBLE_TAP]); + LOG_BINARY_SENSOR(" ", "Freefall", this->event_binary_sensors_[EVENT_FREEFALL]); + LOG_BINARY_SENSOR(" ", "Active", this->event_binary_sensors_[EVENT_ACTIVE]); +#endif + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Acceleration X", this->acceleration_x_sensor_); + LOG_SENSOR(" ", "Acceleration Y", this->acceleration_y_sensor_); + LOG_SENSOR(" ", "Acceleration Z", this->acceleration_z_sensor_); +#endif + +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Orientation", this->orientation_text_sensor_); +#endif +} + +bool LIS2DW12Component::read_acceleration_data_(float &x, float &y, float &z) { + uint8_t accel_data[6]; + if (!this->read_bytes(static_cast(RegisterMap::OUT_X_L), accel_data, 6)) { + return false; + } + + int16_t raw_x = static_cast(encode_uint16(accel_data[1], accel_data[0])) >> this->shift_; + int16_t raw_y = static_cast(encode_uint16(accel_data[3], accel_data[2])) >> this->shift_; + int16_t raw_z = static_cast(encode_uint16(accel_data[5], accel_data[4])) >> this->shift_; + + if (this->swap_xy_) { + std::swap(raw_x, raw_y); + } + x = (this->mirror_x_ ? -raw_x : raw_x) * this->scale_ + this->offset_x_; + y = (this->mirror_y_ ? -raw_y : raw_y) * this->scale_ + this->offset_y_; + z = (this->mirror_z_ ? -raw_z : raw_z) * this->scale_ + this->offset_z_; + return true; +} + +#ifdef USE_TEXT_SENSOR +static const char *orientation_to_string(RegSixdSrc sixd) { + if (sixd.xh) + return "X Up"; + if (sixd.xl) + return "X Down"; + if (sixd.yh) + return "Y Up"; + if (sixd.yl) + return "Y Down"; + if (sixd.zh) + return "Z Up"; + if (sixd.zl) + return "Z Down"; + return "Unknown"; +} + +void LIS2DW12Component::publish_orientation_(RegSixdSrc sixd) { + const uint8_t position = sixd.raw & SIXD_POSITION_MASK; + if (this->orientation_text_sensor_ == nullptr || position == this->sixd_last_) { + return; + } + this->sixd_last_ = position; + this->orientation_text_sensor_->publish_state(orientation_to_string(sixd)); +} +#endif + +void LIS2DW12Component::handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback) { + const uint8_t bit = 1 << event; + bool state; + if (active) { + if (now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->last_event_ms_[event] = now; + callback.call(); + if (this->events_on_ & bit) { + return; + } + this->events_on_ |= bit; + state = true; + } else { + if (!(this->events_on_ & bit) || now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->events_on_ &= ~bit; + state = false; + } +#ifdef USE_BINARY_SENSOR + if (this->event_binary_sensors_[event] != nullptr) { + this->event_binary_sensors_[event]->publish_state(state); + } +#endif +} + +void LIS2DW12Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ms_ < POLL_INTERVAL_MS) { + return; + } + this->last_poll_ms_ = now; + + // SIXD_SRC and ALL_INT_SRC are adjacent, so one read captures both before any latch clears + uint8_t regs[2]; + if (!this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs))) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + RegSixdSrc sixd; + sixd.raw = regs[0]; + RegAllIntSrc src; + src.raw = regs[1]; + + this->handle_event_(EVENT_TAP, src.single_tap, now, this->tap_callback_); + this->handle_event_(EVENT_DOUBLE_TAP, src.double_tap, now, this->double_tap_callback_); + this->handle_event_(EVENT_FREEFALL, src.ff_ia, now, this->freefall_callback_); + this->handle_event_(EVENT_ACTIVE, src.wu_ia, now, this->active_callback_); + + if (sixd.d6d_ia) { + this->orientation_callback_.call(); +#ifdef USE_TEXT_SENSOR + this->publish_orientation_(sixd); +#endif + } +} + +void LIS2DW12Component::update() { + float x, y, z; + if (!this->read_acceleration_data_(x, y, z)) { + this->status_set_warning(); + return; + } + + ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}", x, y, z); + +#ifdef USE_SENSOR + if (this->acceleration_x_sensor_ != nullptr) + this->acceleration_x_sensor_->publish_state(x); + if (this->acceleration_y_sensor_ != nullptr) + this->acceleration_y_sensor_->publish_state(y); + if (this->acceleration_z_sensor_ != nullptr) + this->acceleration_z_sensor_->publish_state(z); +#endif + + this->status_clear_warning(); +} + +void LIS2DW12Component::set_offset(float offset_x, float offset_y, float offset_z) { + this->offset_x_ = offset_x; + this->offset_y_ = offset_y; + this->offset_z_ = offset_z; +} + +void LIS2DW12Component::set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy) { + this->mirror_x_ = mirror_x; + this->mirror_y_ = mirror_y; + this->mirror_z_ = mirror_z; + this->swap_xy_ = swap_xy; +} + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/lis2dw12.h b/esphome/components/lis2dw12/lis2dw12.h new file mode 100644 index 00000000000..11c1e6b8628 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.h @@ -0,0 +1,198 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +namespace esphome::lis2dw12 { + +// LIS2DW12 Register Map +enum class RegisterMap : uint8_t { + WHO_AM_I = 0x0F, + CTRL1 = 0x20, + CTRL2 = 0x21, + CTRL3 = 0x22, + CTRL4_INT1 = 0x23, + CTRL6 = 0x25, + OUT_X_L = 0x28, + TAP_THS_X = 0x30, + TAP_THS_Y = 0x31, + TAP_THS_Z = 0x32, + INT_DUR = 0x33, + WAKE_UP_THS = 0x34, + WAKE_UP_DUR = 0x35, + FREE_FALL = 0x36, + SIXD_SRC = 0x3A, + ALL_INT_SRC = 0x3B, + CTRL7 = 0x3F, +}; + +enum class Range : uint8_t { + RANGE_2G = 0b00, + RANGE_4G = 0b01, + RANGE_8G = 0b10, + RANGE_16G = 0b11, +}; + +enum class PowerMode : uint8_t { + HIGH_PERF = 0, + LOW_POWER_1 = 1, + LOW_POWER_2 = 2, + LOW_POWER_3 = 3, + LOW_POWER_4 = 4, +}; + +enum class DataRate : uint8_t { + RATE_1_6HZ = 0b0001, + RATE_12_5HZ = 0b0010, + RATE_25HZ = 0b0011, + RATE_50HZ = 0b0100, + RATE_100HZ = 0b0101, + RATE_200HZ = 0b0110, + RATE_400HZ = 0b0111, + RATE_800HZ = 0b1000, + RATE_1600HZ = 0b1001, +}; + +enum class FilterBandwidth : uint8_t { + BW_ODR_DIV_2 = 0b00, + BW_ODR_DIV_4 = 0b01, + BW_ODR_DIV_10 = 0b10, + BW_ODR_DIV_20 = 0b11, +}; + +// ALL_INT_SRC register (0x3B) +union RegAllIntSrc { + struct { + bool ff_ia : 1; + bool wu_ia : 1; + bool single_tap : 1; + bool double_tap : 1; + bool d6d_ia : 1; + bool sleep_change_ia : 1; + uint8_t reserved : 2; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// SIXD_SRC register (0x3A) +union RegSixdSrc { + struct { + bool xl : 1; + bool xh : 1; + bool yl : 1; + bool yh : 1; + bool zl : 1; + bool zh : 1; + bool d6d_ia : 1; + uint8_t reserved : 1; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// Debounced events, also the bit index into events_on_ +enum Event : uint8_t { + EVENT_TAP = 0, + EVENT_DOUBLE_TAP, + EVENT_FREEFALL, + EVENT_ACTIVE, + EVENT_COUNT, +}; + +class LIS2DW12Component : public PollingComponent { + public: + void setup() override; + void dump_config() override; + void loop() override; + void update() override; + + void set_power_mode(PowerMode mode) { this->power_mode_ = mode; } + void set_low_noise(bool low_noise) { this->low_noise_ = low_noise; } + void set_output_data_rate(DataRate rate) { this->data_rate_ = rate; } + void set_range(Range range) { this->range_ = range; } + void set_filter_bandwidth(FilterBandwidth bw) { this->filter_bandwidth_ = bw; } + void set_offset(float offset_x, float offset_y, float offset_z); + void set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy); + + bool read_byte(uint8_t a_register, uint8_t *data) { return this->read_bytes(a_register, data, 1); } + virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; + virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + +#ifdef USE_BINARY_SENSOR + void set_tap_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_TAP] = s; } + void set_double_tap_binary_sensor(binary_sensor::BinarySensor *s) { + this->event_binary_sensors_[EVENT_DOUBLE_TAP] = s; + } + void set_freefall_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_FREEFALL] = s; } + void set_active_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_ACTIVE] = s; } +#endif + +#ifdef USE_SENSOR + SUB_SENSOR(acceleration_x) + SUB_SENSOR(acceleration_y) + SUB_SENSOR(acceleration_z) +#endif + +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(orientation) +#endif + + template void add_on_tap_callback(F &&callback) { this->tap_callback_.add(std::forward(callback)); } + template void add_on_double_tap_callback(F &&callback) { + this->double_tap_callback_.add(std::forward(callback)); + } + template void add_on_freefall_callback(F &&callback) { + this->freefall_callback_.add(std::forward(callback)); + } + template void add_on_active_callback(F &&callback) { + this->active_callback_.add(std::forward(callback)); + } + template void add_on_orientation_callback(F &&callback) { + this->orientation_callback_.add(std::forward(callback)); + } + + protected: + bool write_reg_(RegisterMap reg, uint8_t value) { return this->write_byte(static_cast(reg), value); } + bool configure_registers_(); + bool read_acceleration_data_(float &x, float &y, float &z); + void handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback); +#ifdef USE_TEXT_SENSOR + void publish_orientation_(RegSixdSrc sixd); +#endif + + float offset_x_{0}, offset_y_{0}, offset_z_{0}; + float scale_{0}; // m/s² per LSB after shift_ + uint32_t last_event_ms_[EVENT_COUNT]{}; + uint32_t last_poll_ms_{0}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *event_binary_sensors_[EVENT_COUNT]{}; +#endif + LazyCallbackManager tap_callback_; + LazyCallbackManager double_tap_callback_; + LazyCallbackManager freefall_callback_; + LazyCallbackManager active_callback_; + LazyCallbackManager orientation_callback_; + + PowerMode power_mode_{PowerMode::HIGH_PERF}; + DataRate data_rate_{DataRate::RATE_100HZ}; + Range range_{Range::RANGE_2G}; + FilterBandwidth filter_bandwidth_{FilterBandwidth::BW_ODR_DIV_2}; + bool low_noise_{false}; + bool mirror_x_{false}, mirror_y_{false}, mirror_z_{false}, swap_xy_{false}; + uint8_t shift_{2}; // data is left-justified: 14-bit >> 2, 12-bit (LP1) >> 4 + uint8_t events_on_{0}; // bit per Event: binary sensor currently published on +#ifdef USE_TEXT_SENSOR + uint8_t sixd_last_{0xFF}; // position bits only; 0xFF cannot occur, so the first read publishes +#endif +}; + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/sensor.py b/esphome/components/lis2dw12/sensor.py new file mode 100644 index 00000000000..9f4a4e570fc --- /dev/null +++ b/esphome/components/lis2dw12/sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCELERATION_X, + CONF_ACCELERATION_Y, + CONF_ACCELERATION_Z, + CONF_NAME, + ICON_ACCELERATION, + STATE_CLASS_MEASUREMENT, + UNIT_METER_PER_SECOND_SQUARED, +) +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +ACCELERATION_SENSORS = (CONF_ACCELERATION_X, CONF_ACCELERATION_Y, CONF_ACCELERATION_Z) + +accel_schema = cv.maybe_simple_value( + sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND_SQUARED, + icon=ICON_ACCELERATION, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ), + key=CONF_NAME, +) + + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + {cv.Optional(key): accel_schema for key in ACCELERATION_SENSORS} +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACCELERATION_X, hub.set_acceleration_x_sensor) + await sensors(CONF_ACCELERATION_Y, hub.set_acceleration_y_sensor) + await sensors(CONF_ACCELERATION_Z, hub.set_acceleration_z_sensor) diff --git a/esphome/components/lis2dw12/text_sensor.py b/esphome/components/lis2dw12/text_sensor.py new file mode 100644 index 00000000000..b292cc87cc2 --- /dev/null +++ b/esphome/components/lis2dw12/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, ICON_SCREEN_ROTATION +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_ORIENTATION = "orientation" + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(CONF_ORIENTATION): cv.maybe_simple_value( + text_sensor.text_sensor_schema(icon=ICON_SCREEN_ROTATION), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_ORIENTATION, hub.set_orientation_text_sensor) diff --git a/esphome/components/lis2dw12_i2c/__init__.py b/esphome/components/lis2dw12_i2c/__init__.py new file mode 100644 index 00000000000..39cbd46a8de --- /dev/null +++ b/esphome/components/lis2dw12_i2c/__init__.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lis2dw12_i2c" +MULTI_CONF = True + +lis2dw12_i2c_ns = cg.esphome_ns.namespace("lis2dw12_i2c") +LIS2DW12I2CComponent = lis2dw12_i2c_ns.class_( + "LIS2DW12I2CComponent", LIS2DW12Component, i2c.I2CDevice +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12I2CComponent), + } + ).extend(i2c.i2c_device_schema(0x19)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp new file mode 100644 index 00000000000..3956f0a42de --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp @@ -0,0 +1,13 @@ +#include "lis2dw12_i2c.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_i2c { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_i2c"); + +void LIS2DW12I2CComponent::dump_config() { + LOG_I2C_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h new file mode 100644 index 00000000000..aa6069e434b --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/lis2dw12/lis2dw12.h" + +namespace esphome::lis2dw12_i2c { + +class LIS2DW12I2CComponent : public lis2dw12::LIS2DW12Component, public i2c::I2CDevice { + public: + bool write_byte(uint8_t a_register, uint8_t data) override { return i2c::I2CDevice::write_byte(a_register, data); } + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + return i2c::I2CDevice::read_bytes(a_register, data, len); + } + void dump_config() override; +}; + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_spi/__init__.py b/esphome/components/lis2dw12_spi/__init__.py new file mode 100644 index 00000000000..1b0815d6911 --- /dev/null +++ b/esphome/components/lis2dw12_spi/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["spi"] +DOMAIN = "lis2dw12_spi" +MULTI_CONF = True + +lis2dw12_spi_ns = cg.esphome_ns.namespace("lis2dw12_spi") +LIS2DW12SPIComponent = lis2dw12_spi_ns.class_( + "LIS2DW12SPIComponent", + LIS2DW12Component, + spi.SPIDevice, +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12SPIComponent), + } + ).extend(spi.spi_device_schema(cs_pin_required=True)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp new file mode 100644 index 00000000000..0ca013ef6ca --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp @@ -0,0 +1,34 @@ +#include "lis2dw12_spi.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_spi { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_spi"); + +void LIS2DW12SPIComponent::setup() { + this->spi_setup(); + lis2dw12::LIS2DW12Component::setup(); +} + +void LIS2DW12SPIComponent::dump_config() { + LOG_SPI_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +bool LIS2DW12SPIComponent::write_byte(uint8_t a_register, uint8_t data) { + this->enable(); + this->transfer_byte(a_register & 0x3F); + this->transfer_byte(data); + this->disable(); + return true; +} + +bool LIS2DW12SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register | 0x80); + this->read_array(data, len); + this->disable(); + return true; +} + +} // namespace esphome::lis2dw12_spi diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.h b/esphome/components/lis2dw12_spi/lis2dw12_spi.h new file mode 100644 index 00000000000..0b9b3a2e54c --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/lis2dw12/lis2dw12.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::lis2dw12_spi { + +class LIS2DW12SPIComponent : public lis2dw12::LIS2DW12Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + bool write_byte(uint8_t a_register, uint8_t data) override; + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; +}; + +} // namespace esphome::lis2dw12_spi diff --git a/tests/components/lis2dw12_i2c/common.yaml b/tests/components/lis2dw12_i2c/common.yaml new file mode 100644 index 00000000000..c0a58a00db7 --- /dev/null +++ b/tests/components/lis2dw12_i2c/common.yaml @@ -0,0 +1,60 @@ +lis2dw12_i2c: + id: lis2dw12_i2c_accel + i2c_id: i2c_bus + range: 4G + power_mode: high_performance + output_data_rate: 100Hz + filter_bandwidth: odr_div_2 + low_noise: true + update_interval: 10s + calibration: + offset_x: -0.250 + offset_y: -0.400 + offset_z: -0.800 + transform: + mirror_x: false + mirror_y: true + mirror_z: true + swap_xy: false + on_tap: + - then: + - logger.log: "Tapped" + - then: + - logger.log: "Tapped again" + on_double_tap: + - then: + - logger.log: "Double tapped" + on_freefall: + - then: + - logger.log: "Freefall detected" + on_active: + - then: + - logger.log: "Activity detected" + on_orientation: + - then: + - logger.log: "Orientation changed" + +sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + acceleration_x: Accel X + acceleration_y: Accel Y + acceleration_z: Accel Z + +text_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + orientation: Orientation + +binary_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + tap: Single tap + double_tap: + name: Double tap + freefall: + name: Freefall + active: + name: Active + filters: + - delayed_off: 5000ms diff --git a/tests/components/lis2dw12_i2c/test.esp32-idf.yaml b/tests/components/lis2dw12_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..e5af1541923 --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml b/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..77fd8e299bb --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml b/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml new file mode 100644 index 00000000000..286ab79298e --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_spi/common.yaml b/tests/components/lis2dw12_spi/common.yaml new file mode 100644 index 00000000000..88f730a83de --- /dev/null +++ b/tests/components/lis2dw12_spi/common.yaml @@ -0,0 +1,61 @@ +lis2dw12_spi: + id: lis2dw12_spi_accel + spi_id: spi_bus + cs_pin: GPIO5 + range: 4G + power_mode: high_performance + output_data_rate: 100Hz + filter_bandwidth: odr_div_2 + low_noise: true + update_interval: 10s + calibration: + offset_x: -0.250 + offset_y: -0.400 + offset_z: -0.800 + transform: + mirror_x: false + mirror_y: true + mirror_z: true + swap_xy: false + on_tap: + - then: + - logger.log: "Tapped" + - then: + - logger.log: "Tapped again" + on_double_tap: + - then: + - logger.log: "Double tapped" + on_freefall: + - then: + - logger.log: "Freefall detected" + on_active: + - then: + - logger.log: "Activity detected" + on_orientation: + - then: + - logger.log: "Orientation changed" + +sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + acceleration_x: Accel X + acceleration_y: Accel Y + acceleration_z: Accel Z + +text_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + orientation: Orientation + +binary_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + tap: Single tap + double_tap: + name: Double tap + freefall: + name: Freefall + active: + name: Active + filters: + - delayed_off: 5000ms diff --git a/tests/components/lis2dw12_spi/test.esp32-idf.yaml b/tests/components/lis2dw12_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..fcb5a6a526c --- /dev/null +++ b/tests/components/lis2dw12_spi/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + lis2dw12_spi: !include common.yaml diff --git a/tests/components/lis2dw12_spi/test.esp8266-ard.yaml b/tests/components/lis2dw12_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..d287bfe46a1 --- /dev/null +++ b/tests/components/lis2dw12_spi/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + lis2dw12_spi: !include common.yaml diff --git a/tests/components/lis2dw12_spi/test.rp2040-ard.yaml b/tests/components/lis2dw12_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..62203267027 --- /dev/null +++ b/tests/components/lis2dw12_spi/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + lis2dw12_spi: !include common.yaml From 0f087f425ab56be33ed88bcdd81f43777a0037b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Vallejo?= Date: Mon, 12 Oct 2026 22:52:04 +0200 Subject: [PATCH 389/435] [ld2460] Add HLK-LD2460 radar hub with target sensors (#19002) 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/ld2460/__init__.py | 47 ++++ esphome/components/ld2460/ld2460.cpp | 230 ++++++++++++++++++ esphome/components/ld2460/ld2460.h | 67 +++++ esphome/components/ld2460/sensor.py | 87 +++++++ tests/components/ld2460/common.h | 63 +++++ tests/components/ld2460/common.yaml | 20 ++ .../ld2460/sensor/ld2460_readline.cpp | 183 ++++++++++++++ tests/components/ld2460/test.esp32-idf.yaml | 3 + tests/components/ld2460/test.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_ld2460.yaml | 141 +++++++++++ tests/integration/test_uart_mock_ld2460.py | 160 ++++++++++++ 12 files changed, 1005 insertions(+) create mode 100644 esphome/components/ld2460/__init__.py create mode 100644 esphome/components/ld2460/ld2460.cpp create mode 100644 esphome/components/ld2460/ld2460.h create mode 100644 esphome/components/ld2460/sensor.py create mode 100644 tests/components/ld2460/common.h create mode 100644 tests/components/ld2460/common.yaml create mode 100644 tests/components/ld2460/sensor/ld2460_readline.cpp create mode 100644 tests/components/ld2460/test.esp32-idf.yaml create mode 100644 tests/components/ld2460/test.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2460.yaml create mode 100644 tests/integration/test_uart_mock_ld2460.py diff --git a/CODEOWNERS b/CODEOWNERS index 192147f2902..2db90be68e3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -303,6 +303,7 @@ esphome/components/ld2410/* @regevbr @sebcaps esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu +esphome/components/ld2460/* @jesusvallejo esphome/components/ld24xx/* @kbx81 esphome/components/ld6002b/* @hepter esphome/components/ledc/* @OttoWinter diff --git a/esphome/components/ld2460/__init__.py b/esphome/components/ld2460/__init__.py new file mode 100644 index 00000000000..88c3a173efe --- /dev/null +++ b/esphome/components/ld2460/__init__.py @@ -0,0 +1,47 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.types import ConfigType + +AUTO_LOAD = ["ld24xx"] +DEPENDENCIES = ["uart"] +CODEOWNERS = ["@jesusvallejo"] +DOMAIN = "ld2460" +MULTI_CONF = True + +ld2460_ns = cg.esphome_ns.namespace("ld2460") +LD2460Component = ld2460_ns.class_("LD2460Component", cg.Component, uart.UARTDevice) + +CONF_LD2460_ID = "ld2460_id" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2460Component), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp new file mode 100644 index 00000000000..2efd34a1d8c --- /dev/null +++ b/esphome/components/ld2460/ld2460.cpp @@ -0,0 +1,230 @@ +#include "ld2460.h" + +#include "esphome/core/hal.h" + +#include +#include +#include + +namespace esphome::ld2460 { + +ESPHOME_LOG_TAG(TAG, "ld2460"); + +static constexpr uint8_t HEADER_FOOTER_SIZE = 4; +// PROGMEM keeps the patterns out of RAM on the ESP8266; they are copied out for each compare +static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; + +static constexpr uint8_t CMD_REPORT_DATA = 0x04; +static constexpr uint8_t CMD_QUERY_INSTALLATION_MODE = 0x0A; +static constexpr uint8_t ACK_FRAME_LENGTH = 12; +static constexpr uint8_t MAX_QUERY_ATTEMPTS = 3; +// Header, command, length (12, little endian), payload 0x01, footer +static constexpr uint8_t QUERY_INSTALLATION_MODE_FRAME[] PROGMEM = { + 0xFD, 0xFC, 0xFB, 0xFA, CMD_QUERY_INSTALLATION_MODE, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01, +}; + +static bool matches(const uint8_t *bytes, const uint8_t *pattern) { + uint8_t copy[HEADER_FOOTER_SIZE]; + progmem_memcpy(copy, pattern, sizeof(copy)); + return std::memcmp(bytes, copy, sizeof(copy)) == 0; +} + +// Header (4), command (1), length (2, little endian); the length counts the whole frame +static constexpr uint8_t FRAME_PREFIX_SIZE = 7; +static uint16_t frame_length(const uint8_t *frame) { return encode_uint16(frame[6], frame[5]); } + +static constexpr float RADIANS_TO_DEGREES = 180.0f / std::numbers::pi_v; + +void LD2460Component::setup() { this->send_query_(); } + +void LD2460Component::send_query_() { + uint8_t frame[sizeof(QUERY_INSTALLATION_MODE_FRAME)]; + progmem_memcpy(frame, QUERY_INSTALLATION_MODE_FRAME, sizeof(frame)); + this->write_array(frame, sizeof(frame)); +} + +void LD2460Component::dump_config() { + ESP_LOGCONFIG(TAG, + "LD2460:\n" + " Installation mode: %s", + this->query_attempts_ != 0 ? LOG_STR_LITERAL("unknown") + : this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetCount", this->target_count_sensor_); + for (const auto &t : this->target_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetX", t.x); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetY", t.y); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetDistance", t.distance); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetAngle", t.angle); + } +#endif +} + +void LD2460Component::loop() { + size_t avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } + } +} + +void LD2460Component::handle_periodic_data_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(DATA_FRAME_HEADER) || data[4] != CMD_REPORT_DATA) { + ESP_LOGW(TAG, "Invalid periodic data header"); + return; + } + const uint8_t packet_len = this->buffer_pos_; + if (packet_len < 11 || (packet_len - 11) % 4 != 0) { + ESP_LOGW(TAG, "Periodic packet length %u is not a whole number of targets", packet_len); + return; + } + + if (this->query_attempts_ != 0) { + if (this->query_attempts_ < MAX_QUERY_ATTEMPTS) { + // The module may have booted after us and missed the earlier query + this->query_attempts_++; + this->send_query_(); + } else if (this->query_attempts_ == MAX_QUERY_ATTEMPTS) { + this->query_attempts_++; + ESP_LOGW(TAG, "No installation mode reply after %u queries; assuming side mount", MAX_QUERY_ATTEMPTS); + } + } + +#ifdef USE_SENSOR + uint8_t num_targets = std::min((packet_len - 11) / 4, MAX_TARGETS); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + TargetSensors &t = this->target_sensors_[i]; + if (i >= num_targets) { + t.last_x = INT16_MIN; + t.last_y = INT16_MIN; + SAFE_PUBLISH_SENSOR_UNKNOWN(t.x); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.y); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.distance); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.angle); + continue; + } + const uint8_t *p = &data[7 + i * 4]; + auto raw_x = static_cast(encode_uint16(p[1], p[0])); + auto raw_y = static_cast(encode_uint16(p[3], p[2])); + if (raw_x == t.last_x && raw_y == t.last_y) { + continue; + } + t.last_x = raw_x; + t.last_y = raw_y; + + // Coordinates are reported in 0.1 m steps + float x = raw_x * 0.1f; + float y = raw_y * 0.1f; + SAFE_PUBLISH_SENSOR(t.x, x); + SAFE_PUBLISH_SENSOR(t.y, y); + if (t.distance.has_sensor()) { + SAFE_PUBLISH_SENSOR(t.distance, std::sqrt(x * x + y * y)); + } + if (t.angle.has_sensor()) { + float angle; + if (this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP) { + angle = std::atan2(y, x) * RADIANS_TO_DEGREES; + if (angle < 0.0f) { + angle += 360.0f; + } + } else { + angle = std::atan2(x, y) * RADIANS_TO_DEGREES; + } + SAFE_PUBLISH_SENSOR(t.angle, angle); + } + } + SAFE_PUBLISH_SENSOR(this->target_count_sensor_, num_targets); +#endif + + this->data_callback_.call(); +} + +void LD2460Component::readline_(uint8_t readch) { + if (this->buffer_pos_ >= MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Max frame length exceeded; ignoring"); + this->buffer_pos_ = 0; + } + this->buffer_data_[this->buffer_pos_++] = readch; + + if (this->buffer_pos_ < HEADER_FOOTER_SIZE) { + return; + } + const uint8_t *tail = &this->buffer_data_[this->buffer_pos_ - HEADER_FOOTER_SIZE]; + bool is_data = readch == progmem_read_byte(&DATA_FRAME_FOOTER[3]) && matches(tail, DATA_FRAME_FOOTER); + if (!is_data && (readch != progmem_read_byte(&CMD_FRAME_FOOTER[3]) || !matches(tail, CMD_FRAME_FOOTER))) { + return; + } + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; + ESP_LOGV(TAG, "Frame: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); +#endif + + if (is_data) { + this->handle_periodic_data_(); + } else { + this->handle_ack_(); + } + this->buffer_pos_ = 0; +} + +// Once a footer is matched, shift the buffer to the first offset holding a header whose length +// field ends the frame exactly at the footer. Stray bytes, truncated frames and header bytes inside +// a payload are skipped this way. Returns false when no such offset exists. +bool LD2460Component::align_to_header_(const uint8_t *header) { + for (uint8_t i = 0; i + FRAME_PREFIX_SIZE <= this->buffer_pos_; i++) { + const uint8_t *frame = &this->buffer_data_[i]; + if (matches(frame, header) && frame_length(frame) == this->buffer_pos_ - i) { + if (i != 0) { + this->buffer_pos_ -= i; + std::memmove(this->buffer_data_, frame, this->buffer_pos_); + } + return true; + } + } + return false; +} + +void LD2460Component::handle_ack_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(CMD_FRAME_HEADER) || this->buffer_pos_ != ACK_FRAME_LENGTH) { + ESP_LOGW(TAG, "Dropping malformed command frame"); + return; + } + if (data[4] != CMD_QUERY_INSTALLATION_MODE) { + ESP_LOGD(TAG, "Ignoring reply to command 0x%02X", data[4]); + return; + } + const uint8_t mode = data[7]; + if (mode != static_cast(InstallationMode::INSTALLATION_MODE_SIDE) && + mode != static_cast(InstallationMode::INSTALLATION_MODE_TOP)) { + ESP_LOGW(TAG, "Unexpected installation mode %u", mode); + return; + } + this->installation_mode_ = static_cast(mode); + this->query_attempts_ = 0; + ESP_LOGD(TAG, "Installation mode: %s", + mode == static_cast(InstallationMode::INSTALLATION_MODE_TOP) ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + // Angles depend on the mode, so republish every target on the next frame + for (auto &t : this->target_sensors_) { + t.last_x = INT16_MIN; + } +#endif +} + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/ld2460.h b/esphome/components/ld2460/ld2460.h new file mode 100644 index 00000000000..aa7679f39a1 --- /dev/null +++ b/esphome/components/ld2460/ld2460.h @@ -0,0 +1,67 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/components/ld24xx/ld24xx.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::ld2460 { + +static constexpr uint8_t MAX_LINE_LENGTH = 32; // Longest frame is a 5-target data frame of 31 bytes +static constexpr uint8_t MAX_TARGETS = 5; + +enum class InstallationMode : uint8_t { + INSTALLATION_MODE_SIDE = 1, + INSTALLATION_MODE_TOP = 2, +}; + +class LD2460Component : public Component, public uart::UARTDevice { +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(target_count, uint8_t) +#endif + + public: + void setup() override; + void dump_config() override; + void loop() override; + +#ifdef USE_SENSOR + void set_target_x_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].x.set_sensor(s); } + void set_target_y_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].y.set_sensor(s); } + void set_target_distance_sensor(uint8_t target, sensor::Sensor *s) { + this->target_sensors_[target].distance.set_sensor(s); + } + void set_target_angle_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].angle.set_sensor(s); } +#endif + + /// Add a callback that will be called after each successfully processed periodic data frame. + template void add_on_data_callback(F &&callback) { this->data_callback_.add(std::forward(callback)); } + + protected: + void send_query_(); + bool align_to_header_(const uint8_t *header); + void handle_periodic_data_(); + void handle_ack_(); + void readline_(uint8_t readch); + +#ifdef USE_SENSOR + // INT16_MIN marks a slot with no published coordinates, so the next report always publishes + struct TargetSensors { + ld24xx::SensorWithDedup x, y, distance, angle; + int16_t last_x{INT16_MIN}; + int16_t last_y{INT16_MIN}; + }; + std::array target_sensors_{}; +#endif + + LazyCallbackManager data_callback_; + uint8_t buffer_data_[MAX_LINE_LENGTH]; + uint8_t buffer_pos_{0}; + // The angle formula depends on how the module is mounted, so it is read from the module at boot + InstallationMode installation_mode_{InstallationMode::INSTALLATION_MODE_SIDE}; + // Queries sent so far; 0 once the module answered. Data frames resend up to MAX_QUERY_ATTEMPTS. + uint8_t query_attempts_{1}; +}; + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/sensor.py b/esphome/components/ld2460/sensor.py new file mode 100644 index 00000000000..c9d4354e7a3 --- /dev/null +++ b/esphome/components/ld2460/sensor.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_ANGLE, + CONF_DISTANCE, + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + UNIT_DEGREES, + UNIT_METER, +) +from esphome.types import ConfigType + +from . import CONF_LD2460_ID, LD2460Component + +DEPENDENCIES = ["ld2460"] + +ICON_ACCOUNT_GROUP = "mdi:account-group" +ICON_ALPHA_X_BOX_OUTLINE = "mdi:alpha-x-box-outline" +ICON_ALPHA_Y_BOX_OUTLINE = "mdi:alpha-y-box-outline" +ICON_FORMAT_TEXT_ROTATION_ANGLE_UP = "mdi:format-text-rotation-angle-up" +ICON_MAP_MARKER_DISTANCE = "mdi:map-marker-distance" + +MAX_TARGETS = 5 + +_TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_X_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_Y_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_MAP_MARKER_DISTANCE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + ), + cv.Optional(CONF_ANGLE): sensor.sensor_schema( + icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=1, + ), + } +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2460_ID): cv.use_id(LD2460Component), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_ACCOUNT_GROUP, + ), + **{ + cv.Optional(f"target_{n}"): _TARGET_SCHEMA + for n in range(1, MAX_TARGETS + 1) + }, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2460_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + + setters = ( + (CONF_X, hub.set_target_x_sensor), + (CONF_Y, hub.set_target_y_sensor), + (CONF_DISTANCE, hub.set_target_distance_sensor), + (CONF_ANGLE, hub.set_target_angle_sensor), + ) + for n in range(MAX_TARGETS): + if (target_conf := config.get(f"target_{n + 1}")) is None: + continue + for key, setter in setters: + if (sens_conf := target_conf.get(key)) is not None: + cg.add(setter(n, await sensor.new_sensor(sens_conf))) diff --git a/tests/components/ld2460/common.h b/tests/components/ld2460/common.h new file mode 100644 index 00000000000..e5b19d5ba27 --- /dev/null +++ b/tests/components/ld2460/common.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include +#include "esphome/components/ld2460/ld2460.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::ld2460::testing { + +// Mock UART component to satisfy UARTDevice parent requirement. +class MockUARTComponent : public uart::UARTComponent { + public: + MOCK_METHOD(void, write_array, (const uint8_t *data, size_t len), (override)); + MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override)); + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(size_t, available, (), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +class TestableLD2460 : public LD2460Component { + public: + void feed(const std::vector &data) { + for (uint8_t byte : data) { + this->readline_(byte); + } + } +}; + +struct Point { + int16_t x; // 0.1 m steps + int16_t y; +}; + +// Periodic data frame: header (4) + function (1) + length (2) + 4 bytes per target + footer (4) +inline std::vector make_periodic_frame(const std::vector &targets) { + auto len = static_cast(11 + targets.size() * 4); + std::vector frame = { + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, static_cast(len & 0xFF), static_cast(len >> 8)}; + for (const auto &t : targets) { + auto x = static_cast(t.x); + auto y = static_cast(t.y); + frame.insert(frame.end(), {static_cast(x & 0xFF), static_cast(x >> 8), + static_cast(y & 0xFF), static_cast(y >> 8)}); + } + frame.insert(frame.end(), {0xF8, 0xF7, 0xF6, 0xF5}); + return frame; +} + +// One target at (1.5 m, 2.3 m) +inline std::vector make_periodic_frame() { return make_periodic_frame({{15, 23}}); } + +// ACK for the query installation mode command (0x0A); mode 1 = side, 2 = top +inline std::vector make_ack_frame(uint8_t mode) { + return {0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, mode, 0x04, 0x03, 0x02, 0x01}; +} + +} // namespace esphome::ld2460::testing diff --git a/tests/components/ld2460/common.yaml b/tests/components/ld2460/common.yaml new file mode 100644 index 00000000000..398bcbae6ea --- /dev/null +++ b/tests/components/ld2460/common.yaml @@ -0,0 +1,20 @@ +ld2460: + - id: ld2460_radar + on_data: + then: + - logger.log: "LD2460 Radar Data Received" + +sensor: + - platform: ld2460 + ld2460_id: ld2460_radar + target_count: + name: Target Count + target_5: + x: + name: Target-5 X + y: + name: Target-5 Y + angle: + name: Target-5 Angle + distance: + name: Target-5 Distance diff --git a/tests/components/ld2460/sensor/ld2460_readline.cpp b/tests/components/ld2460/sensor/ld2460_readline.cpp new file mode 100644 index 00000000000..43144b09f31 --- /dev/null +++ b/tests/components/ld2460/sensor/ld2460_readline.cpp @@ -0,0 +1,183 @@ +#include + +#include "../common.h" + +namespace esphome::ld2460::testing { + +class LD2460ReadlineTest : public ::testing::Test { + protected: + void SetUp() override { + this->ld2460_.set_uart_parent(&this->mock_uart_); + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, ::testing::_)).Times(::testing::AnyNumber()); + this->ld2460_.set_target_count_sensor(&this->count_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + this->ld2460_.set_target_x_sensor(i, &this->x_[i]); + this->ld2460_.set_target_y_sensor(i, &this->y_[i]); + this->ld2460_.set_target_distance_sensor(i, &this->distance_[i]); + this->ld2460_.set_target_angle_sensor(i, &this->angle_[i]); + } + } + + MockUARTComponent mock_uart_; + TestableLD2460 ld2460_; + sensor::Sensor count_; + sensor::Sensor x_[MAX_TARGETS]; + sensor::Sensor y_[MAX_TARGETS]; + sensor::Sensor distance_[MAX_TARGETS]; + sensor::Sensor angle_[MAX_TARGETS]; +}; + +TEST_F(LD2460ReadlineTest, BackToBackFrames) { + this->ld2460_.feed(make_periodic_frame({{10, 10}})); + this->ld2460_.feed(make_periodic_frame({{10, 10}, {-20, 30}})); + EXPECT_FLOAT_EQ(this->count_.state, 2); + EXPECT_NEAR(this->x_[1].state, -2.0f, 0.001f); + this->ld2460_.feed(make_periodic_frame({{12, 10}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 1.2f, 0.001f); + EXPECT_TRUE(std::isnan(this->x_[1].state)); +} + +TEST_F(LD2460ReadlineTest, MixedFrameTypes) { + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_ack_frame(1)); + this->ld2460_.feed(make_periodic_frame({{-5, 40}, {5, 40}})); + EXPECT_FLOAT_EQ(this->count_.state, 2); + EXPECT_NEAR(this->y_[1].state, 4.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, FooterBytesInsideGarbage) { + this->ld2460_.feed({0x01, 0x02, 0x03, 0x04, 0xF8, 0xF7, 0xF6, 0xF5}); + this->ld2460_.feed({0x10, 0x20, 0x30, 0x40, 0x04, 0x03, 0x02, 0x01}); + EXPECT_TRUE(std::isnan(this->count_.state)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); +} + +TEST_F(LD2460ReadlineTest, OverflowThenValidFrame) { + this->ld2460_.feed(std::vector(MAX_LINE_LENGTH + 8, 0x11)); + // The frame right after the overflow lands behind leftover bytes; the header resync skips them + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 1.5f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, MaxTargetsFrame) { + this->ld2460_.feed(make_periodic_frame({{1, 2}, {3, 4}, {5, 6}, {7, 8}, {9, 10}})); + EXPECT_FLOAT_EQ(this->count_.state, 5); + EXPECT_NEAR(this->x_[4].state, 0.9f, 0.001f); + EXPECT_NEAR(this->y_[4].state, 1.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, CoordinateDecoding) { + this->ld2460_.feed(make_periodic_frame({{15, 23}, {-300, -1}})); + EXPECT_NEAR(this->x_[0].state, 1.5f, 0.001f); + EXPECT_NEAR(this->y_[0].state, 2.3f, 0.001f); + EXPECT_NEAR(this->distance_[0].state, 2.746f, 0.001f); + EXPECT_NEAR(this->x_[1].state, -30.0f, 0.001f); + EXPECT_NEAR(this->y_[1].state, -0.1f, 0.001f); + + this->ld2460_.feed(make_periodic_frame({})); + EXPECT_FLOAT_EQ(this->count_.state, 0); + EXPECT_TRUE(std::isnan(this->x_[0].state)); + EXPECT_TRUE(std::isnan(this->distance_[0].state)); + EXPECT_TRUE(std::isnan(this->angle_[1].state)); +} + +TEST_F(LD2460ReadlineTest, AngleByInstallationMode) { + // Side mount (default): atan2(x, y) + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); + + // Top mount: atan2(y, x), wrapped to 0..360; the same coordinates are republished + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); + this->ld2460_.feed(make_periodic_frame({{15, -23}})); + EXPECT_NEAR(this->angle_[0].state, 303.11f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, AckHandling) { + // An ACK for another command or with a bad length leaves the side mount in place + this->ld2460_.feed({0xFD, 0xFC, 0xFB, 0xFA, 0x0B, 0x0C, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01}); + this->ld2460_.feed({0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0D, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01}); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); + + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, QueryResentWhileNoAck) { + // No ACK arrived: the first two data frames resend the 12-byte query (three attempts with the + // one from setup), after which the hub gives up and assumes a side mount + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, 12)).Times(2); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); + this->ld2460_.feed(make_periodic_frame({{30, 30}})); + this->ld2460_.feed(make_periodic_frame({{40, 40}})); +} + +TEST_F(LD2460ReadlineTest, InvalidModeKeepsQuerying) { + // A mode byte that is neither side nor top is rejected and the next frame asks again + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, 12)).Times(1); + this->ld2460_.feed(make_ack_frame(3)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, StrayBytesBeforeFrame) { + // Garbage in front of a frame is skipped once the footer is seen, for data and command frames + this->ld2460_.feed({0x01, 0x02, 0x03}); + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed({0xAA, 0xBB}); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, StrayByteThenTruncatedFrame) { + // The truncated frame's header is not followed by a matching length, so the full frame is used + this->ld2460_.feed({0x00, 0xF4, 0xF3, 0xF2, 0xF1, 0x04}); + this->ld2460_.feed(make_periodic_frame({{20, 30}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 2.0f, 0.001f); + EXPECT_NEAR(this->y_[0].state, 3.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, HeaderBytesInsidePayload) { + // The target bytes F4 F3 F2 F1 repeat the data header; the frame still parses at offset 0 + this->ld2460_.feed(make_periodic_frame({{static_cast(0xF3F4), static_cast(0xF1F2)}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, -308.4f, 0.01f); + EXPECT_NEAR(this->y_[0].state, -359.8f, 0.01f); +} + +TEST_F(LD2460ReadlineTest, PartialTargetRejected) { + // 13 bytes of payload is not a whole number of 4-byte targets + std::vector frame = {0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0D, 0x00, 0x0F, 0x00, 0xF8, 0xF7, 0xF6, 0xF5}; + this->ld2460_.feed(frame); + EXPECT_TRUE(std::isnan(this->count_.state)); +} + +TEST_F(LD2460ReadlineTest, NoQueryAfterAck) { + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, ::testing::_)).Times(0); + this->ld2460_.feed(make_ack_frame(1)); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); +} + +TEST_F(LD2460ReadlineTest, DataCallbackOncePerValidFrame) { + int calls = 0; + this->ld2460_.add_on_data_callback([&calls]() { calls++; }); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); + EXPECT_EQ(calls, 2); + // A frame with the data footer but a wrong header is rejected without a callback + this->ld2460_.feed({0x00, 0x00, 0x00, 0x00, 0x04, 0x0B, 0x00, 0xF8, 0xF7, 0xF6, 0xF5}); + this->ld2460_.feed(make_ack_frame(2)); + EXPECT_EQ(calls, 2); +} + +} // namespace esphome::ld2460::testing diff --git a/tests/components/ld2460/test.esp32-idf.yaml b/tests/components/ld2460/test.esp32-idf.yaml new file mode 100644 index 00000000000..e4efcf68fac --- /dev/null +++ b/tests/components/ld2460/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + ld2460: !include common.yaml diff --git a/tests/components/ld2460/test.esp8266-ard.yaml b/tests/components/ld2460/test.esp8266-ard.yaml new file mode 100644 index 00000000000..d66d2604239 --- /dev/null +++ b/tests/components/ld2460/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ld2460: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2460.yaml b/tests/integration/fixtures/uart_mock_ld2460.yaml new file mode 100644 index 00000000000..7c673924396 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2460.yaml @@ -0,0 +1,141 @@ +esphome: + name: uart-mock-ld2460-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2460's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + responses: + # The hub queries the installation mode at boot (HLK-LD2460 serial protocol v1.0, table 11): + # FD FC FB FA 0A 0C 00 01 04 03 02 01 + # Answer with the side-mount receipt from table 12 (mode byte 01). + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01] + + injections: + # Phase 1 (t=100ms): the datasheet's own report example (table 2), one target at (1.5 m, 2.3 m) + # F4 F3 F2 F1 = header, 04 = function, 0F 00 = length 15 (little endian), + # 0F 00 = X 15 (0.1 m steps), 17 00 = Y 23, F8 F7 F6 F5 = footer + # Side mount: distance = sqrt(1.5² + 2.3²) = 2.746 m, angle = atan2(1.5, 2.3) = 33.1° + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0F, 0x00, + 0x0F, 0x00, 0x17, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 2 (t=300ms): garbage bytes accumulate in the parser buffer + - delay: 200ms + inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22] + + # Phase 3 (t=400ms): a report header with partial data and no footer + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x13, 0x00, 0x01, 0x02] + + # Phase 4 (t=600ms): overflow the 32-byte frame buffer. Phases 2 and 3 left 16 bytes + # in it; 48 more make 64, so the parser fills the buffer twice. The 33rd byte logs + # "Max frame length exceeded" and restarts the buffer, the 64th leaves it full, and the + # first byte of the next frame restarts it again, so phase 5 parses from a clean buffer. + - delay: 200ms + inject_rx: + [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ] + + # Phase 5 (t=700ms): recovery, two targets after the overflow + # Target 1 at (-2.0 m, 3.0 m): X = -20 = 0xFFEC, Y = 30 = 0x001E + # distance = 3.606 m, side-mount angle = atan2(-2.0, 3.0) = -33.7° + # Target 2 at (0.5 m, 1.0 m): X = 5, Y = 10 + # distance = 1.118 m, angle = atan2(0.5, 1.0) = 26.6° + # length = 11 + 2 * 4 = 19 = 0x13 + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x13, 0x00, + 0xEC, 0xFF, 0x1E, 0x00, + 0x05, 0x00, 0x0A, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 6 (t=900ms): the datasheet's top-mount receipt (table 12 example, + # FD FC FB FA 0A 0C 00 02 04 03 02 01) switches the angle formula, then the + # same single target as phase 1 reports again + # Top mount: angle = atan2(2.3, 1.5) = 56.9° + - delay: 200ms + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01] + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0F, 0x00, + 0x0F, 0x00, 0x17, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + +ld2460: + id: ld2460_dev + uart_id: mock_uart + +sensor: + - platform: ld2460 + ld2460_id: ld2460_dev + target_count: + name: "Target Count" + filters: &sensor_filters + - timeout: + timeout: 50ms + value: last + - throttle_with_priority: 50ms + target_1: + x: + name: "Target 1 X" + filters: *sensor_filters + y: + name: "Target 1 Y" + filters: *sensor_filters + distance: + name: "Target 1 Distance" + filters: *sensor_filters + angle: + name: "Target 1 Angle" + filters: *sensor_filters + target_2: + x: + name: "Target 2 X" + filters: *sensor_filters + y: + name: "Target 2 Y" + filters: *sensor_filters + distance: + name: "Target 2 Distance" + filters: *sensor_filters + angle: + name: "Target 2 Angle" + filters: *sensor_filters + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2460.py b/tests/integration/test_uart_mock_ld2460.py new file mode 100644 index 00000000000..4374ee92d7d --- /dev/null +++ b/tests/integration/test_uart_mock_ld2460.py @@ -0,0 +1,160 @@ +"""Integration test for the LD2460 component with a mock UART. + +The frames come from the HLK-LD2460 serial protocol v1.0: the report example in +table 2 (one target at 1.5 m, 2.3 m), the installation-mode query in table 11 and +the receipts in table 12. + +test_uart_mock_ld2460: + 1. The hub queries the installation mode at boot (TX logged by the mock) + 2. The datasheet report example decodes to x, y, distance and angle (side mount) + 3. Garbage, a truncated frame and a buffer overflow do not stop the parser + 4. A two-target frame after the overflow decodes, including a negative X + 5. A top-mount receipt switches the angle formula for the next frame +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_ld2460( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2460 frame parsing with the datasheet frames, garbage and overflow.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + overflow_seen = loop.create_future() + tx_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Max frame length exceeded" in line and not overflow_seen.done(): + overflow_seen.set_result(True) + if "uart_mock" in line and "TX " in line: + tx_log_lines.append(line) + + collector = SensorStateCollector( + sensor_names=[ + "target_count", + "target_1_x", + "target_1_y", + "target_1_distance", + "target_1_angle", + "target_2_x", + "target_2_y", + "target_2_distance", + "target_2_angle", + ], + ) + + # Phase 5: two targets after the overflow + recovery_received = collector.add_waiter( + lambda: ( + pytest.approx(2.0) in collector.sensor_states["target_count"] + and pytest.approx(-2.0, abs=0.01) in collector.sensor_states["target_1_x"] + and pytest.approx(3.0, abs=0.01) in collector.sensor_states["target_1_y"] + and pytest.approx(0.5, abs=0.01) in collector.sensor_states["target_2_x"] + and pytest.approx(1.0, abs=0.01) in collector.sensor_states["target_2_y"] + and pytest.approx(1.118, abs=0.01) + in collector.sensor_states["target_2_distance"] + ) + ) + # Phase 6: the same target as phase 1, measured as a top mount + top_mount_received = collector.add_waiter( + lambda: ( + pytest.approx(56.9, abs=0.1) in collector.sensor_states["target_1_angle"] + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Phase 1: the datasheet example, one target at (1.5, 2.3) + phase1 = collector.add_waiter( + lambda: ( + pytest.approx(1.0) in collector.sensor_states["target_count"] + and pytest.approx(33.1, abs=0.1) + in collector.sensor_states["target_1_angle"] + ) + ) + try: + await asyncio.wait_for(phase1, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + assert collector.sensor_states["target_1_x"][0] == pytest.approx(1.5, abs=0.01) + assert collector.sensor_states["target_1_y"][0] == pytest.approx(2.3, abs=0.01) + assert collector.sensor_states["target_1_distance"][0] == pytest.approx( + 2.746, abs=0.01 + ) + assert collector.sensor_states["target_1_angle"][0] == pytest.approx( + 33.1, abs=0.1 + ) + assert collector.sensor_states["target_count"][0] == pytest.approx(1.0) + + # Phase 5: parsing survives garbage, a truncated frame and an overflow + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the recovery frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + assert overflow_seen.done(), "Expected 'Max frame length exceeded' in the log" + assert ( + pytest.approx(3.606, abs=0.01) + in collector.sensor_states["target_1_distance"] + ) + assert ( + pytest.approx(-33.7, abs=0.1) in collector.sensor_states["target_1_angle"] + ) + assert pytest.approx(26.6, abs=0.1) in collector.sensor_states["target_2_angle"] + + # Phase 6: the top-mount receipt changes the angle for the same coordinates + try: + await asyncio.wait_for(top_mount_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the top-mount angle. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + + # The hub sent the installation-mode query from the datasheet (table 11) + tx_data = " ".join(tx_log_lines) + assert "FD:FC:FB:FA:0A:0C:00:01:04:03:02:01" in tx_data, ( + f"Expected the installation-mode query in the TX log, got: {tx_data}" + ) From ffa849753a7f141904cf3b537e9ba3cd81c1ef46 Mon Sep 17 00:00:00 2001 From: Stephen Cox <69124219+linkedupbits@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:00:05 +1300 Subject: [PATCH 390/435] [ds3231] Add DS3231 real-time clock hub with temperature sensor (#18883) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ds3231/__init__.py | 35 ++++++++ esphome/components/ds3231/ds3231.cpp | 82 +++++++++++++++++++ esphome/components/ds3231/ds3231.h | 32 ++++++++ esphome/components/ds3231/sensor/__init__.py | 41 ++++++++++ .../ds3231/sensor/ds3231_temperature.cpp | 23 ++++++ .../ds3231/sensor/ds3231_temperature.h | 17 ++++ tests/components/ds3231/common.yaml | 15 ++++ tests/components/ds3231/test.esp32-idf.yaml | 3 + tests/components/ds3231/test.esp8266-ard.yaml | 3 + 10 files changed, 252 insertions(+) create mode 100644 esphome/components/ds3231/__init__.py create mode 100644 esphome/components/ds3231/ds3231.cpp create mode 100644 esphome/components/ds3231/ds3231.h create mode 100644 esphome/components/ds3231/sensor/__init__.py create mode 100644 esphome/components/ds3231/sensor/ds3231_temperature.cpp create mode 100644 esphome/components/ds3231/sensor/ds3231_temperature.h create mode 100644 tests/components/ds3231/common.yaml create mode 100644 tests/components/ds3231/test.esp32-idf.yaml create mode 100644 tests/components/ds3231/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2db90be68e3..27aac4fb68e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -156,6 +156,7 @@ esphome/components/ds1307/* @badbadc0ffee esphome/components/ds1603l/* @JakeLC15 esphome/components/ds2484/* @mrk-its esphome/components/ds248x/* @tomwellnitz +esphome/components/ds3231/* @linkedupbits esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py new file mode 100644 index 00000000000..262c951e80f --- /dev/null +++ b/esphome/components/ds3231/__init__.py @@ -0,0 +1,35 @@ +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.types import ConfigType + +CODEOWNERS = ["@linkedupbits"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True +DOMAIN = "ds3231" + +CONF_DS3231_ID = "ds3231_id" + +ds3231_ns = cg.esphome_ns.namespace("ds3231") +DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)}) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x68)) +) + + +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) + + +automation.register_apply_action( + "ds3231.force_temperature_conversion", + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}), + automation.ApplyCall("force_temperature_conversion()"), +) diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp new file mode 100644 index 00000000000..4f7396fcbdd --- /dev/null +++ b/esphome/components/ds3231/ds3231.cpp @@ -0,0 +1,82 @@ +#include "ds3231.h" +#include "esphome/core/log.h" + +// Datasheet: +// - https://www.analog.com/media/en/technical-documentation/data-sheets/DS3231.pdf + +namespace esphome::ds3231 { + +ESPHOME_LOG_TAG(TAG, "ds3231"); + +static constexpr uint8_t DS3231_REG_CONTROL = 0x0E; +static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11; + +void DS3231Component::setup() { + if (!this->read_control_status_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery + // INT/SQW is the (unused) interrupt line rather than a square-wave output. + this->control_reg_ |= CONTROL_INTCN; + if (!this->write_control_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } +} + +void DS3231Component::dump_config() { + ESP_LOGCONFIG(TAG, "DS3231:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +bool DS3231Component::read_temperature(float &out) { + uint8_t raw[2]; + if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) { + return false; + } + // 10-bit two's complement, 0.25 degC per LSB. + int16_t value = static_cast((raw[0] << 8) | raw[1]) >> 6; + out = value * 0.25f; + return true; +} + +void DS3231Component::force_temperature_conversion() { + // Just kick off the conversion - the chip clears CONV and updates the temperature + // register on its own within ~200 ms, and the next temperature poll picks it up. + // Blocking here to wait for it would stall the main loop. + if (this->is_failed() || !this->read_control_status_()) + return; + if ((this->status_reg_ & STATUS_BSY) != 0) { + ESP_LOGW(TAG, "A temperature conversion is already in progress."); + return; + } + this->control_reg_ |= CONTROL_CONV; + this->write_control_(); +} + +bool DS3231Component::read_control_status_() { + uint8_t raw[2]; + if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) { + ESP_LOGW(TAG, "Can't read control/status registers."); + return false; + } + this->control_reg_ = raw[0]; + this->status_reg_ = raw[1]; + return true; +} + +bool DS3231Component::write_control_() { + if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) { + ESP_LOGW(TAG, "Can't write control register."); + return false; + } + return true; +} + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/ds3231.h b/esphome/components/ds3231/ds3231.h new file mode 100644 index 00000000000..b8a81a2e490 --- /dev/null +++ b/esphome/components/ds3231/ds3231.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ds3231 { + +/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the +/// chip through this class. +class DS3231Component final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + bool read_temperature(float &out); + void force_temperature_conversion(); + + protected: + static constexpr uint8_t CONTROL_INTCN = 0x04; + static constexpr uint8_t CONTROL_CONV = 0x20; + static constexpr uint8_t CONTROL_EOSC = 0x80; + + static constexpr uint8_t STATUS_BSY = 0x04; + + bool read_control_status_(); + bool write_control_(); + + uint8_t control_reg_{0}; + uint8_t status_reg_{0}; +}; + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/sensor/__init__.py b/esphome/components/ds3231/sensor/__init__.py new file mode 100644 index 00000000000..a92fc1f48c0 --- /dev/null +++ b/esphome/components/ds3231/sensor/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns + +DEPENDENCIES = ["ds3231"] + +DS3231TemperatureSensor = ds3231_ns.class_( + "DS3231TemperatureSensor", + sensor.Sensor, + cg.PollingComponent, + cg.Parented.template(DS3231Component), +) + + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + DS3231TemperatureSensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_THERMOMETER, + ) + .extend({cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component)}) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_DS3231_ID]) diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.cpp b/esphome/components/ds3231/sensor/ds3231_temperature.cpp new file mode 100644 index 00000000000..8d7e1156be4 --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.cpp @@ -0,0 +1,23 @@ +#include "ds3231_temperature.h" +#include "esphome/core/log.h" + +namespace esphome::ds3231 { + +ESPHOME_LOG_TAG(TAG, "ds3231.sensor"); + +void DS3231TemperatureSensor::update() { + float temperature; + if (!this->parent_->read_temperature(temperature)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + this->publish_state(temperature); +} + +void DS3231TemperatureSensor::dump_config() { + LOG_SENSOR("", "DS3231 Temperature", this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.h b/esphome/components/ds3231/sensor/ds3231_temperature.h new file mode 100644 index 00000000000..aa31c424384 --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" + +#include "../ds3231.h" + +namespace esphome::ds3231 { + +class DS3231TemperatureSensor final : public sensor::Sensor, public PollingComponent, public Parented { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::ds3231 diff --git a/tests/components/ds3231/common.yaml b/tests/components/ds3231/common.yaml new file mode 100644 index 00000000000..9f087cb9411 --- /dev/null +++ b/tests/components/ds3231/common.yaml @@ -0,0 +1,15 @@ +ds3231: + - id: ds3231_component + i2c_id: i2c_bus + address: 0x68 + +sensor: + - platform: ds3231 + ds3231_id: ds3231_component + name: DS3231 Temperature + update_interval: never + +interval: + - interval: 60s + then: + - ds3231.force_temperature_conversion: ds3231_component diff --git a/tests/components/ds3231/test.esp32-idf.yaml b/tests/components/ds3231/test.esp32-idf.yaml new file mode 100644 index 00000000000..fb7ac2fba5c --- /dev/null +++ b/tests/components/ds3231/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ds3231: !include common.yaml diff --git a/tests/components/ds3231/test.esp8266-ard.yaml b/tests/components/ds3231/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ca42d95738e --- /dev/null +++ b/tests/components/ds3231/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds3231: !include common.yaml From 0b6856c0524bdb009e12bd543fd914853b331281 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 17:17:58 -0400 Subject: [PATCH 391/435] [udp] Add IPv6 unicast and multicast (#20590) Co-authored-by: J. Nick Koston Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/socket/socket.cpp | 6 +- esphome/components/socket/socket.h | 5 + esphome/components/udp/__init__.py | 97 +++++++- esphome/components/udp/udp_component.cpp | 228 +++++++++++++----- esphome/components/udp/udp_component.h | 52 +++- .../udp/test_udp_listen_address.py | 135 +++++++++++ tests/components/udp/common-ipv6.yaml | 20 ++ tests/components/udp/common.yaml | 56 +++-- tests/components/udp/test-ipv6.esp32-idf.yaml | 4 + tests/components/udp/test.host.yaml | 5 + tests/components/udp/test.nrf52-adafruit.yaml | 5 + .../fixtures/udp_ipv6_unicast.yaml | 24 ++ tests/integration/test_udp_ipv6.py | 63 +++++ 13 files changed, 592 insertions(+), 108 deletions(-) create mode 100644 tests/component_tests/udp/test_udp_listen_address.py create mode 100644 tests/components/udp/common-ipv6.yaml create mode 100644 tests/components/udp/test-ipv6.esp32-idf.yaml create mode 100644 tests/components/udp/test.nrf52-adafruit.yaml create mode 100644 tests/integration/fixtures/udp_ipv6_unicast.yaml create mode 100644 tests/integration/test_udp_ipv6.py diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index dd9387785f7..630d68b562d 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -263,8 +263,7 @@ bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_ } return true; } - // The raw lwIP implementation has no IPv6 membership support -#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) +#ifdef USE_SOCKET_IPV6 struct ipv6_mreq imreq6 {}; #ifdef USE_SOCKET_IMPL_BSD_SOCKETS #ifdef USE_ZEPHYR @@ -315,7 +314,7 @@ bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_ #else errno = EINVAL; return false; -#endif // USE_NETWORK_IPV6 && fd based sockets +#endif // USE_SOCKET_IPV6 } #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) @@ -335,6 +334,7 @@ bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { return true; }); if (ifindex == 0) { + errno = EADDRNOTAVAIL; return false; } } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 7310e32afb6..e8f9208774c 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -56,6 +56,11 @@ inline constexpr int IP_DOMAIN = AF_INET6; inline constexpr int IP_DOMAIN = AF_INET; #endif +// IPv6 sockets with multicast membership; the raw lwIP implementation has no IPv6 membership support. +#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) +#define USE_SOCKET_IPV6 +#endif + #ifdef USE_LWIP_FAST_SELECT /// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. /// cached_sock == nullptr means the socket is not monitored (monitor_loop was false, fd diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 81149f056ba..5957870e7f2 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,9 +1,11 @@ +from ipaddress import IPv4Address, IPv6Address from typing import Any from esphome import automation from esphome.automation import Trigger import esphome.codegen as cg from esphome.components.const import CONF_ON_RECEIVE +from esphome.components.network import DOMAIN as NETWORK_DOMAIN from esphome.components.packet_transport import ( CONF_BINARY_SENSORS, CONF_ENCRYPTION, @@ -13,9 +15,16 @@ from esphome.components.packet_transport import ( CONF_SENSORS, ) import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID +from esphome.const import ( + CONF_DATA, + CONF_ENABLE_IPV6, + CONF_ID, + CONF_PORT, + CONF_TRIGGER_ID, +) from esphome.core import ID from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -63,13 +72,80 @@ RELOCATED = { } -def _consume_udp_sockets(config: ConfigType) -> ConfigType: - """Register socket needs for UDP component.""" +def _no_scope_id(addr: IPv4Address | IPv6Address) -> IPv4Address | IPv6Address: + """Python accepts a scope id, which neither inet_pton nor lwIP's inet6_aton takes.""" + if isinstance(addr, IPv6Address) and addr.scope_id is not None: + raise cv.Invalid( + f"{addr} carries a scope id, which the device cannot parse; " + "give the address without the '%' part" + ) + return addr + + +def _address(value: Any) -> IPv4Address | IPv6Address: + addr = _no_scope_id(cv.ipaddress(value)) + # A link-local destination needs a scope id, which the device cannot pick on every platform + if isinstance(addr, IPv6Address) and addr.is_link_local: + raise cv.Invalid( + f"{addr} is link-local; use a multicast, unique local or global address" + ) + return addr + + +def _listen_address(value: Any) -> IPv4Address | IPv6Address: + """Accept an IPv4 multicast/broadcast or an IPv6 multicast address, with a family-specific error.""" + if ":" not in str(value): + return cv.ipv4address_multi_broadcast(value) + addr = _no_scope_id(cv.ipv6address(value)) + if not addr.is_multicast: + raise cv.Invalid(f"{value} is not an IPv6 multicast address") + return addr + + +def _final_validate(config: ConfigType) -> None: from esphome.components import socket - # UDP uses up to 2 sockets: 1 broadcast + 1 listen - # Whether each is used depends on code generation, so register worst case - socket.consume_sockets(2, "udp", socket.SocketType.UDP)(config) + full_config = fv.full_config.get() + ipv6_enabled = full_config.get(NETWORK_DOMAIN, {}).get(CONF_ENABLE_IPV6, False) + raw_lwip = ( + full_config.get(socket.DOMAIN, {}).get(socket.CONF_IMPLEMENTATION) + == socket.IMPLEMENTATION_LWIP_TCP + ) + for key, addresses in ( + (CONF_ADDRESSES, config[CONF_ADDRESSES]), + (CONF_LISTEN_ADDRESS, [config[CONF_LISTEN_ADDRESS]]), + ): + for addr in addresses: + if addr.version != 6: + continue + if raw_lwip: + raise cv.Invalid( + f"IPv6 address '{addr}' is not supported on this platform", [key] + ) + if not ipv6_enabled: + raise cv.Invalid( + f"IPv6 address '{addr}' requires 'enable_ipv6: true' in the 'network' component", + [key], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _ipv6_addresses(config: ConfigType) -> list[IPv6Address]: + return [addr for addr in config[CONF_ADDRESSES] if addr.version == 6] + + +def _has_ipv4_address(config: ConfigType) -> bool: + return any(addr.version == 4 for addr in config[CONF_ADDRESSES]) + + +def _consume_udp_sockets(config: ConfigType) -> ConfigType: + """Reserve the listen socket plus one send socket per address family in use.""" + from esphome.components import socket + + count = 1 + _has_ipv4_address(config) + bool(_ipv6_addresses(config)) + socket.consume_sockets(count, "udp", socket.SocketType.UDP)(config) return config @@ -88,9 +164,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional( CONF_LISTEN_ADDRESS, default="255.255.255.255" - ): cv.ipv4address_multi_broadcast, + ): _listen_address, cv.Optional(CONF_ADDRESSES, default=["255.255.255.255"]): cv.ensure_list( - cv.ipv4address, + _address ), cv.Optional(CONF_ON_RECEIVE): automation.validate_automation( { @@ -125,6 +201,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_broadcast_port(conf_port[CONF_BROADCAST_PORT])) if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) + if ipv6_addresses := _ipv6_addresses(config): + cg.add_define("USE_UDP_IPV6_SEND") + cg.add(var.set_ipv6_send(any(addr.is_multicast for addr in ipv6_addresses))) + if not _has_ipv4_address(config): + cg.add(var.set_ipv4_send(False)) # Shared flash table ended by nullptr, so the component stores only a pointer. if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]: cg.add( diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index e6046ebc36c..e143f93a4be 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -8,44 +8,95 @@ namespace esphome::udp { ESPHOME_LOG_TAG(TAG, "udp"); -static constexpr uint32_t JOIN_POLL_ID = 0; -static constexpr uint32_t JOIN_POLL_INTERVAL_MS = 1000; +static constexpr uint32_t NETWORK_POLL_ID = 0; +static constexpr uint32_t NETWORK_POLL_INTERVAL_MS = 1000; void UDPComponent::setup() { + size_t address_count = 0; + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) + address_count++; + this->sockaddrs_.init(address_count); for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { const char *address = *it; - struct sockaddr saddr {}; - if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) { + SockaddrEntry entry{}; + if (socket::set_sockaddr(&entry.sa, sizeof(entry), address, this->broadcast_port_) == 0) { ESP_LOGW(TAG, "Invalid address %s", address); // A dropped address silently receives nothing; surface the misconfiguration this->status_set_warning(LOG_STR("invalid address")); continue; } - this->sockaddrs_.push_back(saddr); + this->sockaddrs_.push_back(entry); } // set up broadcast socket if (this->should_broadcast_) { - this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); - if (this->broadcast_socket_ == nullptr) { - this->status_set_error(LOG_STR("Could not create socket")); - this->mark_failed(); - return; +#ifdef USE_UDP_IPV6_SEND + if (this->ipv4_send_) +#endif + { + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); + if (this->broadcast_socket_ == nullptr) { + this->status_set_error(LOG_STR("Could not create socket")); + this->mark_failed(); + return; + } + int enable = 1; + auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); + // we can still continue + } + err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); + } } - int enable = 1; - auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); - // we can still continue - } - err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set broadcast")); +#ifdef USE_UDP_IPV6_SEND + if (this->ipv6_send_) { + this->send_socket_v6_ = socket::socket_udp_send(AF_INET6, IPPROTO_IP); + if (this->send_socket_v6_ == nullptr) { + this->status_set_error(LOG_STR("Could not create IPv6 socket")); + this->mark_failed(); + return; + } + // Unicast needs no egress interface; multicast does + if (this->ipv6_multicast_) + this->poll_pending_ |= POLL_MCAST_IF; } +#endif } // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); + struct sockaddr_storage server {}; + socklen_t server_len; + if (this->listen_address_ != nullptr) { + server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // A multicast join needs a socket of the group's address family + this->listen_socket_ = socket::socket_udp_loop_monitored(server.ss_family, IPPROTO_IP); + this->poll_pending_ |= POLL_JOIN; +#ifdef USE_SOCKET_IPV6 + // An IPv6 group binds after the join, which supplies the scope id link-local groups need + if (server.ss_family == AF_INET6) + this->poll_pending_ |= POLL_BIND; +#endif + } else { +#ifdef USE_SOCKET_IPV6 + server_len = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET6, IPPROTO_IP); + // Clear IPV6_V6ONLY so IPv4 datagrams also arrive, as mapped addresses + int v6only = 0; + if (this->listen_socket_ != nullptr && + this->listen_socket_->setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) != 0) { + ESP_LOGW(TAG, "Failed to clear IPV6_V6ONLY: errno %d", errno); + this->status_set_warning(LOG_STR("IPv4 datagrams may not be received")); + } +#else + // Must stay IPv4: raw lwIP can have USE_NETWORK_IPV6 without IPv6 sockets + server_len = socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), "0.0.0.0", this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); +#endif + } if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -64,51 +115,93 @@ void UDPComponent::setup() { this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } - struct sockaddr_in server {}; - server.sin_family = AF_INET; - server.sin_addr.s_addr = ESPHOME_INADDR_ANY; - server.sin_port = htons(this->listen_port_); - - if (this->listen_address_ != nullptr) { - inet_aton(this->listen_address_, &server.sin_addr); - // Joined once the network is up; RP2040 has no interface to join before then - this->set_interval(JOIN_POLL_ID, JOIN_POLL_INTERVAL_MS, [this]() { - if (network::is_connected() && this->join_multicast_()) - this->cancel_interval(JOIN_POLL_ID); - }); - } - - err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); - if (err != 0) { - ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); - this->status_set_error(LOG_STR("Unable to bind socket")); - this->mark_failed(); - return; + if ((this->poll_pending_ & POLL_BIND) == 0) { + err = this->listen_socket_->bind((struct sockaddr *) &server, server_len); + if (err != 0) { + ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); + this->status_set_error(LOG_STR("Unable to bind socket")); + this->mark_failed(); + return; + } } } + if (this->poll_pending_ != 0) { + // Joins and the IPv6 egress interface need the network up; RP2040 has no interface before then + this->set_interval(NETWORK_POLL_ID, NETWORK_POLL_INTERVAL_MS, [this]() { + if (network::is_connected() && this->network_poll_()) + this->cancel_interval(NETWORK_POLL_ID); + }); + } } -bool UDPComponent::join_multicast_() { - if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_)) { - ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); - if (this->join_warning_) { - this->join_warning_ = false; - this->status_clear_warning(); - } - return true; - } - // Log the first failure only; the poll keeps retrying every second - if (!this->join_failed_) { - this->join_failed_ = true; - ESP_LOGW(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - // One warning bit: only a warning raised here is cleared on success, one from setup() stays - if (!this->status_has_warning()) { - this->status_set_warning(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); - this->join_warning_ = true; +bool UDPComponent::network_poll_() { + bool ok = true; + if (this->poll_pending_ & POLL_JOIN) { +#ifdef USE_SOCKET_IPV6 + uint32_t *if_index = &this->mcast_ifindex_; +#else + uint32_t *if_index = nullptr; +#endif + if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_, if_index)) { + ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); + this->poll_pending_ &= ~POLL_JOIN; + this->poll_failed_ &= ~POLL_JOIN; + } else { + this->set_poll_warning_(POLL_JOIN, LOG_STR("Failed to join multicast group")); + ok = false; } } - return false; +#ifdef USE_SOCKET_IPV6 + if (ok && (this->poll_pending_ & POLL_BIND)) { + struct sockaddr_storage server {}; + socklen_t server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // POSIX bind() on a link-local group needs it; lwIP ignores it + reinterpret_cast(&server)->sin6_scope_id = this->mcast_ifindex_; + if (this->listen_socket_->bind((struct sockaddr *) &server, server_len) == 0) { + this->poll_pending_ &= ~POLL_BIND; + this->poll_failed_ &= ~POLL_BIND; + } else { + this->set_poll_warning_(POLL_BIND, LOG_STR("Unable to bind socket")); + ok = false; + } + } +#endif +#ifdef USE_UDP_IPV6_SEND + // Only an IPv6 group's join supplies the index, so a failing IPv4 join must not hold back sends + const bool index_pending = (this->poll_pending_ & (POLL_JOIN | POLL_BIND)) == (POLL_JOIN | POLL_BIND); + if ((this->poll_pending_ & POLL_MCAST_IF) && !index_pending) { + // Index 0 picks the first eligible interface + if (socket::set_ipv6_multicast_if(this->send_socket_v6_.get(), this->mcast_ifindex_)) { + this->poll_pending_ &= ~POLL_MCAST_IF; + this->poll_failed_ &= ~POLL_MCAST_IF; + } else { + this->set_poll_warning_(POLL_MCAST_IF, LOG_STR("Failed to set IPv6 multicast interface")); + ok = false; + } + } +#endif + if (!ok) + return false; + if (this->poll_warning_) { + this->poll_warning_ = false; + this->status_clear_warning(); + } + return true; +} + +void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) { + // Log the first failure of each step only; the poll keeps retrying every second + if (this->poll_failed_ & step) + return; + this->poll_failed_ |= step; + ESP_LOGW(TAG, "%s. Error %d", LOG_STR_ARG(what), errno); + // One warning bit: only a warning raised here is cleared on success, one from setup() stays + if (!this->status_has_warning()) { + this->status_set_warning(what); + this->poll_warning_ = true; + } } void UDPComponent::loop() { @@ -147,8 +240,23 @@ void UDPComponent::dump_config() { } void UDPComponent::send_packet(const uint8_t *data, size_t size) { - for (const auto &saddr : this->sockaddrs_) { - auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr)); + if (this->is_failed()) + return; // a send socket may be missing + for (const auto &entry : this->sockaddrs_) { +#ifdef USE_UDP_IPV6_SEND + const bool v6 = entry.sa.sa_family == AF_INET6; + // Until the egress interface is set, a multicast packet would leave on the wrong one + if (v6 && (this->poll_pending_ & POLL_MCAST_IF) && entry.in6.sin6_addr.s6_addr[0] == 0xff) { + ESP_LOGV(TAG, "Egress interface not set yet, multicast packet dropped"); + continue; + } + socket::UDPSendSocket *sock = v6 ? this->send_socket_v6_.get() : this->broadcast_socket_.get(); + socklen_t sockaddr_size = v6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in); +#else + socket::UDPSendSocket *sock = this->broadcast_socket_.get(); + socklen_t sockaddr_size = sizeof(sockaddr_in); +#endif + auto result = sock->sendto(data, size, 0, &entry.sa, sockaddr_size); if (result < 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 276fd5adf17..b4dd4b2ac43 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -22,6 +22,15 @@ class UDPComponent final : public Component { void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } void set_should_listen() { this->should_listen_ = true; } +#ifdef USE_UDP_IPV6_SEND + /// This instance has IPv6 destinations; multicast ones also need an egress interface. + void set_ipv6_send(bool multicast) { + this->ipv6_send_ = true; + this->ipv6_multicast_ = multicast; + } + /// An instance whose destinations are all IPv6 needs no IPv4 send socket + void set_ipv4_send(bool ipv4_send) { this->ipv4_send_ = ipv4_send; } +#endif template void add_listener(F &&listener) { this->packet_listeners_.add(std::forward(listener)); } void setup() override; void loop() override; @@ -31,22 +40,43 @@ class UDPComponent final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }; protected: - bool join_multicast_(); + enum PollStep : uint8_t { POLL_JOIN = 1, POLL_BIND = 2, POLL_MCAST_IF = 4 }; + union SockaddrEntry { + sockaddr sa; +#ifdef USE_UDP_IPV6_SEND + sockaddr_in6 in6; // sizes the storage for IPv6 destinations +#endif + }; + + /// One pass of the network poll; true once nothing is left to do. + bool network_poll_(); + void set_poll_warning_(PollStep step, const LogString *what); + + CallbackManager)> packet_listeners_{}; + std::unique_ptr broadcast_socket_ = nullptr; + std::unique_ptr listen_socket_ = nullptr; +#ifdef USE_UDP_IPV6_SEND + std::unique_ptr send_socket_v6_ = nullptr; +#endif + FixedVector sockaddrs_{}; + const char *const *addresses_{nullptr}; + const char *listen_address_{nullptr}; +#ifdef USE_SOCKET_IPV6 + uint32_t mcast_ifindex_{0}; +#endif uint16_t listen_port_{}; uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; - bool join_failed_{}; - bool join_warning_{}; - CallbackManager)> packet_listeners_{}; - - std::unique_ptr broadcast_socket_ = nullptr; - std::unique_ptr listen_socket_ = nullptr; - std::vector sockaddrs_{}; - const char *const *addresses_{nullptr}; - - const char *listen_address_{nullptr}; + uint8_t poll_pending_{}; // PollStep bits + uint8_t poll_failed_{}; // PollStep bits already logged + bool poll_warning_{}; +#ifdef USE_UDP_IPV6_SEND + bool ipv4_send_{true}; + bool ipv6_send_{}; + bool ipv6_multicast_{}; +#endif }; } // namespace esphome::udp diff --git a/tests/component_tests/udp/test_udp_listen_address.py b/tests/component_tests/udp/test_udp_listen_address.py new file mode 100644 index 00000000000..8c9718ca363 --- /dev/null +++ b/tests/component_tests/udp/test_udp_listen_address.py @@ -0,0 +1,135 @@ +"""Tests for the udp address validators.""" + +from ipaddress import ip_address + +import pytest + +from esphome.components.udp import ( + CONF_ADDRESSES, + CONF_LISTEN_ADDRESS, + _address, + _final_validate, + _listen_address, +) +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_IPV6, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("value", ["239.0.60.53", "255.255.255.255", "ff12::cafe"]) +def test_listen_address_accepts_multicast_and_broadcast(value: str) -> None: + assert str(_listen_address(value)) == value + + +@pytest.mark.parametrize( + ("value", "message"), + [ + # An IPv4 address must report the IPv4 error, not an IPv6 parse failure + ("192.168.1.10", "not a multicast address nor local broadcast address"), + ("fe80::1", "not an IPv6 multicast address"), + ("ff02::cafe%eth0", "carries a scope id"), + ("gg::1", "not a valid IPv6 address"), + ], +) +def test_listen_address_reports_matching_family_error(value: str, message: str) -> None: + with pytest.raises(cv.Invalid, match=message): + _listen_address(value) + + +@pytest.mark.parametrize( + "value", ["10.0.0.1", "255.255.255.255", "ff12::cafe", "2001:db8::1"] +) +def test_address_accepts_routable_destinations(value: str) -> None: + assert str(_address(value)) == value + + +@pytest.mark.parametrize( + ("value", "message"), + [ + # The device parses addresses with inet_pton, which takes no scope suffix + ("2001:db8::1%wlan0", "carries a scope id"), + # No scope id is set on the destination, so a link-local send would fail + ("fe80::1", "is link-local"), + ], +) +def test_address_rejects_unsendable_destinations(value: str, message: str) -> None: + with pytest.raises(cv.Invalid, match=message): + _address(value) + + +_RAW_LWIP_IPV6 = { + "network": {CONF_ENABLE_IPV6: True}, + "socket": {"implementation": "lwip_tcp"}, +} +_NO_IPV6 = "requires 'enable_ipv6: true'" +_RAW_LWIP = "not supported on this platform" + + +@pytest.mark.parametrize( + ("addresses", "listen_address", "platform", "full_config", "message"), + [ + pytest.param( + ["ff12::cafe"], + "255.255.255.255", + PlatformFramework.ESP32_IDF, + {}, + _NO_IPV6, + id="ipv6_destination_without_ipv6", + ), + pytest.param( + ["255.255.255.255"], + "ff12::cafe", + PlatformFramework.ESP32_IDF, + {}, + _NO_IPV6, + id="ipv6_listen_without_ipv6", + ), + pytest.param( + ["ff12::cafe"], + "255.255.255.255", + PlatformFramework.ESP8266_ARDUINO, + _RAW_LWIP_IPV6, + _RAW_LWIP, + id="ipv6_destination_on_raw_lwip", + ), + pytest.param( + ["255.255.255.255"], + "ff12::cafe", + PlatformFramework.ESP8266_ARDUINO, + _RAW_LWIP_IPV6, + _RAW_LWIP, + id="ipv6_listen_on_raw_lwip", + ), + ], +) +def test_final_validate_rejects_unsupported_ipv6( + set_core_config: SetCoreConfigCallable, + addresses: list[str], + listen_address: str, + platform: PlatformFramework, + full_config: dict, + message: str, +) -> None: + set_core_config(platform, full_config=full_config) + with pytest.raises(cv.Invalid, match=message): + _final_validate( + { + CONF_ADDRESSES: [ip_address(a) for a in addresses], + CONF_LISTEN_ADDRESS: ip_address(listen_address), + } + ) + + +def test_final_validate_accepts_ipv6_when_enabled( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"network": {CONF_ENABLE_IPV6: True}}, + ) + _final_validate( + { + CONF_ADDRESSES: [ip_address("ff12::cafe")], + CONF_LISTEN_ADDRESS: ip_address("ff12::cafe"), + } + ) diff --git a/tests/components/udp/common-ipv6.yaml b/tests/components/udp/common-ipv6.yaml new file mode 100644 index 00000000000..560d45bf116 --- /dev/null +++ b/tests/components/udp/common-ipv6.yaml @@ -0,0 +1,20 @@ +network: + enable_ipv6: true + +udp: + - id: my_udp_v6 + listen_address: ff12::cafe + addresses: + - "ff12::cafe" + - "2001:db8::1" + on_receive: + - udp.write: + id: my_udp_v6 + data: "pong" + # Default listen address: a dual-stack socket on IPv6 builds + - id: my_udp_dual_stack + port: 18512 + on_receive: + - logger.log: + format: "Received %zu bytes" + args: [data.size()] diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index f3a082fdd2e..680e51df1c8 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -3,30 +3,30 @@ wifi: password: password1 udp: - id: my_udp - listen_address: 239.0.60.53 - addresses: - - "239.0.60.53" - - "192.168.1.255" - - "10.0.0.255" - on_receive: - - logger.log: - format: "Received %zu bytes" - args: [data.size()] - - udp.write: - id: my_udp - data: "hello world" - - udp.write: - id: my_udp - data: !lambda |- - return std::vector{1,3,4,5,6}; - - udp.write: - id: my_udp - data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] - - udp.write: - id: my_udp - data: !lambda |- - return std::vector(data.begin(), data.end()); + - id: my_udp + listen_address: 239.0.60.53 + addresses: + - "239.0.60.53" + - "192.168.1.255" + - "10.0.0.255" + on_receive: + - logger.log: + format: "Received %zu bytes" + args: [data.size()] + - udp.write: + id: my_udp + data: "hello world" + - udp.write: + id: my_udp + data: !lambda |- + return std::vector{1,3,4,5,6}; + - udp.write: + id: my_udp + data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] + - udp.write: + id: my_udp + data: !lambda |- + return std::vector(data.begin(), data.end()); number: - platform: template @@ -43,8 +43,12 @@ button: on_press: then: - udp.write: + id: my_udp data: [0x01, 0x02, 0x03] - udp.write: + id: my_udp data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] - - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; + - udp.write: + id: my_udp + data: !lambda |- + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/udp/test-ipv6.esp32-idf.yaml b/tests/components/udp/test-ipv6.esp32-idf.yaml new file mode 100644 index 00000000000..1ce3077e5f9 --- /dev/null +++ b/tests/components/udp/test-ipv6.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + udp: !include common.yaml + udp_ipv6: !include common-ipv6.yaml diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 825d86c19e1..98b56cbf793 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -13,3 +13,8 @@ udp: id: my_udp data: !lambda |- return std::vector{1,3,4,5,6}; + # Short forms without id: valid while there is a single udp instance + - udp.write: + data: [0x01, 0x02, 0x03] + - udp.write: !lambda |- + return std::vector(data.begin(), data.end()); diff --git a/tests/components/udp/test.nrf52-adafruit.yaml b/tests/components/udp/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..c5a9072a1d6 --- /dev/null +++ b/tests/components/udp/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +packages: + udp_ipv6: !include common-ipv6.yaml + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/integration/fixtures/udp_ipv6_unicast.yaml b/tests/integration/fixtures/udp_ipv6_unicast.yaml new file mode 100644 index 00000000000..32be96043c9 --- /dev/null +++ b/tests/integration/fixtures/udp_ipv6_unicast.yaml @@ -0,0 +1,24 @@ +esphome: + name: udp-ipv6-unicast-test + +host: + +api: + +network: + enable_ipv6: true + +logger: + level: DEBUG + +udp: + - id: test_udp + port: + listen_port: UDP_LISTEN_PORT_PLACEHOLDER + broadcast_port: UDP_REPLY_PORT_PLACEHOLDER + addresses: + - "::1" + on_receive: + - udp.write: + id: test_udp + data: "PONG" diff --git a/tests/integration/test_udp_ipv6.py b/tests/integration/test_udp_ipv6.py new file mode 100644 index 00000000000..19b8eb5fe41 --- /dev/null +++ b/tests/integration/test_udp_ipv6.py @@ -0,0 +1,63 @@ +"""Integration test for UDP over IPv6 with the default dual-stack listen socket.""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +def _free_port(family: socket.AddressFamily, host: str) -> int: + with socket.socket(family, socket.SOCK_DGRAM) as sock: + sock.bind((host, 0)) + return sock.getsockname()[1] + + +@pytest.mark.asyncio +async def test_udp_ipv6_unicast( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """IPv6 and IPv4 datagrams both reach the listen socket, and replies go out over IPv6.""" + if not socket.has_ipv6: + pytest.skip("Host has no IPv6 support") + try: + reply_sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + reply_sock.bind(("::1", 0)) + except OSError: + pytest.skip("Host has no IPv6 loopback") + reply_sock.setblocking(False) + reply_port = reply_sock.getsockname()[1] + listen_port = _free_port(socket.AF_INET6, "::") + config = yaml_config.replace( + "UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port) + ).replace("UDP_REPLY_PORT_PLACEHOLDER", str(reply_port)) + + loop = asyncio.get_running_loop() + try: + async with ( + run_compiled(config), + api_client_connected() as client, + ): + assert await client.device_info() is not None + + for family, host in ( + (socket.AF_INET6, "::1"), + # An IPv4 sender reaches the IPv6 socket as a mapped address + (socket.AF_INET, "127.0.0.1"), + ): + with socket.socket(family, socket.SOCK_DGRAM) as send_sock: + send_sock.sendto(b"PING", (host, listen_port)) + try: + data = await asyncio.wait_for( + loop.sock_recv(reply_sock, 64), timeout=5.0 + ) + except TimeoutError: + pytest.fail(f"No reply over IPv6 to a PING sent to {host}") + assert data == b"PONG" + finally: + reply_sock.close() From 4e3845ba6878d111eec3dac226d28f8352d04148 Mon Sep 17 00:00:00 2001 From: Martin Klingensmith Date: Mon, 12 Oct 2026 15:20:13 -0600 Subject: [PATCH 392/435] [pd_pioneer_ir] Add Pioneer mini-split IR climate component (#17680) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/pd_pioneer_ir/__init__.py | 1 + esphome/components/pd_pioneer_ir/climate.py | 26 ++ .../pd_pioneer_ir/pd_pioneer_data.cpp | 111 ++++++ .../pd_pioneer_ir/pd_pioneer_data.h | 69 ++++ .../pd_pioneer_ir/pd_pioneer_ir.cpp | 59 +++ .../components/pd_pioneer_ir/pd_pioneer_ir.h | 30 ++ esphome/components/remote_base/__init__.py | 56 +++ .../remote_base/pd_pioneer_protocol.cpp | 57 +++ .../remote_base/pd_pioneer_protocol.h | 72 ++++ esphome/core/defines.h | 1 + tests/components/pd_pioneer_ir/__init__.py | 14 + tests/components/pd_pioneer_ir/benchmark.yaml | 2 + tests/components/pd_pioneer_ir/common.yaml | 6 + .../pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 372 ++++++++++++++++++ .../pd_pioneer_ir/test.esp32-idf.yaml | 4 + .../pd_pioneer_ir/test.esp8266-ard.yaml | 4 + .../remote_receiver/common-actions.yaml | 5 + .../remote_receiver/esp32-common.yaml | 4 + .../remote_transmitter/common-buttons.yaml | 5 + 20 files changed, 899 insertions(+) create mode 100644 esphome/components/pd_pioneer_ir/__init__.py create mode 100644 esphome/components/pd_pioneer_ir/climate.py create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_data.h create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_ir.h create mode 100644 esphome/components/remote_base/pd_pioneer_protocol.cpp create mode 100644 esphome/components/remote_base/pd_pioneer_protocol.h create mode 100644 tests/components/pd_pioneer_ir/__init__.py create mode 100644 tests/components/pd_pioneer_ir/benchmark.yaml create mode 100644 tests/components/pd_pioneer_ir/common.yaml create mode 100644 tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp create mode 100644 tests/components/pd_pioneer_ir/test.esp32-idf.yaml create mode 100644 tests/components/pd_pioneer_ir/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 27aac4fb68e..02e66742e5f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -417,6 +417,7 @@ esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman esphome/components/pcm5122/* @remcom +esphome/components/pd_pioneer_ir/* @martinsah esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 diff --git a/esphome/components/pd_pioneer_ir/__init__.py b/esphome/components/pd_pioneer_ir/__init__.py new file mode 100644 index 00000000000..3ca0ea4f780 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pd_pioneer_ir" diff --git a/esphome/components/pd_pioneer_ir/climate.py b/esphome/components/pd_pioneer_ir/climate.py new file mode 100644 index 00000000000..6bd3cc88d50 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/climate.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_USE_FAHRENHEIT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@martinsah"] + +pd_pioneer_ir_ns = cg.esphome_ns.namespace("pd_pioneer_ir") +PDPioneerIR = pd_pioneer_ir_ns.class_("PDPioneerIR", climate_ir.ClimateIR) + + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(PDPioneerIR).extend( + { + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + # The unit's auto mode is encoded but untested, so it is opt-in + cv.Optional(climate_ir.CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean, + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("pd_pioneer") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp new file mode 100644 index 00000000000..3d07d9006b2 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp @@ -0,0 +1,111 @@ +#include "pd_pioneer_data.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +// Default frames stay in flash; the constructor copies them with progmem_memcpy. Even byte 5 is PWR_ON, byte 6 +// MODE_COOL +static const uint8_t DEFAULT_ODD[] PROGMEM = {0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, + 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00}; +static const uint8_t DEFAULT_EVEN[] PROGMEM = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, + 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80}; + +// Odd positions 5 and 6 and the even position 8 fan code, indexed from CLIMATE_FAN_AUTO to CLIMATE_FAN_HIGH +static const uint8_t FAN_CODES[][3] PROGMEM = { + {0x40, 0x20, 0x00}, {0x60, 0x40, 0x02}, {0x40, 0x60, 0x03}, {0x40, 0xC0, 0x05}}; +static constexpr size_t FAN_CODE_COUNT = sizeof(FAN_CODES) / sizeof(FAN_CODES[0]); + +ControlData::ControlData() : odd_(DEFAULT_ODD, sizeof(DEFAULT_ODD)), even_(DEFAULT_EVEN, sizeof(DEFAULT_EVEN)) {} + +void ControlData::set_temp(float temp_c) { + // The temperature travels as 31 - C in whole degrees at position 7, with bit 2 of position 12 adding a half + temp_c = clamp(temp_c, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + // Round to a quarter, then down to a half, so Fahrenheit set points encode as before + const int halves = static_cast(lroundf(temp_c * 4)) / 2; + this->even_[7] = static_cast(31 - halves / 2); + this->even_[12] = static_cast(0x80 | ((halves & 1) ? 0x04 : 0x00)); +} + +void ControlData::set_mode(ClimateMode mode) { + switch (mode) { + case ClimateMode::CLIMATE_MODE_OFF: + this->even_[5] = PWR_OFF; + this->even_[6] = MODE_HEAT; + this->even_[7] = 0x0C; + this->even_[12] = 0x80; + return; + case ClimateMode::CLIMATE_MODE_COOL: + this->even_[6] = MODE_COOL; + break; + case ClimateMode::CLIMATE_MODE_DRY: + this->even_[6] = MODE_DRY; + break; + case ClimateMode::CLIMATE_MODE_FAN_ONLY: + this->even_[6] = MODE_FAN_ONLY; + break; + case ClimateMode::CLIMATE_MODE_HEAT: + this->even_[6] = MODE_HEAT; + break; + default: + this->even_[6] = MODE_AUTO; + break; + } + this->even_[5] = PWR_ON; +} + +optional ControlData::get_mode(const PDPioneerData &even) { + if (even[5] == PWR_OFF) + return ClimateMode::CLIMATE_MODE_OFF; + switch (even[6]) { + case MODE_COOL: + return ClimateMode::CLIMATE_MODE_COOL; + case MODE_DRY: + return ClimateMode::CLIMATE_MODE_DRY; + case MODE_FAN_ONLY: + return ClimateMode::CLIMATE_MODE_FAN_ONLY; + case MODE_HEAT: + return ClimateMode::CLIMATE_MODE_HEAT; + case MODE_AUTO: + return ClimateMode::CLIMATE_MODE_HEAT_COOL; + default: + return {}; + } +} + +void ControlData::finalize() { + const size_t idx = this->fan_ >= climate::CLIMATE_FAN_AUTO && this->fan_ <= climate::CLIMATE_FAN_HIGH + ? this->fan_ - climate::CLIMATE_FAN_AUTO + : FAN_CODE_COUNT - 1; + this->odd_[5] = progmem_read_byte(&FAN_CODES[idx][0]); + this->odd_[6] = progmem_read_byte(&FAN_CODES[idx][1]); + this->odd_[7] = this->swing_; + this->even_[8] = static_cast(progmem_read_byte(&FAN_CODES[idx][2]) | + ((this->swing_ & SWING_VERTICAL) ? SWING_VERTICAL_EVEN_MASK : 0)); + this->odd_.finalize(); + this->even_.finalize(); +} + +ClimateFanMode ControlData::get_fan_mode(const PDPioneerData &even) { + const uint8_t code = even[8] & ~SWING_VERTICAL_EVEN_MASK; + for (size_t idx = 0; idx < FAN_CODE_COUNT; idx++) { + if (progmem_read_byte(&FAN_CODES[idx][2]) == code) + return static_cast(climate::CLIMATE_FAN_AUTO + idx); + } + return ClimateFanMode::CLIMATE_FAN_HIGH; +} + +void ControlData::set_swing_mode(climate::ClimateSwingMode mode) { + const bool vertical = mode == climate::CLIMATE_SWING_VERTICAL || mode == climate::CLIMATE_SWING_BOTH; + const bool horizontal = mode == climate::CLIMATE_SWING_HORIZONTAL || mode == climate::CLIMATE_SWING_BOTH; + this->swing_ = static_cast((vertical ? SWING_VERTICAL : 0) | (horizontal ? SWING_HORIZONTAL : 0)); +} + +climate::ClimateSwingMode ControlData::get_swing_mode(const PDPioneerData &even, bool horizontal) { + const bool vertical = (even[8] & SWING_VERTICAL_EVEN_MASK) == SWING_VERTICAL_EVEN_MASK; + if (vertical) + return horizontal ? climate::CLIMATE_SWING_BOTH : climate::CLIMATE_SWING_VERTICAL; + return horizontal ? climate::CLIMATE_SWING_HORIZONTAL : climate::CLIMATE_SWING_OFF; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.h b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h new file mode 100644 index 00000000000..ffea27deddc --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/components/remote_base/pd_pioneer_protocol.h" +#include "esphome/components/climate/climate_mode.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +constexpr uint8_t PDPIONEER_TEMPC_MIN = 16; +constexpr uint8_t PDPIONEER_TEMPC_MAX = 31; + +using climate::ClimateMode; +using climate::ClimateFanMode; +using remote_base::PDPioneerData; + +class ControlData { + public: + ControlData(); + void set_temp(float temp_c); + void set_mode(ClimateMode mode); + void set_fan_mode(ClimateFanMode mode) { this->fan_ = mode; } + void set_swing_mode(climate::ClimateSwingMode mode); + void set_eco(bool enabled) { + if (enabled) + this->even_[5] = PWR_ECO; + } + + /// Write the fan and swing positions, which share bytes, then the checksums + void finalize(); + const PDPioneerData &odd() const { return this->odd_; } + const PDPioneerData &even() const { return this->even_; } + + /// Clamped to the set point range, so a frame from a sibling remote cannot publish an impossible value + static float get_temp(const PDPioneerData &even) { + const float temp = ((even[12] & 0x04) ? 31.5f : 31.0f) - static_cast(even[7]); + return clamp(temp, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + } + /// Empty for a mode byte this remote does not use + static optional get_mode(const PDPioneerData &even); + static bool get_eco(const PDPioneerData &even) { return even[5] == PWR_ECO; } + static ClimateFanMode get_fan_mode(const PDPioneerData &even); + /// Only the odd burst carries horizontal swing; the even burst carries vertical swing + static bool get_horizontal_swing(const PDPioneerData &odd) { return (odd[7] & SWING_HORIZONTAL) == SWING_HORIZONTAL; } + static climate::ClimateSwingMode get_swing_mode(const PDPioneerData &even, bool horizontal); + + protected: + static constexpr uint8_t MODE_HEAT = 0x01; + static constexpr uint8_t MODE_DRY = 0x02; + static constexpr uint8_t MODE_COOL = 0x03; + static constexpr uint8_t MODE_FAN_ONLY = 0x07; + static constexpr uint8_t MODE_AUTO = 0x08; + + static constexpr uint8_t PWR_ON = 0x24; + static constexpr uint8_t PWR_ECO = 0x25; + static constexpr uint8_t PWR_OFF = 0xA0; + + static constexpr uint8_t SWING_VERTICAL = 0x08; + static constexpr uint8_t SWING_HORIZONTAL = 0x90; + /// Vertical swing also sets this mask on even frame position 8 (fan sub-code). + static constexpr uint8_t SWING_VERTICAL_EVEN_MASK = 0x38; + + PDPioneerData odd_; + PDPioneerData even_; + ClimateFanMode fan_{climate::CLIMATE_FAN_HIGH}; + /// Odd-burst position 7: SWING_VERTICAL and SWING_HORIZONTAL flags + uint8_t swing_{0}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp new file mode 100644 index 00000000000..4ddcb3bc270 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp @@ -0,0 +1,59 @@ +#include "pd_pioneer_ir.h" +#include "esphome/core/log.h" + +namespace esphome::pd_pioneer_ir { + +ESPHOME_LOG_TAG(TAG, "pd_pioneer_ir"); + +void PDPioneerIR::setup() { + climate_ir::ClimateIR::setup(); + this->publish_state(); +} + +void PDPioneerIR::transmit_state() { + ControlData data; + data.set_mode(this->mode); + if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_FAN_ONLY) + data.set_temp(this->target_temperature); + data.set_fan_mode(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + data.set_swing_mode(this->swing_mode); + // ECO shares the power byte, so it must not undo an off command + data.set_eco(this->mode != climate::CLIMATE_MODE_OFF && this->preset == climate::CLIMATE_PRESET_ECO); + data.finalize(); + // The remote sends the two bursts as separate transmissions + this->transmit_(data.odd()); + this->transmit_(data.even()); +} + +bool PDPioneerIR::on_receive(remote_base::RemoteReceiveData data) { + auto frame = remote_base::PDPioneerProtocol().decode(data); + if (!frame.has_value()) + return false; + if (frame->is_odd_burst()) { + this->rx_horizontal_swing_ = ControlData::get_horizontal_swing(*frame); + return false; + } + // decode() only returns odd or even bursts; a mode byte this remote never sends is another device's frame + const optional decoded = ControlData::get_mode(*frame); + if (!decoded.has_value()) + return false; + // An auto-mode frame from the remote must not publish HEAT_COOL when the traits leave it out + if (this->get_traits().supports_mode(*decoded)) { + this->mode = *decoded; + } else { + ESP_LOGD(TAG, "Received mode %s is not enabled, keeping the current mode", + LOG_STR_ARG(climate::climate_mode_to_string(*decoded))); + } + // Off and fan-only bursts carry no target temperature + if (*decoded != climate::CLIMATE_MODE_OFF && *decoded != climate::CLIMATE_MODE_FAN_ONLY) + this->target_temperature = ControlData::get_temp(*frame); + this->fan_mode = ControlData::get_fan_mode(*frame); + this->swing_mode = ControlData::get_swing_mode(*frame, this->rx_horizontal_swing_); + // A missed odd burst must not carry this command's horizontal swing into the next one + this->rx_horizontal_swing_ = false; + this->preset = ControlData::get_eco(*frame) ? climate::CLIMATE_PRESET_ECO : climate::CLIMATE_PRESET_NONE; + this->publish_state(); + return true; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h new file mode 100644 index 00000000000..38b325dc1e3 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "pd_pioneer_data.h" + +namespace esphome::pd_pioneer_ir { + +class PDPioneerIR : public climate_ir::ClimateIR { + public: + PDPioneerIR() + : climate_ir::ClimateIR(PDPIONEER_TEMPC_MIN, PDPIONEER_TEMPC_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}, + {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO}) {} + + void setup() override; + + void set_fahrenheit(bool value) { this->temperature_step_ = value ? 0.5f : 1.0f; } + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + // Horizontal swing from the odd burst, applied to the even burst that follows it + bool rx_horizontal_swing_{false}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 9cf9493cd0e..286d209e980 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2409,6 +2409,62 @@ register_protocol_action( ) +# PD Pioneer +( + PDPioneerData, + PDPioneerBinarySensor, + PDPioneerTrigger, + _, + PDPioneerDumper, +) = declare_protocol("PDPioneer") + + +def _validate_pd_pioneer_burst(code: list[int]) -> list[int]: + """Byte 3 tells the odd (0x02) and even (0x01) bursts apart; decode() drops anything else.""" + if code[3] not in (0x01, 0x02): + raise cv.Invalid("Byte 3 of a pd_pioneer code must be 0x01 or 0x02") + return code + + +PDPIONEER_SCHEMA = cv.Schema( + { + cv.Required(CONF_CODE): cv.All( + [cv.hex_uint8_t], cv.Length(min=13, max=14), _validate_pd_pioneer_burst + ), + } +) + + +def _pd_pioneer_finalized(code: list[int]) -> list[int]: + """Fill in the checksum: the sum of the first 13 bytes, plus 15 for an odd burst.""" + checksum = sum(code[:13], 15 if code[3] == 0x02 else 0) & 0xFF + return [*code[:13], checksum] + + +@register_binary_sensor("pd_pioneer", PDPioneerBinarySensor, PDPIONEER_SCHEMA) +def pd_pioneer_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add(var.set_data(_pd_pioneer_finalized(config[CONF_CODE]))) + + +@register_trigger("pd_pioneer", PDPioneerTrigger, PDPioneerData) +def pd_pioneer_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("pd_pioneer", PDPioneerDumper) +def pd_pioneer_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "pd_pioneer", + "PDPioneer", + PDPIONEER_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PDPioneerData}({{}})"), + automation.ApplyCall("data.finalize()"), +) + + # Midea MideaData, MideaBinarySensor, MideaTrigger, _, MideaDumper = declare_protocol("Midea") MIDEA_SCHEMA = cv.Schema( diff --git a/esphome/components/remote_base/pd_pioneer_protocol.cpp b/esphome/components/remote_base/pd_pioneer_protocol.cpp new file mode 100644 index 00000000000..72568be649d --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.cpp @@ -0,0 +1,57 @@ +#include "pd_pioneer_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pd_pioneer"); + +// Timings from captured Pronto codes (006D timebase, 26 us per word) +static constexpr uint32_t HEADER_MARK_US = 3120; +static constexpr uint32_t HEADER_SPACE_US = 1560; +static constexpr uint32_t BIT_MARK_US = 520; +static constexpr uint32_t BIT_ONE_SPACE_US = 1066; +static constexpr uint32_t BIT_ZERO_SPACE_US = 312; +static constexpr uint32_t FOOTER_MARK_US = 520; +static constexpr uint32_t FOOTER_SPACE_US = 10010; + +void PDPioneerProtocol::encode(RemoteTransmitData *dst, const PDPioneerData &src) { + dst->set_carrier_frequency(38000); + dst->reserve(2 + PDPioneerData::FRAME_SIZE * 8 * 2 + 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) + dst->item(BIT_MARK_US, (src[idx] & mask) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->item(FOOTER_MARK_US, FOOTER_SPACE_US); +} + +optional PDPioneerProtocol::decode(RemoteReceiveData src) { + PDPioneerData out; + if (!src.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) + return {}; + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) { + if (src.expect_item(BIT_MARK_US, BIT_ONE_SPACE_US)) { + out[idx] |= mask; + } else if (!src.expect_item(BIT_MARK_US, BIT_ZERO_SPACE_US)) { + return {}; + } + } + } + // The receiver's idle timeout usually ends the capture inside the footer space, so only the mark is required + if (!src.expect_mark(FOOTER_MARK_US)) + return {}; + if (!out.is_valid()) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGV(TAG, "Checksum mismatch: %s", out.to_str(buf)); + return {}; + } + return out; +} + +void PDPioneerProtocol::dump(const PDPioneerData &data) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGI(TAG, "Received PD-Pioneer: %s", data.to_str(buf)); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pd_pioneer_protocol.h b/esphome/components/remote_base/pd_pioneer_protocol.h new file mode 100644 index 00000000000..b324218f14b --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PDPioneerData { + public: + static constexpr uint8_t FRAME_SIZE = 14; + static constexpr uint8_t OFFSET_CS = 13; + + PDPioneerData() = default; + PDPioneerData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + + // Position 3 tells the two bursts of a command apart; the odd burst's checksum carries an extra 15 + bool is_odd_burst() const { return this->data_[3] == 0x02; } + bool is_even_burst() const { return this->data_[3] == 0x01; } + + bool is_valid() const { + return (this->is_odd_burst() || this->is_even_burst()) && this->data_[OFFSET_CS] == this->checksum_(); + } + + void finalize() { this->data_[OFFSET_CS] = this->checksum_(); } + + static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(FRAME_SIZE); + const char *to_str(char *buffer) const { + format_hex_pretty_to(buffer, TO_STR_BUFFER_SIZE, this->data_.data(), this->data_.size(), '.'); + return buffer; + } + + bool operator==(const PDPioneerData &rhs) const { return this->data_ == rhs.data_; } + + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + uint8_t checksum_() const { + uint8_t cs = this->is_odd_burst() ? 15 : 0; + for (uint8_t idx = 0; idx < OFFSET_CS; idx++) + cs += this->data_[idx]; + return cs; + } + + std::array data_{}; +}; + +class PDPioneerProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PDPioneerData &src); + optional decode(RemoteReceiveData src); + void dump(const PDPioneerData &data); +}; + +DECLARE_REMOTE_PROTOCOL(PDPioneer) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3168d269cf4..943458e3f64 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -182,6 +182,7 @@ #define USE_REMOTE_PROTOCOL_NEXUS #define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PD_PIONEER #define USE_REMOTE_PROTOCOL_PIONEER #define USE_REMOTE_PROTOCOL_PIONEER_WYT #define USE_REMOTE_PROTOCOL_PRONTO diff --git a/tests/components/pd_pioneer_ir/__init__.py b/tests/components/pd_pioneer_ir/__init__.py new file mode 100644 index 00000000000..b2d1d1c7160 --- /dev/null +++ b/tests/components/pd_pioneer_ir/__init__.py @@ -0,0 +1,14 @@ +from esphome.components import remote_base +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # 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 platform's to_code never runs here; without this request remote_base drops the protocol source + remote_base.request_protocol("pd_pioneer") + + manifest.to_code = to_code_testing diff --git a/tests/components/pd_pioneer_ir/benchmark.yaml b/tests/components/pd_pioneer_ir/benchmark.yaml new file mode 100644 index 00000000000..5126d849349 --- /dev/null +++ b/tests/components/pd_pioneer_ir/benchmark.yaml @@ -0,0 +1,2 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test build +climate: [] diff --git a/tests/components/pd_pioneer_ir/common.yaml b/tests/components/pd_pioneer_ir/common.yaml new file mode 100644 index 00000000000..e799f7587f2 --- /dev/null +++ b/tests/components/pd_pioneer_ir/common.yaml @@ -0,0 +1,6 @@ +climate: + - platform: pd_pioneer_ir + name: Pioneer AC + transmitter_id: xmitr + receiver_id: rcvr + use_fahrenheit: true diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp new file mode 100644 index 00000000000..e7a5e2c18f2 --- /dev/null +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -0,0 +1,372 @@ +#include +#include +#include +#include + +#include "esphome/components/pd_pioneer_ir/pd_pioneer_ir.h" +#include "esphome/components/remote_base/pd_pioneer_protocol.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::pd_pioneer_ir::testing { + +using remote_base::PDPioneerData; +using Frame = std::vector; + +static constexpr uint32_t TOLERANCE_PERCENT = 25; +static constexpr uint8_t FRAME_SIZE = PDPioneerData::FRAME_SIZE; + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +/// Fill in the checksum: the sum of the first 13 values, plus 15 for an odd burst +static Frame finalized(Frame frame) { + uint8_t sum = frame[3] == 0x02 ? 15 : 0; + for (size_t idx = 0; idx < FRAME_SIZE - 1; idx++) + sum += frame[idx]; + frame[FRAME_SIZE - 1] = sum; + return frame; +} + +static Frame odd_frame(uint8_t fan5, uint8_t fan6, uint8_t swing) { + return finalized({0x23, 0xCB, 0x26, 0x02, 0x00, fan5, fan6, swing, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00}); +} + +static Frame even_frame(uint8_t power, uint8_t mode, uint8_t temp, uint8_t fan8, uint8_t byte12) { + return finalized({0x23, 0xCB, 0x26, 0x01, 0x00, power, mode, temp, fan8, 0x00, 0x00, 0x00, byte12, 0x00}); +} + +static remote_base::RawTimings encode(const Frame &frame) { + remote_base::RemoteTransmitData data; + remote_base::PDPioneerProtocol().encode(&data, PDPioneerData(frame)); + return data.get_data(); +} + +/// RemoteReceiveData keeps a reference to the timings, so they must outlive it +static remote_base::RemoteReceiveData rx(const remote_base::RawTimings &raw) { + return remote_base::RemoteReceiveData(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); +} + +static Frame decode(const remote_base::RawTimings &raw) { + const auto decoded = remote_base::PDPioneerProtocol().decode(rx(raw)); + EXPECT_TRUE(decoded.has_value()); + if (!decoded.has_value()) + return {}; + return Frame(decoded->data(), decoded->data() + FRAME_SIZE); +} + +struct Fixture { + CapturingTransmitter transmitter; + PDPioneerIR climate; + + Fixture() { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_pd_pioneer_ir"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing = climate::CLIMATE_SWING_OFF, + climate::ClimatePreset preset = climate::CLIMATE_PRESET_NONE) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.set_preset(preset); + call.perform(); + } + + /// The odd and even bursts of the last command, decoded by the protocol + std::pair last_pair() const { + EXPECT_GE(this->transmitter.frames.size(), 2u); + if (this->transmitter.frames.size() < 2) + return {}; + const size_t n = this->transmitter.frames.size(); + return {decode(this->transmitter.frames[n - 2]), decode(this->transmitter.frames[n - 1])}; + } + + bool receive(const remote_base::RawTimings &raw) { + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(rx(raw)); + } + + bool receive(const Frame &frame) { return this->receive(encode(frame)); } +}; + +TEST(PDPioneerIR, ProtocolRoundTripsAFrame) { + const Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + EXPECT_EQ(decode(encode(frame)), frame); + const Frame odd = odd_frame(0x40, 0xC0, 0x00); + EXPECT_EQ(decode(encode(odd)), odd); +} + +TEST(PDPioneerIR, ProtocolRejectsBadChecksumAndUnknownBurst) { + Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + frame[13] ^= 0x01; + const remote_base::RawTimings bad = encode(frame); + EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(bad)).has_value()); + Frame unknown = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + unknown[3] = 0x03; + unknown = finalized(unknown); + const remote_base::RawTimings other = encode(unknown); + EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(other)).has_value()); +} + +TEST(PDPioneerIR, EncodesEachMode) { + struct Case { + climate::ClimateMode mode; + uint8_t code; + }; + // 24 C: 31.75 - 24 = 7.75, position 7 = 7, bit 2 of position 12 clear + static constexpr Case CASES[] = { + {climate::CLIMATE_MODE_COOL, 0x03}, + {climate::CLIMATE_MODE_HEAT, 0x01}, + {climate::CLIMATE_MODE_DRY, 0x02}, + {climate::CLIMATE_MODE_HEAT_COOL, 0x08}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.mode)); + Fixture f; + f.command(c.mode, 24.0f, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00)); + EXPECT_EQ(even, even_frame(0x24, c.code, 0x07, 0x05, 0x80)); + } +} + +TEST(PDPioneerIR, FanOnlyCarriesNoTemperature) { + Fixture f; + f.command(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x60, 0x40, 0x00)); + // Position 7 keeps the template value 0x0B and position 8 follows the fan + EXPECT_EQ(even, even_frame(0x24, 0x07, 0x0B, 0x02, 0x80)); +} + +TEST(PDPioneerIR, EncodesOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00)); + EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80)); +} + +TEST(PDPioneerIR, EncodesFanModes) { + struct Case { + climate::ClimateFanMode fan; + uint8_t odd5; + uint8_t odd6; + uint8_t even8; + }; + static constexpr Case CASES[] = { + {climate::CLIMATE_FAN_AUTO, 0x40, 0x20, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x60, 0x40, 0x02}, + {climate::CLIMATE_FAN_MEDIUM, 0x40, 0x60, 0x03}, + {climate::CLIMATE_FAN_HIGH, 0x40, 0xC0, 0x05}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.fan)); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(c.odd5, c.odd6, 0x00)); + EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80)); + } +} + +TEST(PDPioneerIR, EncodesSwing) { + struct Case { + climate::ClimateSwingMode swing; + uint8_t odd7; + uint8_t even8; + }; + // Vertical swing also ORs 0x38 into the fan field of the even burst (0x05 for high) + static constexpr Case CASES[] = { + {climate::CLIMATE_SWING_VERTICAL, 0x08, 0x3D}, + {climate::CLIMATE_SWING_HORIZONTAL, 0x90, 0x05}, + {climate::CLIMATE_SWING_BOTH, 0x98, 0x3D}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.swing)); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, c.swing); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, c.odd7)); + EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80)); + } +} + +TEST(PDPioneerIR, SwingAndFanEncodeInAnyOrder) { + ControlData fan_first; + fan_first.set_fan_mode(climate::CLIMATE_FAN_LOW); + fan_first.set_swing_mode(climate::CLIMATE_SWING_BOTH); + fan_first.finalize(); + ControlData swing_first; + swing_first.set_swing_mode(climate::CLIMATE_SWING_BOTH); + swing_first.set_fan_mode(climate::CLIMATE_FAN_LOW); + swing_first.finalize(); + EXPECT_EQ(swing_first.odd(), fan_first.odd()); + EXPECT_EQ(swing_first.even(), fan_first.even()); + EXPECT_EQ(swing_first.odd()[7], 0x98); + EXPECT_EQ(swing_first.even()[8], 0x3A); +} + +TEST(PDPioneerIR, EncodesTemperatureInHalfDegrees) { + struct Case { + float temperature; + uint8_t byte7; + uint8_t byte12; + }; + // 31 - C in whole degrees, plus a bit for the half: 16 -> 15, 24.5 -> 7 with the bit set, 31 -> 0 + static constexpr Case CASES[] = { + {16.0f, 0x0F, 0x80}, {24.0f, 0x07, 0x80}, {24.5f, 0x07, 0x84}, + {31.0f, 0x00, 0x80}, {10.0f, 0x0F, 0x80}, {35.0f, 0x00, 0x80}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(c.temperature); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, c.temperature, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0x24, 0x03, c.byte7, 0x05, c.byte12)); + } +} + +TEST(PDPioneerIR, EncodesEcoPreset) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF, + climate::CLIMATE_PRESET_ECO); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0x25, 0x03, 0x07, 0x05, 0x80)); +} + +TEST(PDPioneerIR, OffWithEcoPresetStaysOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF, + climate::CLIMATE_PRESET_ECO); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80)); +} + +TEST(PDPioneerIR, ReceivesAnOddAndEvenPair) { + Fixture f; + // The odd burst alone changes nothing; the even burst completes the command + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_TRUE(f.receive(even_frame(0x25, 0x01, 0x07, 0x3A, 0x84))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.5f); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); +} + +TEST(PDPioneerIR, ReceivesAnEvenBurstWithoutItsOddBurst) { + Fixture f; + // Position 8 is the medium fan code 0x03 with the vertical swing mask 0x38 + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3B, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // Horizontal swing from an earlier pair does not carry over to an even burst whose odd burst was missed + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3A, 0x80))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x00, 0x80))); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(PDPioneerIR, ReceivedUnknownModeIsIgnored) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + // Mode byte 0x05 belongs to no mode of this remote + EXPECT_FALSE(f.receive(even_frame(0x24, 0x05, 0x07, 0x05, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); +} + +TEST(PDPioneerIR, ReceivedTemperatureIsClamped) { + Fixture f; + // Position 7 above 0x0F would decode below the 16 C minimum + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x1F, 0x05, 0x80))); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 16.0f); +} + +TEST(PDPioneerIR, ReceivedAutoModeIsIgnoredWithoutHeatCool) { + Fixture f; + f.climate.set_supports_heat_cool(false); + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x08))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x08, 0x07, 0x3A, 0x80))); + // The mode stays as it was; the rest of the command still applies + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.0f); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(PDPioneerIR, ReceivedOffAndFanOnlyKeepTheTemperature) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00))); + EXPECT_TRUE(f.receive(even_frame(0xA0, 0x01, 0x0C, 0x03, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); + EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x07, 0x0B, 0x05, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); +} + +TEST(PDPioneerIR, TransmittedCommandIsReceivedBack) { + Fixture sender; + sender.command(climate::CLIMATE_MODE_DRY, 19.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_VERTICAL); + Fixture receiver; + const size_t n = sender.transmitter.frames.size(); + EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2])); + EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1])); + EXPECT_EQ(receiver.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(receiver.climate.target_temperature, 19.0f); + EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(receiver.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(PDPioneerIR, AutoFanIsReceivedBack) { + Fixture sender; + sender.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO); + Fixture receiver; + const size_t n = sender.transmitter.frames.size(); + EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2])); + EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1])); + EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_AUTO); +} + +TEST(PDPioneerIR, StaticCodeIsSentWithItsChecksum) { + // A 13-byte code, filled and finalized as the transmit action does + static const uint8_t CODE[] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x07, 0x05, 0x00, 0x00, 0x00, 0x80}; + remote_base::PDPioneerData data(CODE, sizeof(CODE)); + data.finalize(); + remote_base::RemoteTransmitData dst; + remote_base::PDPioneerProtocol().encode(&dst, data); + EXPECT_EQ(decode(dst.get_data()), even_frame(0x24, 0x03, 0x07, 0x05, 0x80)); +} + +} // namespace esphome::pd_pioneer_ir::testing diff --git a/tests/components/pd_pioneer_ir/test.esp32-idf.yaml b/tests/components/pd_pioneer_ir/test.esp32-idf.yaml new file mode 100644 index 00000000000..6172163ce47 --- /dev/null +++ b/tests/components/pd_pioneer_ir/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + pd_pioneer_ir: !include common.yaml diff --git a/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml b/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml new file mode 100644 index 00000000000..98fbf9714ae --- /dev/null +++ b/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + pd_pioneer_ir: !include common.yaml diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 6aa7ae37bb9..da88548d2ab 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -192,6 +192,11 @@ on_hob2hood: - logger.log: format: "on_hob2hood: %u" args: ["x.command"] +on_pd_pioneer: + then: + - logger.log: + format: "on_pd_pioneer: burst %u power %u" + args: ["x[3]", "x[5]"] on_ira211: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 50623190406..edd5ee3647b 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -62,3 +62,7 @@ binary_sensor: nexus: channel: 1 address: 0x42 + - platform: remote_receiver + name: PD Pioneer Cool + pd_pioneer: + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80] diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index b3583ffe12d..66ee803d4f9 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -531,3 +531,8 @@ button: repeat: times: 3 wait_time: 30ms + - platform: template + name: PD Pioneer + on_press: + - remote_transmitter.transmit_pd_pioneer: + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80] From 6bf091104fc395c9b6ab821ac5dbdf07b57f2b55 Mon Sep 17 00:00:00 2001 From: matt123p Date: Mon, 12 Oct 2026 22:22:17 +0100 Subject: [PATCH 393/435] [es7210] Add TDM support for future AEC support (#18954) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/es7210/__init__.py | 2 + esphome/components/es7210/audio_adc.py | 38 ++++++-- esphome/components/es7210/es7210.cpp | 31 +++++-- esphome/components/es7210/es7210.h | 13 ++- .../component_tests/es7210/test_audio_adc.py | 89 +++++++++++++++++++ .../components/es7210/test.esp32-s3-idf.yaml | 11 +++ 6 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/es7210/test_audio_adc.py create mode 100644 tests/components/es7210/test.esp32-s3-idf.yaml diff --git a/esphome/components/es7210/__init__.py b/esphome/components/es7210/__init__.py index 0038d290408..93bb8269199 100644 --- a/esphome/components/es7210/__init__.py +++ b/esphome/components/es7210/__init__.py @@ -1 +1,3 @@ +"""ES7210 audio ADC component.""" + DOMAIN = "es7210" diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index 2defdb0c35c..c2dd40b3e86 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -1,13 +1,18 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import esp32, i2c from esphome.components.audio_adc import AudioAdc +from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2 import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["i2c"] +CONF_CHANNEL_GAINS = "channel_gains" +CONF_TDM = "tdm" + es7210_ns = cg.esphome_ns.namespace("es7210") ES7210 = es7210_ns.class_("ES7210", AudioAdc, cg.Component, i2c.I2CDevice) @@ -21,24 +26,42 @@ ES7210_BITS_PER_SAMPLE_ENUM = { ES7210_MIC_GAINS = [0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 34.5, 36, 37.5] +ES7210_MIC_GAIN_DEFAULT = 24 _validate_bits = cv.float_with_unit("bits", "bit") -CONFIG_SCHEMA = ( + +def _validate_tdm(value: bool) -> bool: + """Reject TDM on ESP32 variants without TDM hardware support.""" + if value and CORE.is_esp32: + esp32.only_on_variant( + unsupported=[VARIANT_ESP32, VARIANT_ESP32S2], + msg_prefix="ES7210 TDM mode", + )(value) + return value + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ES7210), cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( _validate_bits, cv.enum(ES7210_BITS_PER_SAMPLE_ENUM) ), - cv.Optional(CONF_MIC_GAIN, default="24db"): cv.All( + cv.Optional(CONF_MIC_GAIN): cv.All( cv.decibel, cv.one_of(*ES7210_MIC_GAINS) ), + cv.Optional(CONF_CHANNEL_GAINS): cv.All( + cv.ensure_list(cv.All(cv.decibel, cv.one_of(*ES7210_MIC_GAINS))), + cv.Length(min=4, max=4), + ), cv.Optional(CONF_SAMPLE_RATE, default=16000): cv.int_range(min=1), + cv.Optional(CONF_TDM, default=False): cv.All(cv.boolean, _validate_tdm), } ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x40)) + .extend(i2c.i2c_device_schema(0x40)), + cv.has_at_most_one_key(CONF_MIC_GAIN, CONF_CHANNEL_GAINS), ) @@ -48,5 +71,10 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) - cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) + if channel_gains := config.get(CONF_CHANNEL_GAINS): + for channel, gain in enumerate(channel_gains): + cg.add(var.set_channel_gain(channel, gain)) + else: + cg.add(var.set_mic_gain(config.get(CONF_MIC_GAIN, ES7210_MIC_GAIN_DEFAULT))) cg.add(var.set_sample_rate(config[CONF_SAMPLE_RATE])) + cg.add(var.set_tdm(config[CONF_TDM])) diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 0354f719ef5..fbabf7460f2 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -27,8 +27,10 @@ void ES7210::dump_config() { ESP_LOGCONFIG(TAG, "ES7210 audio ADC:\n" " Bits Per Sample: %" PRIu8 "\n" - " Sample Rate: %" PRIu32, - this->bits_per_sample_, this->sample_rate_); + " Sample Rate: %" PRIu32 "\n" + " Channel Gains: %.1f dB, %.1f dB, %.1f dB, %.1f dB", + this->bits_per_sample_, this->sample_rate_, this->channel_gains_[0], this->channel_gains_[1], + this->channel_gains_[2], this->channel_gains_[3]); if (this->is_failed()) { ESP_LOGE(TAG, " Failed to initialize"); @@ -88,7 +90,17 @@ void ES7210::setup() { } bool ES7210::set_mic_gain(float mic_gain) { - this->mic_gain_ = clamp(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX); + this->channel_gains_.fill(clamp(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX)); + if (this->setup_complete_) { + return this->configure_mic_gain_(); + } + return true; +} + +bool ES7210::set_channel_gain(uint8_t channel, float gain) { + if (channel >= this->channel_gains_.size()) + return false; + this->channel_gains_[channel] = clamp(gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX); if (this->setup_complete_) { return this->configure_mic_gain_(); } @@ -133,7 +145,6 @@ bool ES7210::configure_sample_rate_() { } bool ES7210::configure_mic_gain_() { - auto regv = this->es7210_gain_reg_value_(this->mic_gain_); for (uint8_t i = 0; i < 4; ++i) { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43 + i, 0x10, 0x00)); } @@ -144,26 +155,30 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[0]))); // Configure mic 2 ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[1]))); // Configure mic 3 // MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains. ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[2]))); // Configure mic 4 ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[3]))); return true; } diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 42c667b6585..7278d5b3c46 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/components/audio_adc/audio_adc.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" @@ -27,10 +29,15 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I void dump_config() override; void set_bits_per_sample(ES7210BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + /// Sets all four channel gains, replacing any configured per-channel gains. bool set_mic_gain(float mic_gain) override; + /// Sets a zero-based channel gain; returns false for an invalid channel or an I2C failure. + bool set_channel_gain(uint8_t channel, float gain); void set_sample_rate(uint32_t sample_rate) { this->sample_rate_ = sample_rate; } + void set_tdm(bool enable_tdm) { this->enable_tdm_ = enable_tdm; } - float mic_gain() override { return this->mic_gain_; }; + /// Returns the MIC1 gain; other channels may have different gains. + float mic_gain() override { return this->channel_gains_[0]; }; protected: /// @brief Updates an I2C registry address by modifying the current state @@ -50,8 +57,8 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I bool configure_sample_rate_(); bool setup_complete_{false}; - bool enable_tdm_{false}; // TDM is unsupported in ESPHome as of version 2024.12 - float mic_gain_{0}; + bool enable_tdm_{false}; + std::array channel_gains_{{0.0f, 0.0f, 0.0f, 0.0f}}; ES7210BitsPerSample bits_per_sample_{ES7210_BITS_PER_SAMPLE_16}; uint32_t sample_rate_{0}; }; diff --git a/tests/component_tests/es7210/test_audio_adc.py b/tests/component_tests/es7210/test_audio_adc.py new file mode 100644 index 00000000000..12a51eed2ce --- /dev/null +++ b/tests/component_tests/es7210/test_audio_adc.py @@ -0,0 +1,89 @@ +"""Tests for ES7210 audio ADC configuration validation.""" + +import pytest + +from esphome.components.es7210.audio_adc import ( + CONF_CHANNEL_GAINS, + CONF_TDM, + CONFIG_SCHEMA, +) +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_MIC_GAIN, KEY_VARIANT, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_channel_gains_valid_config() -> None: + """Per-channel gains accept an AEC microphone and reference configuration.""" + config = CONFIG_SCHEMA({CONF_CHANNEL_GAINS: ["33db", "33db", "0db", "0db"]}) + + assert config[CONF_CHANNEL_GAINS] == [33.0, 33.0, 0.0, 0.0] + assert CONF_MIC_GAIN not in config + + +def test_omitted_gains_remain_absent() -> None: + """Code generation supplies the default when neither gain option is configured.""" + config = CONFIG_SCHEMA({}) + + assert CONF_MIC_GAIN not in config + assert CONF_CHANNEL_GAINS not in config + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32S2]) +def test_tdm_rejected_on_unsupported_variants( + variant: str, set_core_config: SetCoreConfigCallable +) -> None: + """ESP32 and ESP32-S2 cannot receive TDM audio.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + with pytest.raises(cv.Invalid, match="ES7210 TDM mode is not available") as exc: + CONFIG_SCHEMA({CONF_TDM: True}) + + assert exc.value.path == [CONF_TDM] + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32S2]) +@pytest.mark.parametrize("tdm_config", [{}, {CONF_TDM: False}]) +def test_tdm_disabled_on_unsupported_variants( + variant: str, + tdm_config: dict[str, bool], + set_core_config: SetCoreConfigCallable, +) -> None: + """Unsupported variants still accept standard I2S mode.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + assert CONFIG_SCHEMA(tdm_config)[CONF_TDM] is False + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32S3, VARIANT_ESP32C3, VARIANT_ESP32P4]) +def test_tdm_accepted_on_supported_variants( + variant: str, set_core_config: SetCoreConfigCallable +) -> None: + """Variants with TDM hardware accept TDM mode.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + assert CONFIG_SCHEMA({CONF_TDM: True})[CONF_TDM] is True + + +@pytest.mark.parametrize("channel_count", [3, 5]) +def test_channel_gains_requires_four_channels(channel_count: int) -> None: + """Channel gains must provide one value for each ES7210 input.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_CHANNEL_GAINS: ["0db"] * channel_count}) + + +def test_channel_gains_and_mic_gain_are_mutually_exclusive() -> None: + """Uniform and per-channel gain configuration cannot be combined.""" + with pytest.raises(cv.Invalid, match="Cannot specify more than one"): + CONFIG_SCHEMA( + { + CONF_MIC_GAIN: "24db", + CONF_CHANNEL_GAINS: ["0db"] * 4, + } + ) diff --git a/tests/components/es7210/test.esp32-s3-idf.yaml b/tests/components/es7210/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..26409ccdaef --- /dev/null +++ b/tests/components/es7210/test.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + +audio_adc: + - platform: es7210 + i2c_id: i2c_bus + id: es7210_adc + bits_per_sample: 16bit + channel_gains: [33db, 33db, 0db, 0db] + sample_rate: 16000 + tdm: true From 1c8808339aab2e77d5f4d6225cf93aee54015bcc Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 17:23:55 -0400 Subject: [PATCH 394/435] [power_management] Add the power_management domain with an esp32_power_management platform (#12325) Co-authored-by: Kevin Ahrendt Co-authored-by: Kevin Ahrendt Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/esp32/gpio.cpp | 4 + .../esp32_power_management/__init__.py | 1 + .../esp32_power_management.cpp | 75 ++++ .../esp32_power_management.h | 28 ++ .../power_management.py | 254 ++++++++++++ .../components/power_management/__init__.py | 16 + .../power_management/power_management.h | 9 + .../esp32_power_management/__init__.py | 1 + .../esp32_power_management/test_init.py | 361 ++++++++++++++++++ .../esp32_power_management/common.yaml | 11 + .../test.esp32-ard.yaml | 7 + .../test.esp32-h2-idf.yaml | 7 + .../test.esp32-idf.yaml | 7 + 14 files changed, 783 insertions(+) create mode 100644 esphome/components/esp32_power_management/__init__.py create mode 100644 esphome/components/esp32_power_management/esp32_power_management.cpp create mode 100644 esphome/components/esp32_power_management/esp32_power_management.h create mode 100644 esphome/components/esp32_power_management/power_management.py create mode 100644 esphome/components/power_management/__init__.py create mode 100644 esphome/components/power_management/power_management.h create mode 100644 tests/component_tests/esp32_power_management/__init__.py create mode 100644 tests/component_tests/esp32_power_management/test_init.py create mode 100644 tests/components/esp32_power_management/common.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-ard.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-h2-idf.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 02e66742e5f..b45816640ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,6 +184,7 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 +esphome/components/esp32_power_management/* @rwrozelle esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core @@ -436,6 +437,7 @@ esphome/components/pn7160/* @jesserockz @kbx81 esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/pn71xx/* @jesserockz @kbx81 +esphome/components/power_management/* @rwrozelle esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core esphome/components/provisioning/* @esphome/core diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 59788e76dc7..d9e303c973b 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -164,6 +164,10 @@ void ESP32InternalGPIOPin::setup() { conf.pull_down_en = this->flags_ & gpio::FLAG_PULLDOWN ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE; conf.intr_type = GPIO_INTR_DISABLE; gpio_config(&conf); +#ifdef CONFIG_FREERTOS_USE_TICKLESS_IDLE + // If auto light sleep is used gpio sleep mode needs to be disabled for the pin + gpio_sleep_sel_dis(this->get_pin_num()); +#endif if (this->flags_ & gpio::FLAG_OUTPUT) { gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength()); } diff --git a/esphome/components/esp32_power_management/__init__.py b/esphome/components/esp32_power_management/__init__.py new file mode 100644 index 00000000000..76622032cdf --- /dev/null +++ b/esphome/components/esp32_power_management/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_power_management" diff --git a/esphome/components/esp32_power_management/esp32_power_management.cpp b/esphome/components/esp32_power_management/esp32_power_management.cpp new file mode 100644 index 00000000000..41ba352e5a6 --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.cpp @@ -0,0 +1,75 @@ +#include "esp32_power_management.h" +#ifdef USE_ESP32 +#include "esphome/core/log.h" + +#include "esp_private/esp_clk.h" +#include "soc/rtc.h" + +namespace esphome::esp32_power_management { + +ESPHOME_LOG_TAG(TAG, "esp32_power_management"); + +void ESP32PowerManagement::setup() { + if (this->max_freq_mhz_ == 0) + this->max_freq_mhz_ = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ; + if (this->min_freq_mhz_ == 0) + this->min_freq_mhz_ = esp_clk_xtal_freq() / MHZ; + // A low boot frequency (ESP32-H2 at 16 MHz) can sit below the crystal frequency. + if (this->min_freq_mhz_ > this->max_freq_mhz_) + this->min_freq_mhz_ = this->max_freq_mhz_; + + esp_pm_config_t pm_config = { + .max_freq_mhz = this->max_freq_mhz_, + .min_freq_mhz = this->min_freq_mhz_, +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + .light_sleep_enable = true, +#endif + }; + esp_err_t rc = esp_pm_configure(&pm_config); + if (rc != ESP_OK) { + // The usual cause is a minimum frequency that does not divide this board's crystal + ESP_LOGE(TAG, "esp_pm_configure failed: %s (crystal %u MHz)", esp_err_to_name(rc), + static_cast(esp_clk_xtal_freq() / MHZ)); + this->mark_failed(); + } +} + +void ESP32PowerManagement::dump_config() { + ESP_LOGCONFIG(TAG, + "Power Management:\n" + " Max Frequency: %uMHZ\n" + " Min Frequency: %uMHZ", + this->max_freq_mhz_, this->min_freq_mhz_); +#if SOC_CLK_XTAL32K_SUPPORTED + ESP_LOGCONFIG(TAG, " 32k XTAL: %s", + rtc_clk_slow_src_get() == SOC_RTC_SLOW_CLK_SRC_XTAL32K ? LOG_STR_LITERAL("in use") + : LOG_STR_LITERAL("not in use")); +#endif +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + ESP_LOGCONFIG(TAG, " Light Sleep Enabled"); +#if CONFIG_ESP_SLEEP_POWER_DOWN_FLASH + ESP_LOGCONFIG(TAG, " PM Flash Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP + ESP_LOGCONFIG(TAG, " PM Peripheral Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_IEEE802154_SLEEP_ENABLE + ESP_LOGCONFIG(TAG, " ieee802154 Sleep Enabled"); +#endif +#if CONFIG_PM_PROFILING + ESP_LOGCONFIG(TAG, " PM Profiling Enabled"); +#endif +#if CONFIG_PM_TRACE + ESP_LOGCONFIG(TAG, " PM Trace Enabled"); +#endif +#if defined(CONFIG_LWIP_IPV6) && !defined(CONFIG_LWIP_ND6) + ESP_LOGCONFIG(TAG, " IPv6 Neighbor Discovery Disabled (CONFIG_LWIP_ND6)"); +#endif +#if CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION + ESP_LOGCONFIG(TAG, " USB Serial/JTAG Light Sleep Prevention Enabled"); +#endif +#endif +} + +} // namespace esphome::esp32_power_management +#endif diff --git a/esphome/components/esp32_power_management/esp32_power_management.h b/esphome/components/esp32_power_management/esp32_power_management.h new file mode 100644 index 00000000000..e6194e76813 --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.h @@ -0,0 +1,28 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/power_management/power_management.h" +// Lets a YAML lambda call esp_pm_dump_locks(). +#include "esp_pm.h" + +namespace esphome::esp32_power_management { + +class ESP32PowerManagement : public power_management::PowerManagementComponent { + public: + float get_setup_priority() const override { return setup_priority::POWER; } + void setup() override; + void dump_config() override; + void set_max_freq_mhz(uint16_t max_freq_mhz) { this->max_freq_mhz_ = max_freq_mhz; } + void set_min_freq_mhz(uint16_t min_freq_mhz) { this->min_freq_mhz_ = min_freq_mhz; } + + protected: + // Zero means the build default; setup() replaces it with the value applied. + uint16_t max_freq_mhz_{0}; + uint16_t min_freq_mhz_{0}; +}; + +} // namespace esphome::esp32_power_management + +#endif diff --git a/esphome/components/esp32_power_management/power_management.py b/esphome/components/esp32_power_management/power_management.py new file mode 100644 index 00000000000..e9b6d6462cd --- /dev/null +++ b/esphome/components/esp32_power_management/power_management.py @@ -0,0 +1,254 @@ +import logging + +import esphome.codegen as cg +from esphome.components import esp32, power_management +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, + get_esp32_variant, +) +from esphome.components.openthread.const import CONF_POLL_PERIOD +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN +from esphome.components.zigbee import DOMAIN as ZIGBEE_DOMAIN +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_OPENTHREAD, CONF_PLATFORM +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@rwrozelle"] +DEPENDENCIES = ["esp32"] + +CONF_ENABLE_LIGHT_SLEEP = "enable_light_sleep" +CONF_IDLE_TIME_BEFORE_SLEEP = "idle_time_before_sleep" +CONF_MAX_FREQUENCY = "max_frequency" +CONF_MIN_FREQUENCY = "min_frequency" +CONF_POWER_DOWN_FLASH = "power_down_flash" +CONF_POWER_DOWN_PERIPHERALS = "power_down_peripherals" +CONF_PROFILING = "profiling" +CONF_TRACE = "trace" + +# Variants with a TOP power domain, which can power down peripherals in light sleep. +_TOP_PD_VARIANTS = [ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S31, +] + +# Crystal frequencies each variant can be built for (ESP-IDF's SOC_XTAL_FREQ_* values). +# Below the PLL frequencies the CPU runs from the crystal through an integer divider. +_XTAL_MHZ = { + VARIANT_ESP32: (24, 26, 40), + VARIANT_ESP32C2: (26, 32, 40), + VARIANT_ESP32C3: (32, 40), + VARIANT_ESP32C5: (40, 48), + VARIANT_ESP32C6: (40,), + VARIANT_ESP32C61: (40,), + VARIANT_ESP32H2: (32,), + VARIANT_ESP32H21: (32,), + VARIANT_ESP32H4: (32,), + VARIANT_ESP32P4: (40,), + VARIANT_ESP32S2: (40,), + VARIANT_ESP32S3: (32, 40), + VARIANT_ESP32S31: (40,), +} +# esp_pm_configure() keeps the 1 MHz reference clock reachable: its divider must be +# at least 10 on the ESP32 and 2 on every other variant. +_MIN_FREQUENCY_FLOOR_MHZ = {VARIANT_ESP32: 10} + +esp32_power_management_ns = cg.esphome_ns.namespace("esp32_power_management") +PowerManagement = esp32_power_management_ns.class_( + "ESP32PowerManagement", power_management.PowerManagementComponent +) + + +def _mhz(value: object) -> int: + """Validate a frequency and keep it as whole megahertz.""" + return int(cv.frequency(value)) // 1_000_000 + + +def valid_min_frequencies(variant: str) -> list[int]: + """The minimum CPU frequencies esp_pm_configure() accepts on a variant, in MHz. + + A value must divide one of the variant's crystal frequencies, stay above the + reference clock floor, or be one of the PLL frequencies. + """ + floor = _MIN_FREQUENCY_FLOOR_MHZ.get(variant, 2) + mhz = { + xtal // divider + for xtal in _XTAL_MHZ[variant] + for divider in range(1, xtal // floor + 1) + if xtal % divider == 0 + } + mhz.update(int(freq[:-3]) for freq in esp32.CPU_FREQUENCIES[variant]) + return sorted(mhz) + + +def _validate(config: ConfigType) -> ConfigType: + variant = get_esp32_variant() + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + valid = esp32.CPU_FREQUENCIES[variant] + if f"{max_mhz}MHZ" not in valid: + raise cv.Invalid( + f"{max_mhz}MHZ is not a valid CPU frequency for {variant}. " + f"Valid options are: {', '.join(valid)}", + path=[CONF_MAX_FREQUENCY], + ) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and ( + min_mhz not in (valid_min := valid_min_frequencies(variant)) + ): + raise cv.Invalid( + f"{min_mhz}MHZ is not a valid minimum frequency for {variant}; it must " + f"divide the crystal frequency. Valid options are: " + f"{', '.join(f'{mhz}MHZ' for mhz in valid_min)}", + path=[CONF_MIN_FREQUENCY], + ) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + for key in (CONF_POWER_DOWN_PERIPHERALS, CONF_POWER_DOWN_FLASH): + if config.get(key): + raise cv.Invalid( + f"{key} requires {CONF_ENABLE_LIGHT_SLEEP}: true", path=[key] + ) + config[CONF_POWER_DOWN_PERIPHERALS] = False + elif CONF_POWER_DOWN_PERIPHERALS not in config: + config[CONF_POWER_DOWN_PERIPHERALS] = variant in _TOP_PD_VARIANTS + elif config[CONF_POWER_DOWN_PERIPHERALS] and variant not in _TOP_PD_VARIANTS: + raise cv.Invalid( + f"{CONF_POWER_DOWN_PERIPHERALS} is only available on " + f"{', '.join(_TOP_PD_VARIANTS)}", + path=[CONF_POWER_DOWN_PERIPHERALS], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PowerManagement), + cv.Optional(CONF_MAX_FREQUENCY): _mhz, + cv.Optional(CONF_MIN_FREQUENCY): _mhz, + cv.Optional(CONF_ENABLE_LIGHT_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_IDLE_TIME_BEFORE_SLEEP, default=3): cv.int_range( + min=2, max=4294967295 + ), + cv.Optional(CONF_POWER_DOWN_PERIPHERALS): cv.boolean, + cv.Optional(CONF_POWER_DOWN_FLASH, default=False): cv.boolean, + cv.Optional(CONF_PROFILING, default=False): cv.boolean, + # Repurposes GPIOs as trace outputs, so keep it out of the visual editor. + cv.Optional( + CONF_TRACE, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await power_management.register_power_management(var, config) + add_idf_sdkconfig_option("CONFIG_PM_ENABLE", True) + + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + cg.add(var.set_max_freq_mhz(max_mhz)) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None: + cg.add(var.set_min_freq_mhz(min_mhz)) + if config[CONF_PROFILING]: + add_idf_sdkconfig_option("CONFIG_PM_PROFILING", True) + if config[CONF_TRACE]: + add_idf_sdkconfig_option("CONFIG_PM_TRACE", True) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + return + + # Automatic light sleep whenever no task is runnable. + add_idf_sdkconfig_option("CONFIG_FREERTOS_USE_TICKLESS_IDLE", True) + add_idf_sdkconfig_option("CONFIG_ESP_PHY_MAC_BB_PD", True) + # Kconfig's own "depends on" no-ops this where it doesn't apply. + add_idf_sdkconfig_option("CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION", True) + add_idf_sdkconfig_option( + "CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP", config[CONF_IDLE_TIME_BEFORE_SLEEP] + ) + if config[CONF_POWER_DOWN_PERIPHERALS]: + add_idf_sdkconfig_option("CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP", True) + if config[CONF_POWER_DOWN_FLASH]: + add_idf_sdkconfig_option("CONFIG_ESP_SLEEP_POWER_DOWN_FLASH", True) + + ot_conf = CORE.config.get(CONF_OPENTHREAD) + # A polling (sleepy) Thread end device cannot answer neighbour solicitations. + if ( + ot_conf is not None + and (poll_period := ot_conf.get(CONF_POLL_PERIOD)) is not None + and poll_period.total_milliseconds > 0 + ): + add_idf_sdkconfig_option("CONFIG_LWIP_ND6", False) + if ot_conf is not None or ZIGBEE_DOMAIN in CORE.config: + add_idf_sdkconfig_option("CONFIG_IEEE802154_SLEEP_ENABLE", True) + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + entries = full_config.get(power_management.DOMAIN, []) + if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in entries) > 1: + raise cv.Invalid(f"Only one {DOMAIN} instance is allowed") + boot_freq = full_config[esp32.DOMAIN][esp32.CONF_CPU_FREQUENCY] + boot_mhz = int(boot_freq[:-3]) + max_mhz = config.get(CONF_MAX_FREQUENCY) + # Without max_frequency the boot frequency is the ceiling esp_pm_configure() gets. + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and min_mhz > ( + max_mhz if max_mhz is not None else boot_mhz + ): + raise cv.Invalid( + f"{CONF_MIN_FREQUENCY} must not be greater than the maximum frequency", + path=[CONF_MIN_FREQUENCY], + ) + if max_mhz is not None: + if boot_mhz > max_mhz: + _LOGGER.info( + "esp32.%s (%s) is higher than %s (%dMHZ); the CPU will be " + "downclocked to %dMHZ as soon as power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + elif boot_mhz < max_mhz: + _LOGGER.warning( + "esp32.%s (%s) is lower than %s (%dMHZ); the CPU may run as " + "high as %dMHZ under load once power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + if config[CONF_POWER_DOWN_FLASH] and PSRAM_DOMAIN in full_config: + raise cv.Invalid( + f"{CONF_POWER_DOWN_FLASH} is not allowed when the device has PSRAM", + path=[CONF_POWER_DOWN_FLASH], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/power_management/__init__.py b/esphome/components/power_management/__init__.py new file mode 100644 index 00000000000..5afbb648be0 --- /dev/null +++ b/esphome/components/power_management/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +IS_PLATFORM_COMPONENT = True +CODEOWNERS = ["@rwrozelle"] +DOMAIN = "power_management" + +power_management_ns = cg.esphome_ns.namespace("power_management") +PowerManagementComponent = power_management_ns.class_( + "PowerManagementComponent", cg.Component +) + + +async def register_power_management(var: MockObj, config: ConfigType) -> None: + await cg.register_component(var, config) diff --git a/esphome/components/power_management/power_management.h b/esphome/components/power_management/power_management.h new file mode 100644 index 00000000000..a0c026162a7 --- /dev/null +++ b/esphome/components/power_management/power_management.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::power_management { + +class PowerManagementComponent : public Component {}; + +} // namespace esphome::power_management diff --git a/tests/component_tests/esp32_power_management/__init__.py b/tests/component_tests/esp32_power_management/__init__.py new file mode 100644 index 00000000000..a543aec3001 --- /dev/null +++ b/tests/component_tests/esp32_power_management/__init__.py @@ -0,0 +1 @@ +"""Tests for the esp32_power_management component.""" diff --git a/tests/component_tests/esp32_power_management/test_init.py b/tests/component_tests/esp32_power_management/test_init.py new file mode 100644 index 00000000000..32665060b77 --- /dev/null +++ b/tests/component_tests/esp32_power_management/test_init.py @@ -0,0 +1,361 @@ +"""Tests for esp32_power_management configuration validation and sdkconfig wiring.""" + +from contextlib import nullcontext +import logging +from unittest import mock + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +) +from esphome.components.esp32_power_management.power_management import ( + CONF_ENABLE_LIGHT_SLEEP, + CONF_IDLE_TIME_BEFORE_SLEEP, + CONF_MAX_FREQUENCY, + CONF_MIN_FREQUENCY, + CONF_POWER_DOWN_FLASH, + CONF_POWER_DOWN_PERIPHERALS, + CONFIG_SCHEMA, + _final_validate, + to_code, + valid_min_frequencies, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_POLL_PERIOD +from esphome.const import CONF_OPENTHREAD, KEY_FRAMEWORK_VERSION, PlatformFramework +from esphome.core import CORE, TimePeriodMilliseconds +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +LIGHT_SLEEP = {CONF_ENABLE_LIGHT_SLEEP: True} + + +def _set_variant( + set_core_config: SetCoreConfigCallable, + variant: str, + /, + *, + full_config: ConfigType | None = None, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: variant}, + full_config=full_config, + ) + + +@pytest.mark.parametrize( + ("variant", "config", "expectation"), + [ + pytest.param( + VARIANT_ESP32, + {CONF_MAX_FREQUENCY: "96MHZ"}, + pytest.raises(cv.Invalid, match="not a valid CPU frequency"), + id="max_invalid_for_variant", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_peripherals_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_FLASH: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_flash_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="only available on"), + id="power_down_peripherals_unsupported_variant", + ), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + nullcontext(), + id="power_down_peripherals_top_pd_variant", + ), + # The ESP32 keeps its reference clock divider at 10 or more + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "8MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_esp32_floor", + ), + # 26 MHz crystal modules exist, so 13 MHz is reachable on an ESP32 + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "13MHZ"}, + nullcontext(), + id="min_frequency_esp32_26mhz_crystal", + ), + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "80MHZ"}, + nullcontext(), + id="min_frequency_pll", + ), + # The ESP32-H2 runs from a 32 MHz crystal, so 8 MHz divides it and 10 MHz does not + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "8MHZ"}, + nullcontext(), + id="min_frequency_h2_crystal_divider", + ), + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "10MHZ"}, + pytest.raises(cv.Invalid, match="must divide the crystal"), + id="min_frequency_h2_not_a_divider", + ), + pytest.param( + VARIANT_ESP32C6, + {CONF_MIN_FREQUENCY: "1MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_reference_clock_floor", + ), + ], +) +def test_schema_validation( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expectation, +) -> None: + _set_variant(set_core_config, variant) + with expectation: + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize( + ("variant", "expected"), + [ + pytest.param( + VARIANT_ESP32, [10, 12, 13, 20, 24, 26, 40, 80, 160, 240], id="esp32" + ), + pytest.param(VARIANT_ESP32H2, [2, 4, 8, 16, 32, 48, 64, 96], id="esp32h2"), + pytest.param( + VARIANT_ESP32C6, [2, 4, 5, 8, 10, 20, 40, 80, 120, 160], id="esp32c6" + ), + ], +) +def test_valid_min_frequencies(variant: str, expected: list[int]) -> None: + assert valid_min_frequencies(variant) == expected + + +def test_frequencies_are_kept_in_mhz(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32) + config = CONFIG_SCHEMA({CONF_MAX_FREQUENCY: "160MHZ", CONF_MIN_FREQUENCY: 40e6}) + assert config[CONF_MAX_FREQUENCY] == 160 + assert config[CONF_MIN_FREQUENCY] == 40 + assert config[CONF_IDLE_TIME_BEFORE_SLEEP] == 3 + + +@pytest.mark.parametrize( + ("variant", "config", "expected"), + [ + pytest.param(VARIANT_ESP32H2, LIGHT_SLEEP, True, id="top_pd_light_sleep"), + pytest.param(VARIANT_ESP32, LIGHT_SLEEP, False, id="no_top_pd_light_sleep"), + pytest.param(VARIANT_ESP32H2, {}, False, id="no_light_sleep"), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: False}, + False, + id="explicit_value_kept", + ), + ], +) +def test_power_down_peripherals_default( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expected: bool, +) -> None: + _set_variant(set_core_config, variant) + assert CONFIG_SCHEMA(config)[CONF_POWER_DOWN_PERIPHERALS] is expected + + +def _final_validate_config( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + full_config: ConfigType | None = None, + boot_frequency: str = "160MHZ", +) -> None: + """Validate config, then run _final_validate against the given full config.""" + full_config = {"esp32": {"cpu_frequency": boot_frequency}} | (full_config or {}) + _set_variant(set_core_config, VARIANT_ESP32, full_config=full_config) + _final_validate(CONFIG_SCHEMA(config)) + + +def test_rejects_multiple_esp32_power_management_instances( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="Only one esp32_power_management instance"): + _final_validate_config( + set_core_config, + {}, + {"power_management": [{"platform": "esp32_power_management"}] * 2}, + ) + + +@pytest.mark.parametrize( + ("max_frequency", "boot_frequency", "expected"), + [ + pytest.param("160MHZ", "240MHZ", "downclocked to 160MHZ", id="boot_higher"), + pytest.param("240MHZ", "80MHZ", "may run as high as 240MHZ", id="boot_lower"), + pytest.param("160MHZ", "160MHZ", "", id="equal"), + ], +) +def test_boot_frequency_log( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + max_frequency: str, + boot_frequency: str, + expected: str, +) -> None: + caplog.set_level(logging.INFO) + _final_validate_config( + set_core_config, + {CONF_MAX_FREQUENCY: max_frequency}, + boot_frequency=boot_frequency, + ) + assert expected in caplog.text + assert (caplog.text == "") is (expected == "") + + +@pytest.mark.parametrize( + ("config", "boot_frequency", "expectation"), + [ + pytest.param( + {CONF_MAX_FREQUENCY: "80MHZ", CONF_MIN_FREQUENCY: "160MHZ"}, + "240MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_max", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "160MHZ"}, + "80MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_boot_frequency", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "80MHZ"}, + "80MHZ", + nullcontext(), + id="min_equal_to_boot_frequency", + ), + ], +) +def test_min_frequency_against_effective_max( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + boot_frequency: str, + expectation, +) -> None: + with expectation: + _final_validate_config(set_core_config, config, boot_frequency=boot_frequency) + + +def test_rejects_power_down_flash_with_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="not allowed when the device has PSRAM"): + _final_validate_config( + set_core_config, + LIGHT_SLEEP | {CONF_POWER_DOWN_FLASH: True}, + {"psram": {"mode": "quad"}}, + ) + + +@pytest.fixture +def sdkconfig_calls() -> dict[str, object]: + """Run to_code() without codegen and collect the sdkconfig options it sets.""" + calls: dict[str, object] = {} + with ( + mock.patch( + "esphome.components.esp32_power_management.power_management.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.power_management." + "register_power_management", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.add_idf_sdkconfig_option", + side_effect=calls.__setitem__, + ), + ): + yield calls + + +SLEEPY_MTD = { + CONF_OPENTHREAD: { + CONF_DEVICE_TYPE: "MTD", + CONF_POLL_PERIOD: TimePeriodMilliseconds(milliseconds=1000), + } +} +NOT_SET = object() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("core_config", "light_sleep", "key", "expected"), + [ + pytest.param(SLEEPY_MTD, True, "CONFIG_LWIP_ND6", False, id="nd6_sleepy_mtd"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_LWIP_ND6", + NOT_SET, + id="nd6_ftd", + ), + pytest.param({}, True, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_no_openthread"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_openthread", + ), + pytest.param( + {"zigbee": {"id": "zigbee_id"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_zigbee", + ), + pytest.param( + {}, True, "CONFIG_IEEE802154_SLEEP_ENABLE", NOT_SET, id="radio_sleep_none" + ), + pytest.param( + SLEEPY_MTD, False, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_light_sleep_off" + ), + pytest.param( + SLEEPY_MTD, + False, + "CONFIG_IEEE802154_SLEEP_ENABLE", + NOT_SET, + id="radio_sleep_light_sleep_off", + ), + ], +) +async def test_to_code_sdkconfig( + set_core_config: SetCoreConfigCallable, + sdkconfig_calls: dict[str, object], + core_config: ConfigType, + light_sleep: bool, + key: str, + expected: object, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32H2) + CORE.config = core_config + await to_code(CONFIG_SCHEMA({CONF_ENABLE_LIGHT_SLEEP: light_sleep})) + assert sdkconfig_calls.get(key, NOT_SET) is expected diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml new file mode 100644 index 00000000000..74d93e6577f --- /dev/null +++ b/tests/components/esp32_power_management/common.yaml @@ -0,0 +1,11 @@ +power_management: + - platform: esp32_power_management + id: pm_id + max_frequency: ${esp32_power_management_max_frequency} + min_frequency: ${esp32_power_management_min_frequency} + enable_light_sleep: true + idle_time_before_sleep: 3 + power_down_flash: true + power_down_peripherals: ${esp32_power_management_power_down_peripherals} + profiling: true + trace: true diff --git a/tests/components/esp32_power_management/test.esp32-ard.yaml b/tests/components/esp32_power_management/test.esp32-ard.yaml new file mode 100644 index 00000000000..2b25b2d23f9 --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "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 new file mode 100644 index 00000000000..c571a2be0cd --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 96MHZ + esp32_power_management_min_frequency: 32MHZ + esp32_power_management_power_down_peripherals: "true" + +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 new file mode 100644 index 00000000000..2b25b2d23f9 --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + +packages: + esp32_power_management: !include common.yaml From 2cd7c0eb36e98188894be0a48c400668f9ab31d1 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 21:31:16 +0000 Subject: [PATCH 395/435] [husb238] Add HUSB238 USB PD controller hub with binary sensors (#16572) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/husb238/__init__.py | 33 ++++++++++++ esphome/components/husb238/binary_sensor.py | 38 ++++++++++++++ esphome/components/husb238/husb238.cpp | 51 +++++++++++++++++++ esphome/components/husb238/husb238.h | 28 ++++++++++ tests/components/husb238/common.yaml | 11 ++++ tests/components/husb238/test.esp32-idf.yaml | 3 ++ .../components/husb238/test.esp8266-ard.yaml | 3 ++ 8 files changed, 168 insertions(+) create mode 100644 esphome/components/husb238/__init__.py create mode 100644 esphome/components/husb238/binary_sensor.py create mode 100644 esphome/components/husb238/husb238.cpp create mode 100644 esphome/components/husb238/husb238.h create mode 100644 tests/components/husb238/common.yaml create mode 100644 tests/components/husb238/test.esp32-idf.yaml create mode 100644 tests/components/husb238/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b45816640ed..168c5430806 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -266,6 +266,7 @@ esphome/components/http_request/ota/* @oarcher esphome/components/http_request/update/* @jesserockz esphome/components/htu31d/* @betterengineering esphome/components/hub75/* @stuartparmenter +esphome/components/husb238/* @latonita esphome/components/hydreon_rgxx/* @functionpointer esphome/components/hyt271/* @Philippe12 esphome/components/i2c/* @esphome/core diff --git a/esphome/components/husb238/__init__.py b/esphome/components/husb238/__init__.py new file mode 100644 index 00000000000..92bf8c998ff --- /dev/null +++ b/esphome/components/husb238/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "husb238" +MULTI_CONF = True + +CONF_HUSB238_ID = "husb238_id" + +husb238_ns = cg.esphome_ns.namespace("husb238") +Husb238Component = husb238_ns.class_( + "Husb238Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Husb238Component), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(0x08)) +) + + +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) diff --git a/esphome/components/husb238/binary_sensor.py b/esphome/components/husb238/binary_sensor.py new file mode 100644 index 00000000000..78d5d78a9d6 --- /dev/null +++ b/esphome/components/husb238/binary_sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, DEVICE_CLASS_PLUG, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_HUSB238_ID, Husb238Component + +DEPENDENCIES = ["husb238"] + +CONF_ATTACHED = "attached" +CONF_CC_DIRECTION = "cc_direction" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HUSB238_ID): cv.use_id(Husb238Component), + cv.Optional(CONF_ATTACHED): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PLUG, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + key=CONF_NAME, + ), + cv.Optional(CONF_CC_DIRECTION): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HUSB238_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_ATTACHED, hub.set_attached_binary_sensor) + await sensors(CONF_CC_DIRECTION, hub.set_cc_direction_binary_sensor) diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp new file mode 100644 index 00000000000..8ca841a7e43 --- /dev/null +++ b/esphome/components/husb238/husb238.cpp @@ -0,0 +1,51 @@ +#include "husb238.h" +#include "esphome/core/log.h" + +namespace esphome::husb238 { + +ESPHOME_LOG_TAG(TAG, "husb238"); + +void Husb238Component::setup() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void Husb238Component::update() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + +#ifdef USE_BINARY_SENSOR + if (this->attached_binary_sensor_ != nullptr) { + this->attached_binary_sensor_->publish_state((status & PD_STATUS1_ATTACHED) != 0); + } + if (this->cc_direction_binary_sensor_ != nullptr) { + // The CC line only means something while a source is attached + if (status & PD_STATUS1_ATTACHED) { + this->cc_direction_binary_sensor_->publish_state((status & PD_STATUS1_CC_DIR) != 0); + } else { + this->cc_direction_binary_sensor_->invalidate_state(); + } + } +#endif +} + +void Husb238Component::dump_config() { + ESP_LOGCONFIG(TAG, "HUSB238:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "PD Attached", this->attached_binary_sensor_); + LOG_BINARY_SENSOR(" ", "CC Direction", this->cc_direction_binary_sensor_); +#endif +} + +} // namespace esphome::husb238 diff --git a/esphome/components/husb238/husb238.h b/esphome/components/husb238/husb238.h new file mode 100644 index 00000000000..5a3513db026 --- /dev/null +++ b/esphome/components/husb238/husb238.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/i2c/i2c.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::husb238 { + +static constexpr uint8_t REG_PD_STATUS1 = 0x01; +static constexpr uint8_t PD_STATUS1_ATTACHED = 1 << 6; +static constexpr uint8_t PD_STATUS1_CC_DIR = 1 << 7; + +class Husb238Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(attached) + SUB_BINARY_SENSOR(cc_direction) +#endif +}; + +} // namespace esphome::husb238 diff --git a/tests/components/husb238/common.yaml b/tests/components/husb238/common.yaml new file mode 100644 index 00000000000..94e055acf85 --- /dev/null +++ b/tests/components/husb238/common.yaml @@ -0,0 +1,11 @@ +husb238: + id: husb238_hub + i2c_id: i2c_bus + +binary_sensor: + - platform: husb238 + husb238_id: husb238_hub + attached: "PD Attached" + cc_direction: + name: "CC Direction" + entity_category: diagnostic diff --git a/tests/components/husb238/test.esp32-idf.yaml b/tests/components/husb238/test.esp32-idf.yaml new file mode 100644 index 00000000000..a2286c6e515 --- /dev/null +++ b/tests/components/husb238/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + husb238: !include common.yaml diff --git a/tests/components/husb238/test.esp8266-ard.yaml b/tests/components/husb238/test.esp8266-ard.yaml new file mode 100644 index 00000000000..103b71b6628 --- /dev/null +++ b/tests/components/husb238/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + husb238: !include common.yaml From 5a819f4b4807c4468f5c7557c56a52a00c8915a5 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 21:34:04 +0000 Subject: [PATCH 396/435] [bh1745] Add BH1745 colour sensor (#6349) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bh1745/__init__.py | 75 ++++++++ esphome/components/bh1745/bh1745.cpp | 182 ++++++++++++++++++ esphome/components/bh1745/bh1745.h | 61 ++++++ esphome/components/bh1745/output/__init__.py | 28 +++ .../components/bh1745/output/bh1745_output.h | 16 ++ esphome/components/bh1745/sensor.py | 66 +++++++ tests/components/bh1745/common.yaml | 27 +++ tests/components/bh1745/test.esp32-idf.yaml | 3 + tests/components/bh1745/test.esp8266-ard.yaml | 3 + 10 files changed, 462 insertions(+) create mode 100644 esphome/components/bh1745/__init__.py create mode 100644 esphome/components/bh1745/bh1745.cpp create mode 100644 esphome/components/bh1745/bh1745.h create mode 100644 esphome/components/bh1745/output/__init__.py create mode 100644 esphome/components/bh1745/output/bh1745_output.h create mode 100644 esphome/components/bh1745/sensor.py create mode 100644 tests/components/bh1745/common.yaml create mode 100644 tests/components/bh1745/test.esp32-idf.yaml create mode 100644 tests/components/bh1745/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 168c5430806..2d3f8af71d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,6 +65,7 @@ esphome/components/bedjet/climate/* @jhansche esphome/components/bedjet/fan/* @jhansche esphome/components/bedjet/sensor/* @javawizard @jhansche esphome/components/beken_spi_led_strip/* @Mat931 +esphome/components/bh1745/* @latonita esphome/components/bh1750/* @OttoWinter esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py new file mode 100644 index 00000000000..bd63fd4ef2d --- /dev/null +++ b/esphome/components/bh1745/__init__.py @@ -0,0 +1,75 @@ +from typing import Any + +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_INTEGRATION_TIME, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bh1745" +MULTI_CONF = True + +CONF_BH1745_ID = "bh1745_id" + +bh1745_ns = cg.esphome_ns.namespace("bh1745") + +BH1745Component = bh1745_ns.class_( + "BH1745Component", cg.PollingComponent, i2c.I2CDevice +) + +AdcGain = bh1745_ns.enum("AdcGain", is_class=True) +ADC_GAINS = { + "1X": AdcGain.ADC_GAIN_1X, + "2X": AdcGain.ADC_GAIN_2X, + "16X": AdcGain.ADC_GAIN_16X, +} + +MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True) +MEASUREMENT_TIMES = { + 160: MeasurementTime.MEASUREMENT_TIME_160MS, + 320: MeasurementTime.MEASUREMENT_TIME_320MS, + 640: MeasurementTime.MEASUREMENT_TIME_640MS, + 1280: MeasurementTime.MEASUREMENT_TIME_1280MS, + 2560: MeasurementTime.MEASUREMENT_TIME_2560MS, + 5120: MeasurementTime.MEASUREMENT_TIME_5120MS, +} + + +def validate_measurement_time(value: Any) -> Any: + value = cv.positive_time_period_milliseconds(value).total_milliseconds + return cv.enum(MEASUREMENT_TIMES, int=True)(value) + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BH1745Component), + cv.Optional(CONF_GAIN, default="1X"): cv.enum(ADC_GAINS, upper=True), + cv.Optional( + CONF_INTEGRATION_TIME, default="160ms" + ): validate_measurement_time, + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range( + min=1.0 + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x38)) +) + + +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_adc_gain(config[CONF_GAIN])) + cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME])) + cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp new file mode 100644 index 00000000000..24744c3330d --- /dev/null +++ b/esphome/components/bh1745/bh1745.cpp @@ -0,0 +1,182 @@ +#include "bh1745.h" +#include +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bh1745 { + +ESPHOME_LOG_TAG(TAG, "bh1745"); + +static constexpr uint8_t REG_SYSTEM_CONTROL = 0x40; +static constexpr uint8_t REG_MODE_CONTROL1 = 0x41; +static constexpr uint8_t REG_MODE_CONTROL2 = 0x42; +static constexpr uint8_t REG_MODE_CONTROL3 = 0x44; +static constexpr uint8_t REG_RED_DATA_LSB = 0x50; +static constexpr uint8_t REG_INTERRUPT = 0x60; +static constexpr uint8_t REG_TH_LSB = 0x62; +static constexpr uint8_t REG_MANUFACTURER_ID = 0x92; + +static constexpr uint8_t MANUFACTURER_ID = 0xE0; +static constexpr uint8_t PART_ID = 0b001011; +static constexpr uint8_t SYSTEM_CONTROL_PART_ID_MASK = 0x3F; +static constexpr uint8_t SYSTEM_CONTROL_SW_RESET = 0x80; +static constexpr uint8_t MODE_CONTROL2_RGBC_EN = 0x10; +static constexpr uint8_t MODE_CONTROL2_VALID = 0x80; +static constexpr uint8_t MODE_CONTROL3_VALUE = 0x02; // the datasheet says always write 0x02 +static constexpr uint8_t INTERRUPT_ENABLE = 0x01; + +static constexpr uint8_t BH1745_CHANNELS = 4; +static constexpr uint32_t RESET_DELAY_MS = 100; +static constexpr uint32_t BASE_MEAS_TIME_MS = 160; + +static uint32_t get_measurement_time_ms(MeasurementTime time) { + return BASE_MEAS_TIME_MS << static_cast(time); +} + +static uint8_t get_adc_gain(AdcGain gain) { + return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast(gain); +} + +static float calculate_lux(uint16_t red, uint16_t green, bool low_clear_ratio, float scale) { + if (green == 0) + return 0.0f; + float lx = low_clear_ratio ? 0.202f * red + 0.766f * green : 0.159f * red + 0.646f * green; + return lx * scale; +} + +static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low_clear_ratio) { + if (green == 0) + return 0.0f; + float all = static_cast(static_cast(red) + green + blue); + float r_ratio = red / all; + float b_ratio = blue / all; + float ct; + if (low_clear_ratio) { + float b_eff = std::fmin(b_ratio * 3.13f, 1.0f); + ct = ((1.0f - b_eff) * 12746.0f * std::exp(-2.911f * r_ratio)) + (b_eff * 1637.0f * std::exp(4.865f * b_ratio)); + } else { + float b_eff = std::fmin(b_ratio * 10.67f, 1.0f); + ct = ((1.0f - b_eff) * 16234.0f * std::exp(-2.781f * r_ratio)) + (b_eff * 1882.0f * std::exp(4.448f * b_ratio)); + } + return clamp_at_most(ct, 10000.0f); +} + +void BH1745Component::setup() { + uint8_t manufacturer_id; + if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (manufacturer_id != MANUFACTURER_ID) { + this->mark_failed(LOG_STR("Wrong manufacturer ID")); + return; + } + uint8_t system_control; + if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) || + (system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) { + this->mark_failed(LOG_STR("Wrong part ID")); + return; + } + if (!this->write_byte(REG_SYSTEM_CONTROL, SYSTEM_CONTROL_SW_RESET)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Light lost behind glass is scaled back up, as the other light sensors do + this->lux_scale_ = 160.0f * this->glass_attenuation_factor_ / + (get_adc_gain(this->adc_gain_) * get_measurement_time_ms(this->measurement_time_)); + + this->set_timeout(RESET_DELAY_MS, [this]() { + if (this->interrupt_output_) { + // High threshold 0 and low threshold 0xFFFF (low byte first) make the interrupt condition always + // true, so the INT pin simply follows the enable bit, which drives the breakout's LEDs. + static constexpr uint8_t THRESHOLDS[4] = {0x00, 0x00, 0xFF, 0xFF}; + if (!this->write_bytes(REG_TH_LSB, THRESHOLDS, sizeof(THRESHOLDS))) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + } + // RGBC_EN makes the chip measure continuously; update() reads the latest result. + if (!this->write_byte(REG_MODE_CONTROL1, static_cast(this->measurement_time_)) || + !this->write_byte(REG_MODE_CONTROL2, static_cast(this->adc_gain_) | MODE_CONTROL2_RGBC_EN) || + !this->write_byte(REG_MODE_CONTROL3, MODE_CONTROL3_VALUE)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->busy_ = false; + }); +} + +void BH1745Component::dump_config() { + ESP_LOGCONFIG(TAG, + "BH1745:\n" + " Gain: %ux\n" + " Integration time: %" PRIu32 " ms\n" + " Glass attenuation factor: %.2f", + get_adc_gain(this->adc_gain_), get_measurement_time_ms(this->measurement_time_), + this->glass_attenuation_factor_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + LOG_SENSOR(" ", "Red Counts", this->red_counts_sensor_); + LOG_SENSOR(" ", "Green Counts", this->green_counts_sensor_); + LOG_SENSOR(" ", "Blue Counts", this->blue_counts_sensor_); + LOG_SENSOR(" ", "Clear Counts", this->clear_counts_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Color Temperature", this->color_temperature_sensor_); +} + +void BH1745Component::update() { + if (this->busy_) + return; + + // Reading MODE_CONTROL2 clears VALID, so read it only once per poll. + uint8_t mode_control2; + if (!this->read_byte(REG_MODE_CONTROL2, &mode_control2)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((mode_control2 & MODE_CONTROL2_VALID) == 0) + return; + + uint8_t buffer[BH1745_CHANNELS * 2]; + if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + // Each channel is sent low byte first. + const uint16_t red = encode_uint16(buffer[1], buffer[0]); + const uint16_t green = encode_uint16(buffer[3], buffer[2]); + const uint16_t blue = encode_uint16(buffer[5], buffer[4]); + const uint16_t clear = encode_uint16(buffer[7], buffer[6]); + ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear); + + if (this->red_counts_sensor_ != nullptr) + this->red_counts_sensor_->publish_state(red); + if (this->green_counts_sensor_ != nullptr) + this->green_counts_sensor_->publish_state(green); + if (this->blue_counts_sensor_ != nullptr) + this->blue_counts_sensor_->publish_state(blue); + if (this->clear_counts_sensor_ != nullptr) + this->clear_counts_sensor_->publish_state(clear); + + // The datasheet picks its coefficients by whether clear / green < 0.160 (= 4 / 25). + const bool low_clear_ratio = static_cast(clear) * 25 < static_cast(green) * 4; + if (this->illuminance_sensor_ != nullptr) + this->illuminance_sensor_->publish_state(calculate_lux(red, green, low_clear_ratio, this->lux_scale_)); + if (this->color_temperature_sensor_ != nullptr) + this->color_temperature_sensor_->publish_state(calculate_cct(red, green, blue, low_clear_ratio)); +} + +void BH1745Component::set_interrupt_state(bool on) { + if (this->is_failed()) + return; + if (!this->write_byte(REG_INTERRUPT, on ? INTERRUPT_ENABLE : 0x00)) { + this->status_set_warning(); + } +} + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h new file mode 100644 index 00000000000..06d0987d67d --- /dev/null +++ b/esphome/components/bh1745/bh1745.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bh1745 { + +enum class MeasurementTime : uint8_t { + MEASUREMENT_TIME_160MS = 0b000, + MEASUREMENT_TIME_320MS = 0b001, + MEASUREMENT_TIME_640MS = 0b010, + MEASUREMENT_TIME_1280MS = 0b011, + MEASUREMENT_TIME_2560MS = 0b100, + MEASUREMENT_TIME_5120MS = 0b101, +}; + +enum class AdcGain : uint8_t { + ADC_GAIN_1X = 0, + ADC_GAIN_2X = 1, + ADC_GAIN_16X = 2, +}; + +class BH1745Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_measurement_time(MeasurementTime measurement_time) { this->measurement_time_ = measurement_time; } + 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}; + AdcGain adc_gain_{AdcGain::ADC_GAIN_1X}; + bool busy_{true}; // cleared once the chip is configured after the reset + bool interrupt_output_{false}; +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/output/__init__.py b/esphome/components/bh1745/output/__init__.py new file mode 100644 index 00000000000..6c5da0396bd --- /dev/null +++ b/esphome/components/bh1745/output/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns + +DEPENDENCIES = ["bh1745"] + +BH1745InterruptPinOutput = bh1745_ns.class_( + "BH1745InterruptPinOutput", output.BinaryOutput +) + +CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput), + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + hub = await cg.get_variable(config[CONF_BH1745_ID]) + await cg.register_parented(var, hub) + cg.add(hub.enable_interrupt_output()) diff --git a/esphome/components/bh1745/output/bh1745_output.h b/esphome/components/bh1745/output/bh1745_output.h new file mode 100644 index 00000000000..c6f30304bd0 --- /dev/null +++ b/esphome/components/bh1745/output/bh1745_output.h @@ -0,0 +1,16 @@ +#pragma once + +#include "esphome/components/output/binary_output.h" +#include "esphome/core/helpers.h" + +#include "../bh1745.h" + +namespace esphome::bh1745 { + +/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one. +class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented { + protected: + void write_state(bool state) override { this->parent_->set_interrupt_state(state); } +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/sensor.py b/esphome/components/bh1745/sensor.py new file mode 100644 index 00000000000..6c904466250 --- /dev/null +++ b/esphome/components/bh1745/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, + UNIT_COUNTS, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + ICON_LIGHTBULB, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, +) +from esphome.types import ConfigType + +from . import CONF_BH1745_ID, BH1745Component + +DEPENDENCIES = ["bh1745"] + +color_channel_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_LIGHTBULB, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + cv.Optional(CONF_RED_CHANNEL): color_channel_schema, + cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema, + cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema, + cv.Optional(CONF_CLEAR_CHANNEL): color_channel_schema, + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + icon=ICON_LIGHTBULB, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_THERMOMETER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BH1745_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, hub.set_red_counts_sensor) + await sensors(CONF_GREEN_CHANNEL, hub.set_green_counts_sensor) + await sensors(CONF_BLUE_CHANNEL, hub.set_blue_counts_sensor) + await sensors(CONF_CLEAR_CHANNEL, hub.set_clear_counts_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor) diff --git a/tests/components/bh1745/common.yaml b/tests/components/bh1745/common.yaml new file mode 100644 index 00000000000..bad906653a1 --- /dev/null +++ b/tests/components/bh1745/common.yaml @@ -0,0 +1,27 @@ +bh1745: + id: bh1745_hub + i2c_id: i2c_bus + gain: 16X + integration_time: 320ms + glass_attenuation_factor: 2.0 + +sensor: + - platform: bh1745 + bh1745_id: bh1745_hub + red_channel: + name: Red channel + green_channel: + name: Green channel + blue_channel: + name: Blue channel + clear_channel: + name: Clear channel + illuminance: + name: Illuminance + color_temperature: + name: Color temperature + +output: + - platform: bh1745 + id: bh1745_output + bh1745_id: bh1745_hub diff --git a/tests/components/bh1745/test.esp32-idf.yaml b/tests/components/bh1745/test.esp32-idf.yaml new file mode 100644 index 00000000000..f7aff8bedd9 --- /dev/null +++ b/tests/components/bh1745/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bh1745: !include common.yaml diff --git a/tests/components/bh1745/test.esp8266-ard.yaml b/tests/components/bh1745/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7132464c436 --- /dev/null +++ b/tests/components/bh1745/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bh1745: !include common.yaml From 93183c661ec4cbafa37a0eefdf059fb42fd3f155 Mon Sep 17 00:00:00 2001 From: gledian Date: Mon, 12 Oct 2026 23:36:27 +0200 Subject: [PATCH 397/435] [ecocomfort2] Add component for Fantini Cosmi VMC units via BLE (Part 1: hub + fan) (#17092) Co-authored-by: gledian Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ecocomfort2/__init__.py | 72 ++++++++ .../ecocomfort2/ecocomfort2_child.h | 20 +++ .../ecocomfort2/ecocomfort2_fan.cpp | 146 +++++++++++++++++ .../components/ecocomfort2/ecocomfort2_fan.h | 26 +++ .../ecocomfort2/ecocomfort2_hub.cpp | 154 ++++++++++++++++++ .../components/ecocomfort2/ecocomfort2_hub.h | 76 +++++++++ esphome/components/ecocomfort2/fan.py | 23 +++ esphome/core/defines.h | 1 + tests/components/ecocomfort2/common.yaml | 24 +++ .../ecocomfort2/test.esp32-idf.yaml | 3 + 11 files changed, 546 insertions(+) create mode 100644 esphome/components/ecocomfort2/__init__.py create mode 100644 esphome/components/ecocomfort2/ecocomfort2_child.h create mode 100644 esphome/components/ecocomfort2/ecocomfort2_fan.cpp create mode 100644 esphome/components/ecocomfort2/ecocomfort2_fan.h create mode 100644 esphome/components/ecocomfort2/ecocomfort2_hub.cpp create mode 100644 esphome/components/ecocomfort2/ecocomfort2_hub.h create mode 100644 esphome/components/ecocomfort2/fan.py create mode 100644 tests/components/ecocomfort2/common.yaml create mode 100644 tests/components/ecocomfort2/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2d3f8af71d0..9de83996659 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -160,6 +160,7 @@ esphome/components/ds248x/* @tomwellnitz esphome/components/ds3231/* @linkedupbits esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov +esphome/components/ecocomfort2/* @gledian esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz esphome/components/emc2101/* @ellull diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py new file mode 100644 index 00000000000..472ac8f7cf8 --- /dev/null +++ b/esphome/components/ecocomfort2/__init__.py @@ -0,0 +1,72 @@ +import esphome.codegen as cg +from esphome.components import ble_client, esp32_ble +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.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +DOMAIN = "ecocomfort2" +CONF_ECOCOMFORT2_ID = "ecocomfort2_id" + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ble_client"] +MULTI_CONF = True + +ecocomfort2_ns = cg.esphome_ns.namespace(DOMAIN) +Ecocomfort2Hub = ecocomfort2_ns.class_( + "Ecocomfort2Hub", ble_client.BLEClientNode, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + +ECOCOMFORT2_CLIENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ECOCOMFORT2_ID): cv.use_id(Ecocomfort2Hub), + } +) + +_request_child_slot = cg.slot_counter("ECOCOMFORT2_CHILD_COUNT") + + +async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + _request_child_slot(str(parent)) + cg.add(parent.register_child(var)) + + +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) + + +def _final_validate(config: ConfigType) -> ConfigType: + # The unit only talks over an encrypted, bonded link + ble_conf = fv.full_config.get()[esp32_ble.DOMAIN] + if ( + ble_conf[CONF_IO_CAPABILITY] != "none" + or ble_conf.get(CONF_AUTH_REQ_MODE) != "sc_bond" + or ble_conf.get(CONF_MAX_KEY_SIZE) != 16 + ): + raise cv.Invalid( + "ecocomfort2 needs esp32_ble configured with io_capability: none, " + "auth_req_mode: sc_bond and max_key_size: 16" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ecocomfort2/ecocomfort2_child.h b/esphome/components/ecocomfort2/ecocomfort2_child.h new file mode 100644 index 00000000000..b6d2fc87cb4 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_child.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Hub; + +class Ecocomfort2Client : public Parented { + public: + /// Called after each successful C_OPER readback. + virtual void on_status() = 0; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp new file mode 100644 index 00000000000..c9d64892c48 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -0,0 +1,146 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_fan.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2.fan"); + +static constexpr int SPEED_COUNT = SPEED_VEL3; + +struct Preset { + const char *name; + uint8_t mode; + bool auto_mode; +}; + +// Order matters: the fan core persists the preset as an index into this list. +static constexpr Preset PRESETS[] = { + {"In", OPER_IN, false}, + {"Out", OPER_OUT, false}, + {"In-Out", OPER_IN_OUT, false}, + {"Sensor", OPER_SENSOR_OR_AUTO, false}, + {"Auto", OPER_SENSOR_OR_AUTO, true}, +}; +static constexpr const Preset &PRESET_IN_OUT = PRESETS[2]; +static constexpr const Preset &PRESET_SENSOR = PRESETS[3]; + +static const Preset *find_preset(const char *name) { + for (const auto &preset : PRESETS) { + if (preset.name == name) + return &preset; + } + return nullptr; +} + +static const Preset *find_preset(uint8_t mode, bool auto_mode) { + for (const auto &preset : PRESETS) { + if (preset.mode == mode && preset.auto_mode == auto_mode) + return &preset; + } + return nullptr; +} + +void Ecocomfort2Fan::setup() { + this->set_supported_preset_modes( + {PRESETS[0].name, PRESETS[1].name, PRESETS[2].name, PRESETS[3].name, PRESETS[4].name}); + // Restore the last preset and speed so turning on resumes them; on/off comes from the device. + auto restore = this->restore_state_(); + if (restore.has_value()) { + this->speed = restore->speed; + if (restore->preset_mode < std::size(PRESETS)) + this->set_preset_mode_(PRESETS[restore->preset_mode].name); + } +} + +void Ecocomfort2Fan::dump_config() { LOG_FAN("", "Ecocomfort2 Fan", this); } + +fan::FanTraits Ecocomfort2Fan::get_traits() { + fan::FanTraits traits; + traits.set_speed(true); + traits.set_supported_speed_count(SPEED_COUNT); + this->wire_preset_modes_(traits); + return traits; +} + +void Ecocomfort2Fan::control(const fan::FanCall &call) { + if (!this->parent_->is_ready()) { + ESP_LOGW(TAG, "Not ready, cannot handle control call"); + return; + } + + // Any call that does not turn the fan off turns it on + auto state = call.get_state(); + this->state = !state.has_value() || *state; + // The core clears the preset in a speed call; here the preset is the air flow mode, so keep it. + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); + + const Preset *preset = find_preset(this->get_preset_mode().c_str()); + if (preset == nullptr) { + preset = &PRESET_IN_OUT; + this->set_preset_mode_(preset->name); + } + auto speed = call.get_speed(); + if (speed.has_value()) { + this->speed = *speed; + // A manual speed leaves automatic speed control + if (preset->auto_mode) { + preset = &PRESET_SENSOR; + this->set_preset_mode_(preset->name); + } + } + this->speed = clamp(this->speed, 1, SPEED_COUNT); + + if (!this->state) { + this->parent_->write_oper(OPER_OFF, SPEED_OFF); + } else { + this->parent_->write_oper(preset->mode, preset->auto_mode ? FLAG_AUTO : static_cast(this->speed)); + } + this->publish_state(); +} + +void Ecocomfort2Fan::on_status() { + uint8_t mode = this->parent_->get_mode(); + uint8_t raw_speed = this->parent_->get_speed(); + + // Off keeps the preset and speed so turning on resumes them + if (mode == OPER_OFF) { + if (!this->state) + return; + this->state = false; + this->publish_state(); + return; + } + + int new_speed = this->speed; + uint8_t base_speed = raw_speed & SPEED_MASK; + if (raw_speed & FLAG_BOOST) { + new_speed = SPEED_COUNT; + } else if (raw_speed & FLAG_NIGHT) { + new_speed = SPEED_SLEEP; + } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + new_speed = base_speed; + } + const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); + const char *new_preset = preset != nullptr ? preset->name : nullptr; + bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; + if (this->state && this->speed == new_speed && same_preset) + return; + + this->state = true; + this->speed = new_speed; + if (new_preset != nullptr) { + this->set_preset_mode_(new_preset); + } else { + this->clear_preset_mode_(); + } + this->publish_state(); +} + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.h b/esphome/components/ecocomfort2/ecocomfort2_fan.h new file mode 100644 index 00000000000..81af55699fe --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/fan/fan.h" +#include "ecocomfort2_child.h" +#include "ecocomfort2_hub.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Fan : public fan::Fan, public Ecocomfort2Client, public Component { + public: + void setup() override; + void dump_config() override; + fan::FanTraits get_traits() override; + void on_status() override; + + protected: + void control(const fan::FanCall &call) override; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp new file mode 100644 index 00000000000..a20fdd9112c --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -0,0 +1,154 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_hub.h" +#include "esphome/core/log.h" + +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::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); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status); + } +} + +void Ecocomfort2Hub::dump_config() { + ESP_LOGCONFIG(TAG, + "Ecocomfort2 Hub:\n" + " Address: %s", + this->parent_->address_str()); + LOG_UPDATE_INTERVAL(this); +} + +void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + ESP_LOGD(TAG, "[%s] Disconnected: reason=%d", this->parent_->address_str(), param->disconnect.reason); + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->cancel_timeout(READBACK_TIMEOUT_ID); + this->status_set_warning(); + this->char_handle_oper_ = 0; + 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()); + this->parent_->disconnect(); + break; + } + this->char_handle_oper_ = chr->handle; + this->node_state = espbt::ClientState::ESTABLISHED; + this->status_set_warning(); + + // A bonded device may already have re-encrypted the link before discovery finished. + if (this->parent_->is_paired()) { + this->on_paired_(); + break; + } + ESP_LOGI(TAG, "[%s] Requesting BLE encryption", this->parent_->address_str()); + if (this->parent_->pair() != ESP_OK) { + ESP_LOGW(TAG, "[%s] BLE pair request failed, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->set_timeout(AUTH_TIMEOUT_ID, AUTH_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "[%s] BLE authentication timed out, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + }); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) { + 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(); + 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: + break; + } +} + +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) { + return; + } + if (!param->ble_security.auth_cmpl.success) { + this->cancel_timeout(AUTH_TIMEOUT_ID); + ESP_LOGW(TAG, "[%s] BLE authentication failed, reason=%d, disconnecting", this->parent_->address_str(), + param->ble_security.auth_cmpl.fail_reason); + this->parent_->disconnect(); + return; + } + this->on_paired_(); +} + +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->update(); +} + +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()); + return; + } + uint8_t data[2]{mode, speed}; + ESP_LOGD(TAG, "[%s] Writing C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), mode, speed); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_oper_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + 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(); }); +} + +#ifdef ECOCOMFORT2_CHILD_COUNT +void Ecocomfort2Hub::register_child(Ecocomfort2Client *obj) { + this->children_.push_back(obj); + obj->set_parent(this); +} +#endif + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h new file mode 100644 index 00000000000..5a609d9283e --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "ecocomfort2_child.h" + +namespace esphome::ecocomfort2 { + +namespace espbt = esphome::esp32_ble_tracker; + +// C_OPER byte 0: operating mode +static constexpr uint8_t OPER_OFF = 0x00; +static constexpr uint8_t OPER_IN = 0x01; +static constexpr uint8_t OPER_OUT = 0x02; +static constexpr uint8_t OPER_IN_OUT = 0x03; +static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; + +// C_OPER byte 1: speed in the low nibble, flags in the high nibble +static constexpr uint8_t SPEED_OFF = 0x00; +static constexpr uint8_t SPEED_SLEEP = 0x01; +static constexpr uint8_t SPEED_VEL1 = 0x02; +static constexpr uint8_t SPEED_VEL2 = 0x03; +static constexpr uint8_t SPEED_VEL3 = 0x04; +static constexpr uint8_t SPEED_MASK = 0x0F; +static constexpr uint8_t FLAG_AUTO = 0x10; +static constexpr uint8_t FLAG_BOOST = 0x40; +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 update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BLUETOOTH; } + + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + +#ifdef ECOCOMFORT2_CHILD_COUNT + void register_child(Ecocomfort2Client *obj); +#endif + + /// True once the link is established and paired. + bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } + + /// Write C_OPER (mode byte, speed/flags byte) and read it back shortly after. + void write_oper(uint8_t mode, uint8_t speed); + + /// Raw C_OPER values from the last readback. + uint8_t get_mode() const { return this->mode_; } + uint8_t get_speed() const { return this->speed_; } + + protected: + static constexpr uint32_t AUTH_TIMEOUT_ID = 0; + static constexpr uint32_t READBACK_TIMEOUT_ID = 1; + static constexpr uint32_t AUTH_TIMEOUT_MS = 10000; + static constexpr uint32_t READBACK_DELAY_MS = 300; + + void on_paired_(); + +#ifdef ECOCOMFORT2_CHILD_COUNT + StaticVector children_; +#endif + uint16_t char_handle_oper_{0}; + uint8_t mode_{OPER_OFF}; + uint8_t speed_{SPEED_OFF}; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/fan.py b/esphome/components/ecocomfort2/fan.py new file mode 100644 index 00000000000..e3dd8e09386 --- /dev/null +++ b/esphome/components/ecocomfort2/fan.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ECOCOMFORT2_CLIENT_SCHEMA, ecocomfort2_ns, register_ecocomfort2_child + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +Ecocomfort2Fan = ecocomfort2_ns.class_("Ecocomfort2Fan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = ( + fan.fan_schema(Ecocomfort2Fan) + .extend(cv.COMPONENT_SCHEMA) + .extend(ECOCOMFORT2_CLIENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) + await register_ecocomfort2_child(var, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 943458e3f64..a085cf9c0ac 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -430,6 +430,7 @@ #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 +#define ECOCOMFORT2_CHILD_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml new file mode 100644 index 00000000000..b4951e804f8 --- /dev/null +++ b/tests/components/ecocomfort2/common.yaml @@ -0,0 +1,24 @@ +esp32_ble: + io_capability: none + auth_req_mode: sc_bond + max_key_size: 16 + +ble_client: + - mac_address: 01:02:03:04:05:06 + id: ecocomfort2_ble_1 + - mac_address: 01:02:03:04:05:07 + id: ecocomfort2_ble_2 + +ecocomfort2: + - id: ecocomfort2_hub_1 + ble_client_id: ecocomfort2_ble_1 + - id: ecocomfort2_hub_2 + ble_client_id: ecocomfort2_ble_2 + +fan: + - platform: ecocomfort2 + name: Ecocomfort2 Fan 1 + ecocomfort2_id: ecocomfort2_hub_1 + - platform: ecocomfort2 + name: Ecocomfort2 Fan 2 + ecocomfort2_id: ecocomfort2_hub_2 diff --git a/tests/components/ecocomfort2/test.esp32-idf.yaml b/tests/components/ecocomfort2/test.esp32-idf.yaml new file mode 100644 index 00000000000..956cd9c8135 --- /dev/null +++ b/tests/components/ecocomfort2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + ecocomfort2: !include common.yaml From 584d4878ebafc8807ab56d7752d241dcc282b709 Mon Sep 17 00:00:00 2001 From: Zebble Date: Mon, 12 Oct 2026 17:44:33 -0400 Subject: [PATCH 398/435] [lis3dh] Add LIS3DH accelerometer component (#17393) Co-authored-by: Claude 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/lis3dh/__init__.py | 15 ++ esphome/components/lis3dh/lis3dh.cpp | 195 ++++++++++++++++ esphome/components/lis3dh/lis3dh.h | 135 +++++++++++ esphome/components/lis3dh/motion.py | 214 ++++++++++++++++++ esphome/components/lis3dh/sensor.py | 41 ++++ tests/component_tests/lis3dh/__init__.py | 0 tests/component_tests/lis3dh/test_motion.py | 147 ++++++++++++ tests/components/lis3dh/common.yaml | 67 ++++++ tests/components/lis3dh/test.esp32-idf.yaml | 3 + tests/components/lis3dh/test.esp8266-ard.yaml | 3 + 11 files changed, 821 insertions(+) create mode 100644 esphome/components/lis3dh/__init__.py create mode 100644 esphome/components/lis3dh/lis3dh.cpp create mode 100644 esphome/components/lis3dh/lis3dh.h create mode 100644 esphome/components/lis3dh/motion.py create mode 100644 esphome/components/lis3dh/sensor.py create mode 100644 tests/component_tests/lis3dh/__init__.py create mode 100644 tests/component_tests/lis3dh/test_motion.py create mode 100644 tests/components/lis3dh/common.yaml create mode 100644 tests/components/lis3dh/test.esp32-idf.yaml create mode 100644 tests/components/lis3dh/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9de83996659..8f1271b76a8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -320,6 +320,7 @@ esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lis2dw12/* @latonita esphome/components/lis2dw12_i2c/* @latonita esphome/components/lis2dw12_spi/* @latonita +esphome/components/lis3dh/* @zebble esphome/components/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882h_ble_tracker/* @Bl00d-B0b diff --git a/esphome/components/lis3dh/__init__.py b/esphome/components/lis3dh/__init__.py new file mode 100644 index 00000000000..99c4ee91879 --- /dev/null +++ b/esphome/components/lis3dh/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c", "motion"] +DOMAIN = "lis3dh" + +CONF_LIS3DH_ID = "lis3dh_id" + +# C++ namespace / class +lis3dh_ns = cg.esphome_ns.namespace("lis3dh") +LIS3DHComponent = lis3dh_ns.class_("LIS3DHComponent", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp new file mode 100644 index 00000000000..22ca7ae56b2 --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -0,0 +1,195 @@ +#include "lis3dh.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis3dh { + +ESPHOME_LOG_TAG(TAG, "lis3dh"); + +// g per LSB of the left-justified 16-bit sample, indexed by range. The same in every +// operating mode, since the chip leaves the unused low bits at zero. +static const float SCALE_G[] PROGMEM = {0.0625e-3f, 0.125e-3f, 0.25e-3f, 0.75e-3f}; + +static const LogString *mode_to_str(LIS3DHOperatingMode mode) { + switch (mode) { + case LIS3DH_MODE_LOW_POWER: + return LOG_STR("low power (8-bit)"); + case LIS3DH_MODE_NORMAL: + return LOG_STR("normal (10-bit)"); + default: + return LOG_STR("high resolution (12-bit)"); + } +} + +// Output data rate in Hz, indexed by the ODR field minus one. +static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1344}; + +void LIS3DHComponent::setup() { + MotionComponent::setup(); + + uint8_t who_am_i = 0; + if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != LIS3DH_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { + ESP_LOGE(TAG, "Failed to configure sensor"); + this->mark_failed(); + return; + } +} + +bool LIS3DHComponent::setup_accelerometer_() { + // CTRL_REG1: output data rate, low-power flag and axis enable. + uint8_t ctrl_reg1 = ((uint8_t) this->data_rate_ << 4) | LIS3DH_CTRL_REG1_AXES_EN; + if (this->operating_mode_ == LIS3DH_MODE_LOW_POWER) + ctrl_reg1 |= LIS3DH_CTRL_REG1_LPEN; + if (!this->write_byte(LIS3DH_REG_CTRL_REG1, ctrl_reg1)) + return false; + + // CTRL_REG4: block data update, full-scale range and high-resolution flag. + uint8_t ctrl_reg4 = LIS3DH_CTRL_REG4_BDU | ((uint8_t) this->range_ << 4); + if (this->operating_mode_ == LIS3DH_MODE_HIGH_RESOLUTION) + ctrl_reg4 |= LIS3DH_CTRL_REG4_HR; + return this->write_byte(LIS3DH_REG_CTRL_REG4, ctrl_reg4); +} + +bool LIS3DHComponent::setup_temperature_() { + // Only power the ADC / temperature sensor when a temperature sensor is used. + uint8_t temp_cfg = + this->temperature_callback_.empty() ? 0x00 : (uint8_t) (LIS3DH_TEMP_CFG_ADC_EN | LIS3DH_TEMP_CFG_TEMP_EN); + return this->write_byte(LIS3DH_REG_TEMP_CFG, temp_cfg); +} + +bool LIS3DHComponent::setup_interrupt_() { + // Runs even with no interrupt configured: the chip usually stays powered across resets, + // so this drops whatever the previous firmware left in the interrupt registers. + // High-pass filter the interrupt (IA1) input so the ~1 g of gravity is removed + // from the threshold comparison. The output data registers stay unfiltered + // (FDS = 0), so acceleration / pitch / roll still report the full gravity vector. + uint8_t ctrl_reg2 = this->interrupt_high_pass_ ? LIS3DH_CTRL_REG2_HP_IA1 : 0x00; + if (!this->write_byte(LIS3DH_REG_CTRL_REG2, ctrl_reg2)) + return false; + + if (!this->write_byte(LIS3DH_REG_INT1_CFG, this->interrupt_axes_cfg_) || + !this->write_byte(LIS3DH_REG_INT1_THS, this->interrupt_threshold_) || + !this->write_byte(LIS3DH_REG_INT1_DURATION, this->interrupt_duration_)) + return false; + + // Latch the request so a brief movement keeps the pad asserted until INT1_SRC is read. + uint8_t ctrl_reg5 = this->interrupt_latched_ ? LIS3DH_CTRL_REG5_LIR_INT1 : 0; + if (!this->write_byte(LIS3DH_REG_CTRL_REG5, ctrl_reg5)) + return false; + + // Route the IA1 engine to the chosen physical pad and set the pad polarity. + uint8_t ctrl_reg3 = 0; + uint8_t ctrl_reg6 = this->interrupt_active_high_ ? 0 : LIS3DH_CTRL_REG6_INT_POLARITY; + if (this->interrupt_axes_cfg_ != 0) { + if (this->interrupt_pin_ == LIS3DH_INT_PIN_INT1) { + ctrl_reg3 |= LIS3DH_CTRL_REG3_I1_IA1; + } else { + ctrl_reg6 |= LIS3DH_CTRL_REG6_I2_IA1; + } + } + if (!this->write_byte(LIS3DH_REG_CTRL_REG3, ctrl_reg3) || !this->write_byte(LIS3DH_REG_CTRL_REG6, ctrl_reg6)) + return false; + if (this->interrupt_axes_cfg_ == 0) + return true; + + // With the high-pass filter enabled, reading REFERENCE captures the current + // acceleration as the filter's zero point (removing the standing gravity bias). + if (this->interrupt_high_pass_) { + uint8_t reference = 0; + this->read_byte(LIS3DH_REG_REFERENCE, &reference); + } + + // Clear any latched request left over from the motion that woke the device. + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + return true; +} + +void LIS3DHComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LIS3DH:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, + " Range: ±%ug\n" + " Operating mode: %s\n" + " Data rate: %uHz", + 2u << this->range_, LOG_STR_ARG(mode_to_str(this->operating_mode_)), + (unsigned) progmem_read_uint16(&RATE_HZ[this->data_rate_ - 1])); + if (this->interrupt_axes_cfg_ != 0) { + ESP_LOGCONFIG(TAG, + " Motion interrupt: %s (%s%s%s)\n" + " Threshold: %u steps\n" + " Duration: %u samples", + this->interrupt_pin_ == LIS3DH_INT_PIN_INT1 ? LOG_STR_LITERAL("INT1") : LOG_STR_LITERAL("INT2"), + this->interrupt_active_high_ ? LOG_STR_LITERAL("active high") : LOG_STR_LITERAL("active low"), + this->interrupt_high_pass_ ? LOG_STR_LITERAL(", high-pass") : LOG_STR_LITERAL(""), + this->interrupt_latched_ ? LOG_STR_LITERAL(", latched") : LOG_STR_LITERAL(""), + this->interrupt_threshold_, this->interrupt_duration_); + } + MotionComponent::dump_config(); +} + +void LIS3DHComponent::on_shutdown() { + // Clear a latched request from motion seen while awake, so it does not wake the device straight away. + if (this->interrupt_latched_ && this->interrupt_axes_cfg_ != 0 && !this->is_failed()) { + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + } +} + +bool LIS3DHComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + uint8_t raw[6]; + if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) { + this->status_set_warning(LOG_STR("Failed to read acceleration data")); + return false; + } + + float scale; + progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale)); + + // Data is little-endian and left-justified in a signed 16-bit register. + int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]); + int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]); + int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty() || this->publish_temperature_()) + this->status_clear_warning(); + return true; +} + +bool LIS3DHComponent::publish_temperature_() { + uint8_t raw[2]; + if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + this->status_set_warning(LOG_STR("Failed to read temperature data")); + return false; + } + // Signed, left-justified, relative value with 1 °C per count of the high byte; the + // low byte carries the fractional bits in the modes that have them. + float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/lis3dh.h b/esphome/components/lis3dh/lis3dh.h new file mode 100644 index 00000000000..15c19933c7b --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.h @@ -0,0 +1,135 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::lis3dh { + +// Register map (see ST datasheet DocID18198) +static constexpr uint8_t LIS3DH_REG_OUT_ADC3_L = 0x0C; // temperature (ADC3) low byte +static constexpr uint8_t LIS3DH_REG_WHO_AM_I = 0x0F; +static constexpr uint8_t LIS3DH_REG_TEMP_CFG = 0x1F; +static constexpr uint8_t LIS3DH_REG_CTRL_REG1 = 0x20; // ODR / low-power / axis enable +static constexpr uint8_t LIS3DH_REG_CTRL_REG2 = 0x21; // high-pass filter +static constexpr uint8_t LIS3DH_REG_CTRL_REG3 = 0x22; // INT1 pad routing +static constexpr uint8_t LIS3DH_REG_CTRL_REG4 = 0x23; // BDU / full-scale / high-resolution +static constexpr uint8_t LIS3DH_REG_CTRL_REG5 = 0x24; // interrupt latching +static constexpr uint8_t LIS3DH_REG_CTRL_REG6 = 0x25; // INT2 pad routing / polarity +static constexpr uint8_t LIS3DH_REG_REFERENCE = 0x26; // high-pass filter reference +static constexpr uint8_t LIS3DH_REG_OUT_X_L = 0x28; // acceleration data block (auto-increment) +static constexpr uint8_t LIS3DH_REG_INT1_CFG = 0x30; +static constexpr uint8_t LIS3DH_REG_INT1_SRC = 0x31; +static constexpr uint8_t LIS3DH_REG_INT1_THS = 0x32; +static constexpr uint8_t LIS3DH_REG_INT1_DURATION = 0x33; + +static constexpr uint8_t LIS3DH_WHO_AM_I_VALUE = 0x33; + +// Sub-address auto-increment bit: OR into the register for multi-byte reads. +static constexpr uint8_t LIS3DH_AUTO_INCREMENT = 0x80; + +// CTRL_REG1 +static constexpr uint8_t LIS3DH_CTRL_REG1_AXES_EN = 0x07; // Xen | Yen | Zen +static constexpr uint8_t LIS3DH_CTRL_REG1_LPEN = 0x08; // low-power enable +// CTRL_REG2 +static constexpr uint8_t LIS3DH_CTRL_REG2_HP_IA1 = 0x01; // high-pass filter the IA1 interrupt +// CTRL_REG4 +static constexpr uint8_t LIS3DH_CTRL_REG4_HR = 0x08; // high-resolution enable +static constexpr uint8_t LIS3DH_CTRL_REG4_BDU = 0x80; // block data update +// CTRL_REG5 +static constexpr uint8_t LIS3DH_CTRL_REG5_LIR_INT1 = 0x08; // latch IA1 interrupt +// CTRL_REG3 / CTRL_REG6 interrupt routing +static constexpr uint8_t LIS3DH_CTRL_REG3_I1_IA1 = 0x40; // route IA1 to INT1 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_I2_IA1 = 0x40; // route IA1 to INT2 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_INT_POLARITY = 0x02; // 1 = active low +// TEMP_CFG_REG +static constexpr uint8_t LIS3DH_TEMP_CFG_ADC_EN = 0x80; +static constexpr uint8_t LIS3DH_TEMP_CFG_TEMP_EN = 0x40; + +// The temperature sensor is uncalibrated and reports relative changes only, +// so this fixed reference is just a rough starting point for the °C output. +static constexpr float LIS3DH_TEMP_REFERENCE_C = 25.0f; + +// Full-scale range: value is the index into the scale tables and the +// FS field of CTRL_REG4 (shifted left by 4). +enum LIS3DHRange : uint8_t { + LIS3DH_RANGE_2G = 0, + LIS3DH_RANGE_4G = 1, + LIS3DH_RANGE_8G = 2, + LIS3DH_RANGE_16G = 3, +}; + +// Output data rate: value is the ODR field of CTRL_REG1 (bits 7:4). +enum LIS3DHDataRate : uint8_t { + LIS3DH_DATA_RATE_1HZ = 0x1, + LIS3DH_DATA_RATE_10HZ = 0x2, + LIS3DH_DATA_RATE_25HZ = 0x3, + LIS3DH_DATA_RATE_50HZ = 0x4, + LIS3DH_DATA_RATE_100HZ = 0x5, + LIS3DH_DATA_RATE_200HZ = 0x6, + LIS3DH_DATA_RATE_400HZ = 0x7, + LIS3DH_DATA_RATE_1620HZ = 0x8, // low-power mode only + LIS3DH_DATA_RATE_1344HZ = 0x9, // 1344 Hz normal/HR, 5376 Hz low-power +}; + +// Operating mode: selects the resolution (and thus power usage). +enum LIS3DHOperatingMode : uint8_t { + LIS3DH_MODE_LOW_POWER = 0, // 8-bit + LIS3DH_MODE_NORMAL = 1, // 10-bit + LIS3DH_MODE_HIGH_RESOLUTION = 2, // 12-bit +}; + +// Physical interrupt pad the motion interrupt is routed to. +enum LIS3DHInterruptPin : uint8_t { + LIS3DH_INT_PIN_INT1 = 0, + LIS3DH_INT_PIN_INT2 = 1, +}; + +class LIS3DHComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + + void set_range(LIS3DHRange range) { this->range_ = range; } + void set_data_rate(LIS3DHDataRate data_rate) { this->data_rate_ = data_rate; } + void set_operating_mode(LIS3DHOperatingMode mode) { this->operating_mode_ = mode; } + // threshold is the INT1_THS value and axes_cfg the INT1_CFG mask, both computed in Python. + void set_interrupt(LIS3DHInterruptPin pin, uint8_t threshold, uint8_t duration, uint8_t axes_cfg, bool latched, + bool active_high, bool high_pass) { + this->interrupt_pin_ = pin; + this->interrupt_threshold_ = threshold; + this->interrupt_duration_ = duration; + this->interrupt_axes_cfg_ = axes_cfg; + this->interrupt_latched_ = latched; + this->interrupt_active_high_ = active_high; + this->interrupt_high_pass_ = high_pass; + } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool setup_accelerometer_(); + bool setup_temperature_(); + bool setup_interrupt_(); + bool publish_temperature_(); + + LIS3DHRange range_{LIS3DH_RANGE_2G}; + LIS3DHDataRate data_rate_{LIS3DH_DATA_RATE_100HZ}; + LIS3DHOperatingMode operating_mode_{LIS3DH_MODE_HIGH_RESOLUTION}; + + // Motion (activity) interrupt configuration; a zero axis mask means disabled. + LIS3DHInterruptPin interrupt_pin_{LIS3DH_INT_PIN_INT1}; + uint8_t interrupt_threshold_{0}; + uint8_t interrupt_duration_{0}; + uint8_t interrupt_axes_cfg_{0}; + bool interrupt_latched_{false}; + bool interrupt_active_high_{true}; + bool interrupt_high_pass_{false}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py new file mode 100644 index 00000000000..ea4b4d3b81a --- /dev/null +++ b/esphome/components/lis3dh/motion.py @@ -0,0 +1,214 @@ +import logging +import math + +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import CONF_ACCELEROMETER_ODR, CONF_ACCELEROMETER_RANGE +from esphome.components.motion import AXES, motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType + +from . import LIS3DHComponent, lis3dh_ns + +_LOGGER = logging.getLogger(__name__) + +CONF_OPERATING_MODE = "operating_mode" +CONF_AXES = "axes" +CONF_LATCHED = "latched" +CONF_ACTIVE_HIGH = "active_high" +CONF_HIGH_PASS_FILTER = "high_pass_filter" + +# Enum values must match the C++ enums. +LIS3DHRange = lis3dh_ns.enum("LIS3DHRange") +RANGE_OPTIONS = { + "2G": LIS3DHRange.LIS3DH_RANGE_2G, + "4G": LIS3DHRange.LIS3DH_RANGE_4G, + "8G": LIS3DHRange.LIS3DH_RANGE_8G, + "16G": LIS3DHRange.LIS3DH_RANGE_16G, +} + +LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") +DATA_RATE_OPTIONS = { + "1HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1HZ, + "10HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_10HZ, + "25HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_25HZ, + "50HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_50HZ, + "100HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_100HZ, + "200HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_200HZ, + "400HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_400HZ, + # 1620HZ is only available in low-power mode. + "1620HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1620HZ, + # 1344HZ in normal / high-resolution mode; 5376HZ in low-power mode. + "1344HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1344HZ, +} + +# Samples per second for each data rate key; INT1_DURATION counts samples. +ODR_HZ = { + "1HZ": 1, + "10HZ": 10, + "25HZ": 25, + "50HZ": 50, + "100HZ": 100, + "200HZ": 200, + "400HZ": 400, + "1620HZ": 1620, + "1344HZ": 1344, +} +DURATION_MAX_REG = 0x7F + +LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode") +OPERATING_MODE_OPTIONS = { + "LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER, + "NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL, + "HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION, +} + +LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") +INTERRUPT_PIN_OPTIONS = { + "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, + "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, +} + +# INT1_CFG high-event bit per axis. +INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} + +# Motion interrupt threshold LSB in mg, per range. INT1_THS holds seven bits. +THRESHOLD_LSB_MG = {"2G": 16, "4G": 32, "8G": 62, "16G": 186} +THRESHOLD_MAX_REG = 0x7F + + +def _threshold_register(threshold_g: float, range_key: str) -> int: + """Return INT1_THS for a threshold in g: nearest step, at least 1 so the pad is not held asserted.""" + return max(math.floor(threshold_g * 1000 / THRESHOLD_LSB_MG[range_key] + 0.5), 1) + + +def _duration_register(duration_ms: int, odr_key: str) -> int: + """Return INT1_DURATION for a time in ms: the nearest number of samples at the data rate.""" + return math.floor(duration_ms * ODR_HZ[odr_key] / 1000 + 0.5) + + +def _axes(value: object) -> list[str]: + value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))( + value + ) + if len(set(value)) != len(value): + raise cv.Invalid("Each axis may be listed only once") + return value + + +# Without high_pass_filter the threshold includes gravity, so it should exceed 1g. +INTERRUPT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PIN, default="INT1"): cv.enum( + INTERRUPT_PIN_OPTIONS, upper=True + ), + cv.Optional(CONF_THRESHOLD, default="1.1g"): cv.All( + cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float + ), + cv.Optional(CONF_DURATION, default="0ms"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AXES, default=AXES): _axes, + cv.Optional(CONF_LATCHED, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE_HIGH, default=True): cv.boolean, + cv.Optional(CONF_HIGH_PASS_FILTER, default=False): cv.boolean, + } +) + + +def _validate_odr_mode(config: ConfigType) -> ConfigType: + # 1620Hz exists only in low power mode; the 1344Hz code runs at 5376Hz there. + odr = config[CONF_ACCELEROMETER_ODR] + low_power = config[CONF_OPERATING_MODE] == "LOW_POWER" + if odr == "1620HZ" and not low_power: + raise cv.Invalid( + "'1620HZ' is only available with 'operating_mode: LOW_POWER'", + path=[CONF_ACCELEROMETER_ODR], + ) + if odr == "1344HZ" and low_power: + raise cv.Invalid( + "'1344HZ' is not available with 'operating_mode: LOW_POWER' " + "(that combination runs at 5376Hz)", + path=[CONF_ACCELEROMETER_ODR], + ) + return config + + +def _validate_interrupt(config: ConfigType) -> ConfigType: + if (interrupt := config.get(CONF_INTERRUPT)) is None: + return config + range_key = config[CONF_ACCELEROMETER_RANGE] + threshold = interrupt[CONF_THRESHOLD] + max_g = THRESHOLD_MAX_REG * THRESHOLD_LSB_MG[range_key] / 1000 + if threshold > max_g: + raise cv.Invalid( + f"'threshold' can be at most {max_g:g}g with 'accelerometer_range: {range_key}'", + path=[CONF_INTERRUPT, CONF_THRESHOLD], + ) + if not interrupt[CONF_HIGH_PASS_FILTER] and threshold <= 1.0: + _LOGGER.warning( + "lis3dh: a threshold of %sg or less includes gravity and may keep the interrupt " + "asserted at rest; set 'high_pass_filter: true' to compare against motion only", + threshold, + ) + odr_key = config[CONF_ACCELEROMETER_ODR] + duration_ms = interrupt[CONF_DURATION].total_milliseconds + if _duration_register(duration_ms, odr_key) > DURATION_MAX_REG: + max_ms = math.floor(DURATION_MAX_REG * 1000 / ODR_HZ[odr_key]) + raise cv.Invalid( + f"'duration' can be at most {max_ms}ms with 'accelerometer_odr: {odr_key}'", + path=[CONF_INTERRUPT, CONF_DURATION], + ) + return config + + +CONFIG_SCHEMA = cv.All( + motion_schema(LIS3DHComponent, has_accel=True, has_gyro=False) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + DATA_RATE_OPTIONS, upper=True + ), + cv.Optional(CONF_OPERATING_MODE, default="HIGH_RESOLUTION"): cv.enum( + OPERATING_MODE_OPTIONS, upper=True + ), + cv.Optional(CONF_INTERRUPT): INTERRUPT_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x18)), + _validate_odr_mode, + _validate_interrupt, +) + + +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_data_rate(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_operating_mode(config[CONF_OPERATING_MODE])) + + if (interrupt := config.get(CONF_INTERRUPT)) is not None: + threshold = _threshold_register( + interrupt[CONF_THRESHOLD], config[CONF_ACCELEROMETER_RANGE] + ) + axes_cfg = 0 + for axis in interrupt[CONF_AXES]: + axes_cfg |= INT_CFG_HIGH_EVENT[axis] + cg.add( + var.set_interrupt( + interrupt[CONF_PIN], + threshold, + _duration_register( + interrupt[CONF_DURATION].total_milliseconds, + config[CONF_ACCELEROMETER_ODR], + ), + axes_cfg, + interrupt[CONF_LATCHED], + interrupt[CONF_ACTIVE_HIGH], + interrupt[CONF_HIGH_PASS_FILTER], + ) + ) diff --git a/esphome/components/lis3dh/sensor.py b/esphome/components/lis3dh/sensor.py new file mode 100644 index 00000000000..15d19bcc0c0 --- /dev/null +++ b/esphome/components/lis3dh/sensor.py @@ -0,0 +1,41 @@ +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_LIS3DH_ID, LIS3DHComponent + +# The LIS3DH temperature sensor is uncalibrated and only reports relative +# changes (roughly 1 °C per count), so a single decimal of accuracy is plenty. +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_LIS3DH_ID): cv.use_id(LIS3DHComponent), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LIS3DH_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/component_tests/lis3dh/__init__.py b/tests/component_tests/lis3dh/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/lis3dh/test_motion.py b/tests/component_tests/lis3dh/test_motion.py new file mode 100644 index 00000000000..1d86761d4ce --- /dev/null +++ b/tests/component_tests/lis3dh/test_motion.py @@ -0,0 +1,147 @@ +"""Tests for the lis3dh config cross-checks and interrupt threshold maths.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lis3dh.motion import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_DURATION, + CONF_HIGH_PASS_FILTER, + CONF_INTERRUPT, + CONF_OPERATING_MODE, + CONF_THRESHOLD, + _duration_register, + _threshold_register, + _validate_interrupt, + _validate_odr_mode, +) +import esphome.config_validation as cv +from esphome.core import TimePeriod + + +def _interrupt_config( + range_key: str, + odr_key: str, + threshold_g: float, + duration_ms: int, + high_pass: bool = True, +) -> dict: + return { + CONF_ACCELEROMETER_RANGE: range_key, + CONF_ACCELEROMETER_ODR: odr_key, + CONF_INTERRUPT: { + CONF_THRESHOLD: threshold_g, + CONF_DURATION: TimePeriod(milliseconds=duration_ms), + CONF_HIGH_PASS_FILTER: high_pass, + }, + } + + +@pytest.mark.parametrize("mode", ["NORMAL", "HIGH_RESOLUTION"]) +def test_1620hz_needs_low_power(mode: str) -> None: + """1620Hz exists only in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: mode} + with pytest.raises(cv.Invalid, match="only available with"): + _validate_odr_mode(config) + + +def test_1344hz_is_rejected_in_low_power() -> None: + """The 1344Hz code runs at 5376Hz in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1344HZ", CONF_OPERATING_MODE: "LOW_POWER"} + with pytest.raises(cv.Invalid, match="not available with"): + _validate_odr_mode(config) + + +def test_1620hz_low_power_is_accepted() -> None: + """The one valid 1620Hz combination passes through unchanged.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: "LOW_POWER"} + assert _validate_odr_mode(config) is config + + +@pytest.mark.parametrize( + ("threshold_g", "range_key", "expected"), + [ + (0.0, "2G", 1), + (0.05, "16G", 1), + (1.1, "2G", 69), + (0.2, "2G", 13), + (2.032, "2G", 127), + (23.622, "16G", 127), + ], +) +def test_threshold_register(threshold_g: float, range_key: str, expected: int) -> None: + """INT1_THS is the nearest step and never zero.""" + assert _threshold_register(threshold_g, range_key) == expected + + +@pytest.mark.parametrize( + ("threshold_g", "range_key"), + [(2.04, "2G"), (3.0, "2G"), (4.07, "4G"), (23.7, "16G")], +) +def test_threshold_above_range_is_rejected(threshold_g: float, range_key: str) -> None: + """A threshold the range cannot represent would never fire, so it fails validation.""" + config = _interrupt_config(range_key, "100HZ", threshold_g, 0) + with pytest.raises(cv.Invalid, match="'threshold' can be at most"): + _validate_interrupt(config) + + +def test_threshold_within_range_is_accepted() -> None: + """The largest representable threshold is still valid.""" + config = _interrupt_config("2G", "100HZ", 2.032, 0) + assert _validate_interrupt(config) is config + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key", "expected"), + [ + (0, "100HZ", 0), + (20, "100HZ", 2), + (1, "1620HZ", 2), + (10000, "1HZ", 10), + (4, "100HZ", 0), + ], +) +def test_duration_register(duration_ms: int, odr_key: str, expected: int) -> None: + """INT1_DURATION is the nearest number of samples at the data rate.""" + assert _duration_register(duration_ms, odr_key) == expected + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key"), [(1280, "100HZ"), (128000, "1HZ"), (79, "1620HZ")] +) +def test_duration_above_register_is_rejected(duration_ms: int, odr_key: str) -> None: + """A duration that needs more than 127 samples fails validation.""" + config = _interrupt_config("2G", odr_key, 1.1, duration_ms) + with pytest.raises(cv.Invalid, match="'duration' can be at most"): + _validate_interrupt(config) + + +def test_duration_at_register_limit_is_accepted() -> None: + """127 samples is the largest INT1_DURATION value.""" + config = _interrupt_config("2G", "100HZ", 1.1, 1270) + assert _validate_interrupt(config) is config + + +def test_sub_gravity_threshold_without_filter_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Gravity alone can hold the pad asserted below 1g unless the high-pass filter is on.""" + config = _interrupt_config("2G", "100HZ", 0.5, 0, high_pass=False) + with caplog.at_level(logging.WARNING): + assert _validate_interrupt(config) is config + assert "high_pass_filter: true" in caplog.text + + +@pytest.mark.parametrize(("threshold_g", "high_pass"), [(1.1, False), (0.5, True)]) +def test_threshold_without_gravity_problem_is_silent( + caplog: pytest.LogCaptureFixture, threshold_g: float, high_pass: bool +) -> None: + """Above 1g, or with the filter on, no warning is logged.""" + config = _interrupt_config("2G", "100HZ", threshold_g, 0, high_pass=high_pass) + with caplog.at_level(logging.WARNING): + _validate_interrupt(config) + assert caplog.text == "" diff --git a/tests/components/lis3dh/common.yaml b/tests/components/lis3dh/common.yaml new file mode 100644 index 00000000000..54f492ecfa7 --- /dev/null +++ b/tests/components/lis3dh/common.yaml @@ -0,0 +1,67 @@ +motion: + - platform: lis3dh + id: lis3dh_main + i2c_id: i2c_bus + accelerometer_range: 2G + accelerometer_odr: 100HZ + operating_mode: HIGH_RESOLUTION + axis_map: + x: y + y: x + z: -z + # Motion (activity) interrupt - keeps the INT pad asserted while moving so it + # can be used as the deep_sleep wakeup_pin. + interrupt: + pin: INT1 + threshold: 1.1g + duration: 0ms + axes: [x, y, z] + latched: true + active_high: true + high_pass_filter: false + + # A second device covering the other side of each option: low power mode with + # the rate that only exists there, routed to INT2, active low, unlatched, and + # with the high-pass filter on. + - platform: lis3dh + id: lis3dh_low_power + i2c_id: i2c_bus + address: 0x19 + accelerometer_range: 16G + accelerometer_odr: 1620HZ + operating_mode: LOW_POWER + interrupt: + pin: INT2 + threshold: 0.05g + duration: 1ms + axes: [z] + latched: false + active_high: false + high_pass_filter: true + +sensor: + - platform: lis3dh + lis3dh_id: lis3dh_main + type: temperature + name: "LIS3DH Temperature" + + - platform: motion + motion_id: lis3dh_main + type: acceleration_x + name: "Accel X" + - platform: motion + motion_id: lis3dh_main + type: acceleration_y + name: "Accel Y" + - platform: motion + motion_id: lis3dh_main + type: acceleration_z + name: "Accel Z" + - platform: motion + motion_id: lis3dh_main + type: pitch + name: "Pitch" + - platform: motion + motion_id: lis3dh_main + type: roll + name: "Roll" diff --git a/tests/components/lis3dh/test.esp32-idf.yaml b/tests/components/lis3dh/test.esp32-idf.yaml new file mode 100644 index 00000000000..41a8a051658 --- /dev/null +++ b/tests/components/lis3dh/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lis3dh: !include common.yaml diff --git a/tests/components/lis3dh/test.esp8266-ard.yaml b/tests/components/lis3dh/test.esp8266-ard.yaml new file mode 100644 index 00000000000..63c7f2a94ac --- /dev/null +++ b/tests/components/lis3dh/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + lis3dh: !include common.yaml From 83e5e32e2bcdbb6b15ad0761952999e8b77aa845 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Mon, 12 Oct 2026 14:52:05 -0700 Subject: [PATCH 399/435] [i2s_clockless_led_strip] Drives LED strips using I2S and DMA (#20255) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../i2s_clockless_led_strip/__init__.py | 1 + .../i2s_clockless_led_strip.cpp | 254 ++++++++++++++++++ .../i2s_clockless_led_strip.h | 56 ++++ .../i2s_clockless_led_strip/light.py | 86 ++++++ .../test.esp32-s3-idf.yaml | 11 + 6 files changed, 409 insertions(+) create mode 100644 esphome/components/i2s_clockless_led_strip/__init__.py create mode 100644 esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp create mode 100644 esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h create mode 100644 esphome/components/i2s_clockless_led_strip/light.py create mode 100644 tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8f1271b76a8..ec2bddd5489 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -276,6 +276,7 @@ esphome/components/i2c_device/* @gabest11 esphome/components/i2s_audio/* @jesserockz 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/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 diff --git a/esphome/components/i2s_clockless_led_strip/__init__.py b/esphome/components/i2s_clockless_led_strip/__init__.py new file mode 100644 index 00000000000..038e865f3ab --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "i2s_clockless_led_strip" diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp new file mode 100644 index 00000000000..bf53d96a83f --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -0,0 +1,254 @@ +#include "i2s_clockless_led_strip.h" + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::i2s_clockless_led_strip { + +ESPHOME_LOG_TAG(TAG, "i2s_clockless_led_strip"); + +constexpr const char *const ERROR_ALLOCATION = "Allocation error"; +constexpr const char *const ERROR_I2S = "I2S error"; + +// These values are hardcoded for an LED strip at 800 kbps. +// +// Each bit of LED strip data gets expanded into 3 bits of I2S data, where a 0-bit expands into 100 +// (1/3 duty cycle) and a 1-bit expands into 110 (2/3 duty cycle. Each byte of LED strip data becomes +// one 24-bit I2S sample to be transmitted at a rate of 100000 samples per second in I2S TDM mode. +constexpr uint32_t I2S_SAMPLE_RATE_HZ = 100000; +constexpr size_t I2S_BYTES_PER_SAMPLE = 3; + +// Determine how many I2S samples are needed to feed the output for the specified duration and vice-versa. +constexpr size_t num_i2s_samples_from_duration_us(uint32_t duration_us) { + return (duration_us * I2S_SAMPLE_RATE_HZ + 999999) / 1000000; +} +constexpr uint32_t num_i2s_samples_to_duration_us(size_t samples) { return samples * 1000000ull / I2S_SAMPLE_RATE_HZ; } + +// Determine the number of I2S samples to pad with zeros after the LED data to encode the LED strip reset signal. +// Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5. +constexpr uint32_t I2S_RESET_DURATION_US = 300; +constexpr size_t I2S_RESET_SAMPLES = num_i2s_samples_from_duration_us(I2S_RESET_DURATION_US); +constexpr size_t I2S_RESET_BYTES = I2S_RESET_SAMPLES * I2S_BYTES_PER_SAMPLE; + +// Determine the number of I2S samples per DMA buffer to ensure that the interrupt-driven I2S on_sent callback +// doesn't run too frequently. For example, if the buffers were sized to hold just 1 RGBW pixel then they would be +// recycled approximately every 40 us (25000 Hz) which is way too fast. The buffer duration also imposes an upper +// bound on the maximum LED strip refresh rate. Round up to a multiple of 3 for `dma_frame_num` as required by +// the ESP-IDF programming guide when using 24-bit samples. +constexpr uint32_t I2S_BUFFER_DURATION_US_IDEAL = 2500; +constexpr size_t I2S_BUFFER_SAMPLES = (num_i2s_samples_from_duration_us(I2S_BUFFER_DURATION_US_IDEAL) + 2) / 3 * 3; +constexpr uint32_t I2S_BUFFER_DURATION_US_ACTUAL = num_i2s_samples_to_duration_us(I2S_BUFFER_SAMPLES); + +I2SClocklessLedStrip::I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors) + : color_data_bytes_(num_leds * channel_colors.bytes_per_led()), + channel_colors_(channel_colors), + num_leds_(num_leds), + pin_(pin) {} + +void I2SClocklessLedStrip::dump_config() { + ESP_LOGCONFIG(TAG, + "I2S Clockless LED Strip:\n" + " Pin: %u", + this->pin_); + char channel_colors[5]; + ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" + " Number of LEDs: %" PRIu16 "\n" + " DMA buffer: %" PRIuPTR " samples (%" PRIu32 " us)", + this->channel_colors_.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES, + I2S_BUFFER_DURATION_US_ACTUAL); +} + +float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; } + +void I2SClocklessLedStrip::setup() { + if (!this->allocate_buffers_()) { + this->mark_failed(LOG_STR(ERROR_ALLOCATION)); + return; + } + + i2s_chan_config_t chan_config = { + .id = I2S_NUM_AUTO, + .role = I2S_ROLE_MASTER, + .dma_desc_num = 2, + .dma_frame_num = I2S_BUFFER_SAMPLES, + .auto_clear_after_cb = false, + .auto_clear_before_cb = false, + .allow_pd = false, + .intr_priority = 0, + }; + esp_err_t err = i2s_new_channel(&chan_config, &this->tx_handle_, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_new_channel: %s", esp_err_to_name(err)); + } else { + i2s_tdm_config_t tdm_config = { + .clk_cfg = + { + .sample_rate_hz = I2S_SAMPLE_RATE_HZ, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_384, + }, + .slot_cfg = + { + .data_bit_width = I2S_DATA_BIT_WIDTH_24BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = I2S_TDM_SLOT0, + .ws_width = 1, + .big_endian = true, + .total_slot = I2S_TDM_AUTO_SLOT_NUM, + }, + .gpio_cfg = + { + .mclk = I2S_GPIO_UNUSED, + .bclk = I2S_GPIO_UNUSED, + .ws = I2S_GPIO_UNUSED, + .dout = gpio_num_t(this->pin_), + .din = I2S_GPIO_UNUSED, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + err = i2s_channel_init_tdm_mode(this->tx_handle_, &tdm_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_init_tdm_mode: %s", esp_err_to_name(err)); + } else { + i2s_event_callbacks_t event_callbacks = { + .on_sent = i2s_on_sent_callback, + }; + err = i2s_channel_register_event_callback(this->tx_handle_, &event_callbacks, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_register_event_callback: %s", esp_err_to_name(err)); + } else { + err = i2s_channel_enable(this->tx_handle_); + if (err == ESP_OK) + return; // Success! + ESP_LOGE(TAG, "Error in i2s_channel_enable: %s", esp_err_to_name(err)); + } + } + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = {}; + } + this->mark_failed(LOG_STR(ERROR_I2S)); +} + +bool I2SClocklessLedStrip::allocate_buffers_() { + const size_t i2s_data_bytes = this->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->i2s_data_ = allocator.allocate(i2s_data_bytes); + if (this->i2s_data_ != nullptr) { + this->color_data_ = allocator.allocate(this->color_data_bytes_); + if (this->color_data_ != nullptr) { + this->effect_data_ = allocator.allocate(this->num_leds_); + if (this->effect_data_ != nullptr) { + memset(this->i2s_data_, 0, i2s_data_bytes); + memset(this->color_data_, 0, this->color_data_bytes_); + memset(this->effect_data_, 0, this->num_leds_); + return true; + } + allocator.deallocate(this->color_data_, this->color_data_bytes_); + this->color_data_ = nullptr; + } + allocator.deallocate(this->i2s_data_, i2s_data_bytes); + this->i2s_data_ = nullptr; + } + return false; +} + +light::LightTraits I2SClocklessLedStrip::get_traits() { + auto traits = light::LightTraits(); + if (this->channel_colors_.has_white()) { + traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); + } else { + traits.set_supported_color_modes({light::ColorMode::RGB}); + } + return traits; +} + +void I2SClocklessLedStrip::write_state(light::LightState *state) { + if (this->is_failed()) + return; + + if (this->i2s_data_ready_.load(std::memory_order_acquire)) { + // Busy sending the last frame, try again later. + this->schedule_show(); + return; + } + + const size_t color_data_bytes = this->color_data_bytes_; + uint8_t *color_data = this->color_data_; + uint8_t *i2s_data = this->i2s_data_; + for (size_t i = 0; i < color_data_bytes; i++) { + const uint8_t color_byte = *(color_data++); + *(i2s_data++) = 0b10010010 | ((color_byte & 0x80) >> 1) | ((color_byte & 0x40) >> 3) | ((color_byte & 0x20) >> 5); + *(i2s_data++) = 0b01001001 | ((color_byte & 0x10) << 1) | ((color_byte & 0x08) >> 1); + *(i2s_data++) = 0b00100100 | ((color_byte & 0x04) << 5) | ((color_byte & 0x02) << 3) | ((color_byte & 0x01) << 1); + } + + this->i2s_data_sent_ = 0; + this->i2s_data_ready_.store(true, std::memory_order_release); + + this->mark_shown_(); +} + +bool IRAM_ATTR HOT I2SClocklessLedStrip::i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, + void *user_ctx) { + auto *const self = static_cast(user_ctx); + auto *const dma_buf = static_cast(event->dma_buf); + const size_t dma_buf_size = event->size; + + bool dma_buf_filled = false; + if (self->i2s_data_ready_.load(std::memory_order_acquire)) { + const size_t i2s_data_bytes = self->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + if (self->i2s_data_sent_ < i2s_data_bytes) { + const size_t i2s_data_remaining = i2s_data_bytes - self->i2s_data_sent_; + if (i2s_data_remaining >= dma_buf_size) { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, dma_buf_size); + } else { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, i2s_data_remaining); + memset(dma_buf + i2s_data_remaining, 0, dma_buf_size - i2s_data_remaining); + } + dma_buf_filled = true; + } + self->i2s_data_sent_ += dma_buf_size; + if (self->i2s_data_sent_ >= i2s_data_bytes + I2S_RESET_BYTES) { + self->i2s_data_ready_.store(false, std::memory_order_release); + } + } + // We only need to clear the buffer when the first byte is non-zero. Zero is not a value + // that can appear within the I2S data that gets copied into the front of the buffer so + // if the first byte is zero then the buffer must have already been cleared. + if (!dma_buf_filled && dma_buf[0] != 0) { + memset(dma_buf, 0, dma_buf_size); + } + return false; +} + +void I2SClocklessLedStrip::clear_effect_data() { memset(this->effect_data_, 0, this->num_leds_); } + +light::ESPColorView I2SClocklessLedStrip::get_view_internal(int32_t index) const { + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->color_data_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, + &this->effect_data_[index], + &this->correction_}; +} + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h new file mode 100644 index 00000000000..0c516a6bc8d --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h @@ -0,0 +1,56 @@ +#pragma once + +#ifdef USE_ESP32 +#include +#endif + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" + +#include + +namespace esphome::i2s_clockless_led_strip { + +class I2SClocklessLedStrip final : public light::AddressableLight { + public: + I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors); + + void dump_config() override; + + float get_setup_priority() const override; + void setup() override; + + light::LightTraits get_traits() override; + + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override; + void write_state(light::LightState *state) override; + + protected: + static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + bool allocate_buffers_(); + light::ESPColorView get_view_internal(int32_t index) const override; + + const size_t color_data_bytes_; + uint8_t *effect_data_{nullptr}; + uint8_t *color_data_{nullptr}; + uint8_t *i2s_data_{nullptr}; + size_t i2s_data_sent_{0}; + i2s_chan_handle_t tx_handle_{}; + const light::ChannelColors channel_colors_; + const uint16_t num_leds_; + const uint8_t pin_; + std::atomic i2s_data_ready_{false}; +}; + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/light.py b/esphome/components/i2s_clockless_led_strip/light.py new file mode 100644 index 00000000000..90a4f1c3540 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/light.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, light +from esphome.components.const import CONF_CHANNEL_COLORS +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID, CONF_PIN +from esphome.core import CORE +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@j9brown"] +DEPENDENCIES = ["esp32"] + +i2s_clockless_led_strip_ns = cg.esphome_ns.namespace("i2s_clockless_led_strip") +I2SClocklessLedStrip = i2s_clockless_led_strip_ns.class_( + "I2SClocklessLedStrip", light.AddressableLight +) + +# These variants define SOC_I2S_SUPPORTS_TDM +I2S_TDM_PORTS = { + esp32.VARIANT_ESP32C3: 1, + esp32.VARIANT_ESP32C5: 1, + esp32.VARIANT_ESP32C6: 1, + esp32.VARIANT_ESP32C61: 1, + esp32.VARIANT_ESP32H2: 1, + esp32.VARIANT_ESP32H4: 1, + esp32.VARIANT_ESP32P4: 3, + esp32.VARIANT_ESP32S3: 2, +} + +CONFIG_SCHEMA = cv.All( + esp32.only_on_variant( + supported=list(I2S_TDM_PORTS.keys()), + msg_prefix="I2S Clockless LED Strip", + ), + light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(I2SClocklessLedStrip), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, + cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors, + } + ).extend(cv.COMPONENT_SCHEMA), +) + + +@dataclass +class I2SClocklessLedStripData: + num_ports: int = 0 + + +def _get_data() -> I2SClocklessLedStripData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = I2SClocklessLedStripData() + return CORE.data[DOMAIN] + + +def _final_validate(_: ConfigType) -> None: + _get_data().num_ports += 1 + variant = esp32.get_esp32_variant() + num_ports_available = I2S_TDM_PORTS.get(variant, 0) + if _get_data().num_ports > num_ports_available: + raise cv.Invalid( + f"Only {num_ports_available} I2S TDM ports are supported on {variant}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + esp32.include_builtin_idf_component("esp_driver_i2s") + esp32.add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_PIN], + config[CONF_NUM_LEDS], + light.channel_colors_struct(config[CONF_CHANNEL_COLORS]), + ) + + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..7331691f4db --- /dev/null +++ b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +light: + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip1 + pin: GPIO1 + num_leds: 100 + channel_colors: GRB + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip2 + pin: GPIO2 + num_leds: 5 + channel_colors: RWGB From d7dd25a1bc1700b5d5c8c875be00f3c8896b3183 Mon Sep 17 00:00:00 2001 From: Ivan Novak <37712440+NovakIrs@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:57:16 +0200 Subject: [PATCH 400/435] [ld2410s] Add HLK-LD2410S presence radar component (#10452) Co-authored-by: NovakI Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ld2410s/__init__.py | 40 +++ esphome/components/ld2410s/binary_sensor.py | 23 ++ esphome/components/ld2410s/ld2410s.cpp | 314 +++++++++++++++++ esphome/components/ld2410s/ld2410s.h | 62 ++++ tests/components/ld2410s/common.yaml | 7 + tests/components/ld2410s/test.esp32-idf.yaml | 3 + .../components/ld2410s/test.esp8266-ard.yaml | 3 + tests/components/ld2410s/test.rp2040-ard.yaml | 3 + .../fixtures/uart_mock_ld2410s.yaml | 84 +++++ .../fixtures/uart_mock_ld2410s_link_lost.yaml | 75 ++++ .../fixtures/uart_mock_ld2410s_recovery.yaml | 100 ++++++ tests/integration/test_uart_mock_ld2410s.py | 329 ++++++++++++++++++ 13 files changed, 1044 insertions(+) create mode 100644 esphome/components/ld2410s/__init__.py create mode 100644 esphome/components/ld2410s/binary_sensor.py create mode 100644 esphome/components/ld2410s/ld2410s.cpp create mode 100644 esphome/components/ld2410s/ld2410s.h create mode 100644 tests/components/ld2410s/common.yaml create mode 100644 tests/components/ld2410s/test.esp32-idf.yaml create mode 100644 tests/components/ld2410s/test.esp8266-ard.yaml create mode 100644 tests/components/ld2410s/test.rp2040-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml create mode 100644 tests/integration/test_uart_mock_ld2410s.py diff --git a/CODEOWNERS b/CODEOWNERS index ec2bddd5489..2b77a16db89 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -306,6 +306,7 @@ esphome/components/kuntze/* @ssieb esphome/components/lc709203f/* @ilikecake esphome/components/lcd_menu/* @numo68 esphome/components/ld2410/* @regevbr @sebcaps +esphome/components/ld2410s/* @NovakIrs esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py new file mode 100644 index 00000000000..1c39c7eab32 --- /dev/null +++ b/esphome/components/ld2410s/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@NovakIrs"] +DEPENDENCIES = ["uart"] +DOMAIN = "ld2410s" +MULTI_CONF = True + +CONF_LD2410S_ID = "ld2410s_id" + +ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) +LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2410SComponent), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + baud_rate=115200, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2410s/binary_sensor.py b/esphome/components/ld2410s/binary_sensor.py new file mode 100644 index 00000000000..83e0a36cbac --- /dev/null +++ b/esphome/components/ld2410s/binary_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +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.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + icon=ICON_ACCOUNT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp new file mode 100644 index 00000000000..6462236ac93 --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -0,0 +1,314 @@ +#include "ld2410s.h" +#include +#include +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ld2410s { + +ESPHOME_LOG_TAG(TAG, "ld2410s"); + +static constexpr uint8_t SHORT_DATA_FRAME_HEADER = 0x6E; +static constexpr uint8_t SHORT_DATA_FRAME_FOOTER[] PROGMEM = {0x62}; +static constexpr uint8_t SHORT_DATA_FRAME_SIZE = 5; // header, state, distance low, distance high, footer +static constexpr uint8_t STD_DATA_FRAME_HEADER[] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t STD_DATA_FRAME_FOOTER[] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; +static constexpr uint8_t CMD_FRAME_HEADER[] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t LONG_HEADER_SIZE = 4; +static constexpr uint8_t LENGTH_FIELD_SIZE = 2; +static constexpr uint8_t LONG_PAYLOAD_POS = LONG_HEADER_SIZE + LENGTH_FIELD_SIZE; + +static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word of an acknowledgement +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 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); + +// 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}; +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; +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 +static constexpr uint32_t LINK_TIMEOUT_MS = 5000; // the module streams frames continuously; silence means it is gone + +static constexpr uint8_t DATA_TYPE_STANDARD = 0x01; + +void LD2410SComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LD2410S:"); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); +#endif +} + +void LD2410SComponent::loop() { + uint8_t chunk[16]; + // Take the count once so one pass handles a bounded number of bytes + size_t avail = this->available(); + while (avail > 0) { + const size_t len = std::min(avail, sizeof(chunk)); + if (!this->read_array(chunk, len)) { + ESP_LOGV(TAG, "UART read of %u bytes failed", static_cast(len)); + break; + } + for (size_t i = 0; i < len; i++) { + this->receive_byte_(chunk[i]); + } + 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) { + // 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; + } +} + +void LD2410SComponent::run_init_sequence_(uint32_t now) { + if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { + return; + } + const uint16_t command = init_command(this->init_step_); + if (this->awaiting_ack_) { + if (++this->init_timeouts_ > INIT_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; + return; + } + ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); + } + this->send_command_(command); + this->awaiting_ack_ = true; + this->next_send_at_ = now + ACK_TIMEOUT_MS; +} + +void LD2410SComponent::send_command_(uint16_t command) { + uint8_t frame[CMD_FRAME_MAX_SIZE]; + progmem_memcpy(frame, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + uint8_t pos = LONG_PAYLOAD_POS; + frame[pos++] = command & 0xFF; + frame[pos++] = command >> 8; + switch (command) { + case OUTPUT_MODE_SWITCH_CMD: + memset(&frame[pos], 0, OUTPUT_MODE_MINIMAL_SIZE); + pos += OUTPUT_MODE_MINIMAL_SIZE; + break; + case CONFIG_MODE_START_CMD: + frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; + frame[pos++] = CONFIG_MODE_START_VALUE >> 8; + break; + default: + break; + } + frame[LONG_HEADER_SIZE] = pos - LONG_PAYLOAD_POS; // payload length, little-endian + frame[LONG_HEADER_SIZE + 1] = 0; + progmem_memcpy(&frame[pos], CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)); + pos += sizeof(CMD_FRAME_FOOTER); + this->write_array(frame, pos); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(CMD_FRAME_MAX_SIZE)]; + ESP_LOGVV(TAG, "> %s", format_hex_pretty_to(hex_buf, frame, pos, ' ')); +#endif +} + +void LD2410SComponent::receive_byte_(uint8_t byte) { + if (this->rx_len_ >= RX_BUFFER_SIZE) { + this->reset_frame_(); + } + this->rx_buffer_[this->rx_len_++] = byte; + + if (this->frame_type_ == FrameType::NONE) { + if (this->rx_len_ == 1 && byte == SHORT_DATA_FRAME_HEADER) { + this->frame_type_ = FrameType::SHORT_DATA; + this->expected_len_ = SHORT_DATA_FRAME_SIZE; + return; + } + // The two long headers are told apart once all four bytes are in + uint8_t std_header[LONG_HEADER_SIZE]; + uint8_t cmd_header[LONG_HEADER_SIZE]; + progmem_memcpy(std_header, STD_DATA_FRAME_HEADER, LONG_HEADER_SIZE); + progmem_memcpy(cmd_header, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + const bool std_prefix = memcmp(this->rx_buffer_, std_header, this->rx_len_) == 0; + const bool cmd_prefix = memcmp(this->rx_buffer_, cmd_header, this->rx_len_) == 0; + if (std_prefix || cmd_prefix) { + if (this->rx_len_ == LONG_HEADER_SIZE) { + this->frame_type_ = std_prefix ? FrameType::STD_DATA : FrameType::COMMAND; + } + return; + } + // Not a header: drop what was collected, and try this byte as the start of the next frame + const bool retry = this->rx_len_ > 1; + this->reset_frame_(); + if (retry) { + this->receive_byte_(byte); + } + return; + } + + if (this->expected_len_ == 0) { + if (this->rx_len_ < LONG_PAYLOAD_POS) { + return; + } + // Standard data and command frames carry a little-endian payload length after the header + const uint16_t payload_len = + encode_uint16(this->rx_buffer_[LONG_HEADER_SIZE + 1], this->rx_buffer_[LONG_HEADER_SIZE]); + if (payload_len > RX_BUFFER_SIZE - LONG_PAYLOAD_POS - sizeof(CMD_FRAME_FOOTER)) { + ESP_LOGV(TAG, "Dropping frame with a %u byte payload, larger than the receive buffer", payload_len); + this->count_dropped_frame_(); + this->reset_frame_(); + return; + } + this->expected_len_ = LONG_PAYLOAD_POS + payload_len + sizeof(CMD_FRAME_FOOTER); + return; + } + + if (this->rx_len_ < this->expected_len_) { + return; + } + if (this->handle_frame_() || this->rescanning_) { + this->reset_frame_(); + return; + } + // A truncated long frame swallowed the bytes that followed it; try them again as new frames, once + uint8_t rest[RX_BUFFER_SIZE]; + const uint8_t rest_len = this->rx_len_ - 1; + memcpy(rest, &this->rx_buffer_[1], rest_len); + this->reset_frame_(); + this->rescanning_ = true; + for (uint8_t i = 0; i < rest_len; i++) { + this->receive_byte_(rest[i]); + } + this->rescanning_ = false; +} + +bool LD2410SComponent::handle_frame_() { + const uint8_t *footer_src; + uint8_t footer_size = sizeof(CMD_FRAME_FOOTER); + uint8_t payload_pos = LONG_PAYLOAD_POS; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + footer_src = SHORT_DATA_FRAME_FOOTER; + footer_size = sizeof(SHORT_DATA_FRAME_FOOTER); + payload_pos = 1; + break; + case FrameType::STD_DATA: + footer_src = STD_DATA_FRAME_FOOTER; + break; + default: + footer_src = CMD_FRAME_FOOTER; + break; + } + uint8_t footer[sizeof(CMD_FRAME_FOOTER)]; + progmem_memcpy(footer, footer_src, footer_size); + if (memcmp(&this->rx_buffer_[this->rx_len_ - footer_size], footer, footer_size) != 0) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(RX_BUFFER_SIZE)]; + ESP_LOGV(TAG, "Footer does not match header: %s", + format_hex_pretty_to(hex_buf, this->rx_buffer_, this->rx_len_, ' ')); +#endif + this->count_dropped_frame_(); + return false; + } + const uint8_t *payload = &this->rx_buffer_[payload_pos]; + const uint16_t payload_len = this->rx_len_ - payload_pos - footer_size; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + case FrameType::STD_DATA: + if (!this->handle_data_frame_(payload, payload_len)) { + this->count_dropped_frame_(); + break; + } + this->last_frame_at_ = App.get_loop_component_start_time(); + this->dropped_frames_ = 0; + break; + default: + this->handle_command_ack_(payload, payload_len); + break; + } + return true; +} + +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]); + } + return true; + } + // 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); + return true; +} + +void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) { + if (len < 4) { + ESP_LOGV(TAG, "Acknowledgement too short (%u bytes)", len); + return; + } + 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)) { + 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); + return; + } + 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) { + ESP_LOGD(TAG, "Setup done"); + this->last_frame_at_ = App.get_loop_component_start_time(); + this->status_clear_warning(); + } +} + +void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_BINARY_SENSOR + if (this->target_binary_sensor_ != nullptr) { + this->target_binary_sensor_->publish_state(target); + } +#endif +} + +} // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h new file mode 100644 index 00000000000..4af809836ce --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::ld2410s { + +// Largest frame read here is the standard data frame: 4 header + 2 length + payload + 4 footer +static constexpr size_t RX_BUFFER_SIZE = 96; +static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); + +class LD2410SComponent final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(target) +#endif + + public: + void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence + void loop() override; + void dump_config() override; + + protected: + enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; + + void receive_byte_(uint8_t byte); + /// Returns false when the footer does not match, so the bytes can be scanned again. + bool handle_frame_(); + /// 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); + void count_dropped_frame_() { + if (this->dropped_frames_ < UINT8_MAX) { + this->dropped_frames_++; + } + } + void reset_frame_() { + this->rx_len_ = 0; + this->expected_len_ = 0; + this->frame_type_ = FrameType::NONE; + } + void run_init_sequence_(uint32_t now); + void send_command_(uint16_t command); + void publish_target_(bool target); + + uint32_t next_send_at_{0}; + uint32_t last_frame_at_{0}; + 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 dropped_frames_{0}; // since the last accepted data frame, saturates at 255 + FrameType frame_type_{FrameType::NONE}; + bool awaiting_ack_{false}; + bool rescanning_{false}; +}; + +} // namespace esphome::ld2410s diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml new file mode 100644 index 00000000000..33b213d363c --- /dev/null +++ b/tests/components/ld2410s/common.yaml @@ -0,0 +1,7 @@ +ld2410s: + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + has_target: + name: Presence diff --git a/tests/components/ld2410s/test.esp32-idf.yaml b/tests/components/ld2410s/test.esp32-idf.yaml new file mode 100644 index 00000000000..a82d5fd3431 --- /dev/null +++ b/tests/components/ld2410s/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.esp8266-ard.yaml b/tests/components/ld2410s/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7346cb4670e --- /dev/null +++ b/tests/components/ld2410s/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.rp2040-ard.yaml b/tests/components/ld2410s/test.rp2040-ard.yaml new file mode 100644 index 00000000000..c3969bdf0df --- /dev/null +++ b/tests/components/ld2410s/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml new file mode 100644 index 00000000000..113e0881e6f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -0,0 +1,84 @@ +esphome: + name: uart-mock-ld2410s-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + 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] + # 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] + injections: + # Short frame, state 2 (target), distance 300 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, state 2, distance 300 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5] + # Short frame, state 1 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x01, 0x00, 0x00, 0x62] + # Noise byte directly followed by a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0xAA, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Partial standard header byte directly followed by a short frame, state 3 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0x6E, 0x03, 0x2C, 0x01, 0x62] + # Short frame with the impossible state 7, dropped, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x07, 0x00, 0x00, 0x62, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, with the impossible state 7, dropped, then a short frame, state 2 + # -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x07, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, 0x6E, + 0x02, 0x2C, 0x01, 0x62, + ] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml new file mode 100644 index 00000000000..5f06985c7b5 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -0,0 +1,75 @@ +esphome: + name: uart-mock-ld2410s-link-lost + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + 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] + # 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] + injections: + # Short frames every second for 7 s, longer than the 5 s link timeout, so they must keep the link alive + # t=1 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=2 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=3 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=4 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=5 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=6 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=7 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Then silence: the link watchdog must start the init sequence over + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml new file mode 100644 index 00000000000..3502ccb85ca --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml @@ -0,0 +1,100 @@ +esphome: + name: uart-mock-ld2410s-recovery + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# No responses: the init commands go unanswered, so the test also covers the resend and give-up path; +# this does not affect receiving data frames +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + injections: + # Short frame, state 2 -> presence on (baseline) + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Standard header with a 255 byte length field (over the 96 byte buffer), three payload bytes, + # then a short frame, state 0 -> frame dropped, presence off + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame declaring 5 payload bytes but carrying 3, then a short frame, state 2: the short + # frame is read as the rest of the declared length, so the frame stays one byte short + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0: its first byte completes the truncated frame (footer mismatch); the + # rescan of the swallowed bytes rescues the state 2 short frame -> presence on, and leaves this + # frame's header pending, so the rest of it completes -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Command frame with zeros where the footer belongs, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6E, 0x00, + 0x00, 0x00, 0x62, + ] + # 100 bytes that never start a header, then a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: + [ + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x6E, 0x02, 0x2C, 0x01, 0x62, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame of the largest size that fits (86 byte payload: type 1, state 2, distance 300, + # 82 padding bytes) -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x56, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py new file mode 100644 index 00000000000..8cd9b4b8dd1 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -0,0 +1,329 @@ +"""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 + 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 + does not hide that frame + 4. A short frame and a standard frame whose state byte is above 3 are + dropped without publishing, and the short frame after each still publishes + +test_uart_mock_ld2410s_recovery (the mock never answers the init commands): + 1. Config-mode start is resent after the acknowledgement timeout + 2. Once the retries run out, config-mode end is sent and the component warns + that it is starting over + 3. A standard frame whose length field exceeds the receive buffer is dropped + 4. A truncated standard frame swallows the bytes after it up to its declared + length and fails the footer check; the swallowed bytes are rescanned, so + the short frames hidden in them still publish + 5. A command frame with a bad footer is dropped + 6. 100 bytes that never start a header are skipped + 7. A standard frame of the largest size that fits (86 byte payload) is accepted + +test_uart_mock_ld2410s_link_lost: + 1. Short frames arriving for longer than the 5 s link timeout keep the link up + 2. 5 s of silence logs "No data for 5 s (0 frames dropped), starting over", sets the warning flag + and sends a new config-mode start +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import APIClient, ButtonInfo +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" +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" + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + +# has_target after each injection: short 2, short 0, standard frame state 2, +# short 1, noise + short 2, short 0, partial header + short 3, +# short 7 (dropped) + short 0, standard frame state 7 (dropped) + short 2 +EXPECTED_PRESENCE = [True, False, True, False, True, False, True, False, True] +# The short frame and the standard frame with state 7 are logged as dropped +EXPECTED_DROPPED_STATES = ["7", "7"] + +# has_target after each recovery phase (see uart_mock_ld2410s_recovery.yaml). +# The truncated frame swallows the state 2 short frame after it plus the header +# byte of the following state 0 short frame; the footer mismatch rescans those +# bytes, which publishes the swallowed frame and resumes the next one. +EXPECTED_RECOVERY_PRESENCE = [ + True, # baseline short frame + False, # short frame after the dropped oversized length field + True, # rescued: short frame swallowed by the truncated standard frame + False, # short frame whose header byte completed the truncated frame + True, # short frame + False, # short frame after the command frame with a bad footer + True, # short frame after 100 junk bytes + False, # short frame + True, # maximum-size standard frame + False, # short frame +] +# The truncated frame takes the next 6 bytes to reach its declared length +TRUNCATED_FRAME_HEX = "F4 F3 F2 F1 05 00 01 02 2C 6E 02 2C 01 62 6E" +BAD_COMMAND_FRAME_HEX = "FD FC FB FA 04 00 FE 01 00 00 00 00 00 00" + + +def _tx_frame(line: str) -> str | None: + """Return the hex payload of a uart_mock TX log line, or None.""" + if "uart_mock" not in line or "TX " not in line or "bytes: " not in line: + return None + return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip() + + +async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None: + """Subscribe the collector past the initial states, then start the mock scenario.""" + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(collector.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + +async def _wait_for_presence( + collector: SensorStateCollector, waiter: asyncio.Future[bool] +) -> None: + try: + await asyncio.wait_for(waiter, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the LD2410S init sequence, presence parsing and receiver resync.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + setup_done = loop.create_future() + warning_cleared = loop.create_future() + dropped_states: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + 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: + dropped_states.append( + ANSI_ESCAPE.sub("", line.split("state ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(asyncio.gather(setup_done, warning_cleared), 5.0) + 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] + + await _start_scenario(client, collector) + 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 + # Data frames must not restart the init sequence + assert len(tx_frames) == 3, tx_frames + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the unanswered-command retry, and that bad frames do not stop later ones.""" + loop = asyncio.get_running_loop() + tx_frames: list[tuple[float, str]] = [] + resent = loop.create_future() + gave_up = loop.create_future() + oversized_dropped = loop.create_future() + footer_mismatches: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append((loop.time(), frame)) + if "No acknowledgement for command 00FF, resending" in line and not ( + resent.done() + ): + resent.set_result(True) + if ( + "No acknowledgement for command 00FF, starting over in 15 s" in line + and not gave_up.done() + ): + gave_up.set_result(True) + if ( + "Dropping frame with a 255 byte payload, larger than the receive buffer" + in line + and not oversized_dropped.done() + ): + oversized_dropped.set_result(True) + if "Footer does not match header: " in line: + footer_mismatches.append( + ANSI_ESCAPE.sub("", line.split("header: ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) + >= len(EXPECTED_RECOVERY_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await _start_scenario(client, collector) + await _wait_for_presence(collector, all_presence) + try: + await asyncio.wait_for(asyncio.gather(resent, gave_up), 5.0) + except TimeoutError: + pytest.fail(f"No resend or give-up warning. TX frames: {tx_frames}") + # Let any unexpected extra update arrive before comparing + await asyncio.sleep(0.3) + + # Recovery shows in the published states alone; the footer log below is + # only a cross-check that needs the fixture's VERBOSE logger level + assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE + assert oversized_dropped.done(), "Oversized length field was not dropped" + assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX] + + frames = [frame for _, frame in tx_frames] + # First send plus INIT_MAX_TIMEOUTS (2) resends, then config-mode end; + # the restart after the 15 s pause is out of the test's time window + assert frames == [CONFIG_START_TX] * 3 + [CONFIG_END_TX] + # Resends follow the 300 ms acknowledgement timeout + gaps = [b[0] - a[0] for a, b in zip(tx_frames, tx_frames[1:], strict=False)] + assert all(0.2 <= gap <= 1.0 for gap in gaps), gaps + + +# has_target after each short frame in uart_mock_ld2410s_link_lost.yaml +EXPECTED_LINK_PRESENCE = [True, False, True, False, True, False, True] +LINK_TIMEOUT_S = 5.0 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_link_lost( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that short frames keep the link alive and silence restarts the init.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + rx_injected_at: list[float] = [] + setup_done = loop.create_future() + link_lost = loop.create_future() + warning_set_after_loss = loop.create_future() + restarted = loop.create_future() + config_start_before_loss = 0 + + def line_callback(line: str) -> None: + nonlocal config_start_before_loss + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if frame == CONFIG_START_TX and link_lost.done() and not restarted.done(): + restarted.set_result(True) + if "uart_mock" in line and "Injecting " in line and "(injection " in line: + rx_injected_at.append(loop.time()) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + if ( + "No data for 5 s (0 frames dropped), starting over" in line + and not link_lost.done() + ): + config_start_before_loss = tx_frames.count(CONFIG_START_TX) + link_lost.set_result(loop.time()) + if ( + link_lost.done() + and "ld2410s set Warning flag" in line + and not warning_set_after_loss.done() + ): + warning_set_after_loss.set_result(True) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) >= len(EXPECTED_LINK_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(setup_done, 5.0) + except TimeoutError: + pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") + + await _start_scenario(client, collector) + try: + await asyncio.wait_for(all_presence, timeout=10.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + assert collector.binary_states["has_target"] == EXPECTED_LINK_PRESENCE + + try: + lost_at = await asyncio.wait_for(link_lost, timeout=7.0) + await asyncio.wait_for( + asyncio.gather(warning_set_after_loss, restarted), timeout=1.0 + ) + except TimeoutError: + pytest.fail( + f"No link-lost warning, warning flag or restart. TX frames: {tx_frames}" + ) + + # The stream ran past the link timeout, so only the silence after it counts + silence = lost_at - rx_injected_at[-1] + assert silence >= LINK_TIMEOUT_S - 0.5, ( + f"Link declared lost {silence:.2f} s after the last frame" + ) + + # The init sequence starts over with a new config-mode start + assert config_start_before_loss == 1, tx_frames + assert tx_frames.count(CONFIG_START_TX) == 2, tx_frames From b88eafcd00070de1c894b764430f21f3a860743a Mon Sep 17 00:00:00 2001 From: Ryan Gammon Date: Mon, 12 Oct 2026 15:05:34 -0700 Subject: [PATCH 401/435] [bq27220] Add BQ27220 battery fuel gauge component (#17702) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq27220/__init__.py | 2 + esphome/components/bq27220/bq27220.cpp | 93 ++++++++++++++ esphome/components/bq27220/bq27220.h | 44 +++++++ esphome/components/bq27220/sensor.py | 123 +++++++++++++++++++ tests/components/bq27220/common.yaml | 19 +++ tests/components/bq27220/test.esp32-idf.yaml | 3 + 7 files changed, 285 insertions(+) create mode 100644 esphome/components/bq27220/__init__.py create mode 100644 esphome/components/bq27220/bq27220.cpp create mode 100644 esphome/components/bq27220/bq27220.h create mode 100644 esphome/components/bq27220/sensor.py create mode 100644 tests/components/bq27220/common.yaml create mode 100644 tests/components/bq27220/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2b77a16db89..87a89a14f8a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -101,6 +101,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core diff --git a/esphome/components/bq27220/__init__.py b/esphome/components/bq27220/__init__.py new file mode 100644 index 00000000000..2a5d7bc1d7a --- /dev/null +++ b/esphome/components/bq27220/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@rggammon"] +DOMAIN = "bq27220" diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp new file mode 100644 index 00000000000..d2f7961e020 --- /dev/null +++ b/esphome/components/bq27220/bq27220.cpp @@ -0,0 +1,93 @@ +#include "bq27220.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bq27220 { + +ESPHOME_LOG_TAG(TAG, "bq27220"); + +// Standard command registers (16-bit, little-endian), SLUUBD4A +static constexpr uint8_t REG_CONTROL = 0x00; +static constexpr uint8_t REG_TEMPERATURE = 0x06; // 0.1 K +static constexpr uint8_t REG_VOLTAGE = 0x08; // mV +static constexpr uint8_t REG_CURRENT = 0x0C; // mA, signed +static constexpr uint8_t REG_REMAINING_CAPACITY = 0x10; // mAh +static constexpr uint8_t REG_FULL_CHARGE_CAPACITY = 0x12; // mAh +static constexpr uint8_t REG_TIME_TO_EMPTY = 0x16; // min, 0xFFFF when not discharging +static constexpr uint8_t REG_STATE_OF_CHARGE = 0x2C; // % +static constexpr uint8_t REG_STATE_OF_HEALTH = 0x2E; // % +// The DEVICE_NUMBER subcommand (0x0001, SLUUBD4A §2.2.2) answers through the MAC data buffer +static constexpr uint8_t REG_MAC_DATA = 0x40; +static constexpr uint16_t DEVICE_NUMBER = 0x0220; +static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF; + +bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) { + uint8_t data[2]; + if (this->read_register(reg, data, 2) != i2c::ERROR_OK) { + return false; + } + value = encode_uint16(data[1], data[0]); + return true; +} + +void BQ27220Component::setup() { + const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian + if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to communicate with BQ27220")); + return; + } + delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read + uint16_t device_number = 0; + if (!this->read_word_(REG_MAC_DATA, device_number)) { + this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); + return; + } + if (device_number != DEVICE_NUMBER) { + ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER); + this->mark_failed(); + } +} + +void BQ27220Component::update() { + bool ok = true; + this->publish_(this->voltage_sensor_, REG_VOLTAGE, ok, [](uint16_t raw) { return raw * 0.001f; }); + this->publish_(this->current_sensor_, REG_CURRENT, ok, + [](uint16_t raw) { return static_cast(raw) * 0.001f; }); + this->publish_(this->battery_level_sensor_, REG_STATE_OF_CHARGE, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->temperature_sensor_, REG_TEMPERATURE, ok, [](uint16_t raw) { return raw * 0.1f - 273.15f; }); + this->publish_(this->remaining_capacity_sensor_, REG_REMAINING_CAPACITY, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->full_charge_capacity_sensor_, REG_FULL_CHARGE_CAPACITY, ok, + [](uint16_t raw) { return float(raw); }); + this->publish_(this->time_to_empty_sensor_, REG_TIME_TO_EMPTY, ok, + [](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); }); + this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); }); + + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220")); + } +} + +void BQ27220Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ27220 Battery Fuel Gauge:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Remaining Capacity", this->remaining_capacity_sensor_); + LOG_SENSOR(" ", "Full Charge Capacity", this->full_charge_capacity_sensor_); + LOG_SENSOR(" ", "Time To Empty", this->time_to_empty_sensor_); + LOG_SENSOR(" ", "State Of Health", this->state_of_health_sensor_); +} + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h new file mode 100644 index 00000000000..d88b76a20f0 --- /dev/null +++ b/esphome/components/bq27220/bq27220.h @@ -0,0 +1,44 @@ +#pragma once + +// Datasheet: https://www.ti.com/lit/ds/symlink/bq27220.pdf +// Technical Reference Manual: https://www.ti.com/lit/ug/sluubd4a/sluubd4a.pdf + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bq27220 { + +class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(battery_level) + SUB_SENSOR(temperature) + SUB_SENSOR(remaining_capacity) + SUB_SENSOR(full_charge_capacity) + SUB_SENSOR(time_to_empty) + SUB_SENSOR(state_of_health) + + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool read_word_(uint8_t reg, uint16_t &value); + /// Read a standard command register and publish it through the sensor, if one is configured. + /// A failed read publishes NAN and clears `ok`. + template void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) { + if (sensor == nullptr) + return; + uint16_t raw; + if (!this->read_word_(reg, raw)) { + ok = false; + sensor->publish_state(NAN); + return; + } + sensor->publish_state(convert(raw)); + } +}; + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py new file mode 100644 index 00000000000..a3fcec22432 --- /dev/null +++ b/esphome/components/bq27220/sensor.py @@ -0,0 +1,123 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_MILLIAMPERE_HOUR +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CURRENT, + CONF_ID, + CONF_TEMPERATURE, + CONF_VOLTAGE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_BATTERY, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_MINUTE, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +bq27220_ns = cg.esphome_ns.namespace("bq27220") +BQ27220Component = bq27220_ns.class_( + "BQ27220Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REMAINING_CAPACITY = "remaining_capacity" +CONF_FULL_CHARGE_CAPACITY = "full_charge_capacity" +CONF_TIME_TO_EMPTY = "time_to_empty" +CONF_STATE_OF_HEALTH = "state_of_health" + +# Other SLUUBD4A standard commands are intentionally not exposed to keep the +# component focused; they can be added later if there is demand: TimeToFull +# (0x18), CycleCount (0x2A), BatteryStatus (0x0A), AveragePower (0x24), +# DesignCapacity (0x3C). + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ27220Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + 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, + ), + 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, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_REMAINING_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_FULL_CHARGE_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIME_TO_EMPTY): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_STATE_OF_HEALTH): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x55)) +) + + +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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_REMAINING_CAPACITY, var.set_remaining_capacity_sensor) + await sensors(CONF_FULL_CHARGE_CAPACITY, var.set_full_charge_capacity_sensor) + await sensors(CONF_TIME_TO_EMPTY, var.set_time_to_empty_sensor) + await sensors(CONF_STATE_OF_HEALTH, var.set_state_of_health_sensor) diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml new file mode 100644 index 00000000000..99d1e1f7f85 --- /dev/null +++ b/tests/components/bq27220/common.yaml @@ -0,0 +1,19 @@ +sensor: + - platform: bq27220 + id: bq27220_id + voltage: + name: "Battery Voltage" + current: + name: "Battery Current" + battery_level: + name: "Battery" + temperature: + name: "Battery Temperature" + remaining_capacity: + name: "Battery Remaining" + full_charge_capacity: + name: "Battery Full Capacity" + time_to_empty: + name: "Time to Empty" + state_of_health: + name: "Battery Health" diff --git a/tests/components/bq27220/test.esp32-idf.yaml b/tests/components/bq27220/test.esp32-idf.yaml new file mode 100644 index 00000000000..fe4dd41cef8 --- /dev/null +++ b/tests/components/bq27220/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq27220: !include common.yaml From 3ddbf8b3efd588601ea3eda75f5b302c15013270 Mon Sep 17 00:00:00 2001 From: Dennis Gaida <2392217+DennisGaida@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:12:11 +0200 Subject: [PATCH 402/435] [lp5562] Add LP5562 4-channel I2C LED driver output component (#18453) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/lp5562/__init__.py | 29 ++++++++ esphome/components/lp5562/lp5562_output.cpp | 72 +++++++++++++++++++ esphome/components/lp5562/lp5562_output.h | 35 +++++++++ esphome/components/lp5562/output.py | 35 +++++++++ tests/components/lp5562/common.yaml | 21 ++++++ tests/components/lp5562/test.esp32-idf.yaml | 4 ++ tests/components/lp5562/test.esp8266-ard.yaml | 4 ++ tests/components/lp5562/test.rp2040-ard.yaml | 4 ++ 9 files changed, 205 insertions(+) create mode 100644 esphome/components/lp5562/__init__.py create mode 100644 esphome/components/lp5562/lp5562_output.cpp create mode 100644 esphome/components/lp5562/lp5562_output.h create mode 100644 esphome/components/lp5562/output.py create mode 100644 tests/components/lp5562/common.yaml create mode 100644 tests/components/lp5562/test.esp32-idf.yaml create mode 100644 tests/components/lp5562/test.esp8266-ard.yaml create mode 100644 tests/components/lp5562/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 87a89a14f8a..09fdec59a0c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,6 +331,7 @@ esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow +esphome/components/lp5562/* @DennisGaida esphome/components/lps22/* @nagisa esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py new file mode 100644 index 00000000000..b8ace97fc9d --- /dev/null +++ b/esphome/components/lp5562/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@DennisGaida"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lp5562" +MULTI_CONF = True + +lp5562_ns = cg.esphome_ns.namespace("lp5562") +LP5562Output = lp5562_ns.class_("LP5562Output", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LP5562Output), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +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) diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp new file mode 100644 index 00000000000..356ef2c0669 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -0,0 +1,72 @@ +#include "lp5562_output.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lp5562 { + +ESPHOME_LOG_TAG(TAG, "lp5562"); + +// see table 26 in the datasheet https://web.archive.org/web/20240327023921/https://www.ti.com/lit/ds/symlink/lp5562.pdf +static constexpr uint8_t LP5562_REG_ENABLE = 0x00; +static constexpr uint8_t LP5562_REG_CONFIG = 0x08; +static constexpr uint8_t LP5562_REG_RESET = 0x0d; +static constexpr uint8_t LP5562_REG_LED_MAP = 0x70; + +static constexpr uint8_t LP5562_RESET_VALUE = 0xff; + +static constexpr uint8_t LP5562_ENABLE_CHIP_EN = 0x40; +static constexpr uint8_t LP5562_CONFIG_INT_CLK_EN = 0x01; +static constexpr uint8_t LP5562_LED_MAP_ALL_PWM_REGISTERS = 0x00; // every channel driven by its PWM register + +// PWM duty-cycle registers, indexed the same way as the LP5562_CHANNELS enum in output.py +static const uint8_t LP5562_REG_PWM[4] PROGMEM = { + 0x02, // blue + 0x03, // green + 0x04, // red + 0x0e, // white +}; + +void LP5562Output::setup() { + // clear any state left over from before a warm reboot; the chip may not ACK while resetting, so ignore the result + this->write_byte(LP5562_REG_RESET, LP5562_RESET_VALUE); + delay(1); + // power up the chip and enable the internal 32 kHz oscillator, see + // https://github.com/m5stack/M5GFX/blob/b2422cdb4735159209c2097d425c0756505b9a81/src/M5GFX.cpp#L566 + if (!this->write_byte(LP5562_REG_ENABLE, LP5562_ENABLE_CHIP_EN)) { + this->mark_failed(); + return; + } + delay(1); + // use the internal clock, and drive every channel directly from its PWM register instead of the + // internal blink/fade engines + if (!this->write_byte(LP5562_REG_CONFIG, LP5562_CONFIG_INT_CLK_EN) || + !this->write_byte(LP5562_REG_LED_MAP, LP5562_LED_MAP_ALL_PWM_REGISTERS)) { + this->mark_failed(); + } +} + +void LP5562Output::dump_config() { + ESP_LOGCONFIG(TAG, "LP5562:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, "Setting up LP5562 failed!"); + } +} + +void LP5562Output::set_channel_value_(uint8_t channel, uint8_t value) { + if (this->is_failed()) + return; + if (!this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); +} + +void LP5562Channel::write_state(float state) { + const uint8_t max_duty = 255; + const auto duty = static_cast(roundf(state * max_duty)); + this->parent_->set_channel_value_(this->channel_, duty); +} + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/lp5562_output.h b/esphome/components/lp5562/lp5562_output.h new file mode 100644 index 00000000000..a67a4b0658b --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::lp5562 { + +class LP5562Output; + +class LP5562Channel final : public output::FloatOutput, public Parented { + public: + void set_channel(uint8_t channel) { this->channel_ = channel; } + + protected: + void write_state(float state) override; + + uint8_t channel_{0}; +}; + +/// LP5562 4-channel (RGBW) I2C LED driver. +class LP5562Output final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + friend class LP5562Channel; + + void set_channel_value_(uint8_t channel, uint8_t value); +}; + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/output.py b/esphome/components/lp5562/output.py new file mode 100644 index 00000000000..4866eb7b9c9 --- /dev/null +++ b/esphome/components/lp5562/output.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType + +from . import LP5562Output, lp5562_ns + +DEPENDENCIES = ["lp5562"] + +LP5562Channel = lp5562_ns.class_("LP5562Channel", output.FloatOutput) +CONF_LP5562_ID = "lp5562_id" + +# Indices match the LP5562_REG_PWM lookup table in lp5562_output.cpp +LP5562_CHANNELS = { + "blue": 0, + "green": 1, + "red": 2, + "white": 3, +} + +CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(LP5562Channel), + cv.GenerateID(CONF_LP5562_ID): cv.use_id(LP5562Output), + cv.Required(CONF_CHANNEL): cv.enum(LP5562_CHANNELS, lower=True), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_LP5562_ID]) + cg.add(var.set_channel(config[CONF_CHANNEL])) + await output.register_output(var, config) diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml new file mode 100644 index 00000000000..c1608f1a3cf --- /dev/null +++ b/tests/components/lp5562/common.yaml @@ -0,0 +1,21 @@ +lp5562: + - address: 0x30 + id: lp5562_1 + +output: + - platform: lp5562 + id: lp5562_red + channel: red + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_green + channel: green + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_blue + channel: blue + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_white + channel: white + lp5562_id: lp5562_1 diff --git a/tests/components/lp5562/test.esp32-idf.yaml b/tests/components/lp5562/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/lp5562/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.esp8266-ard.yaml b/tests/components/lp5562/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/lp5562/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.rp2040-ard.yaml b/tests/components/lp5562/test.rp2040-ard.yaml new file mode 100644 index 00000000000..319a7c71a65 --- /dev/null +++ b/tests/components/lp5562/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From a68cf669aab3d4554a31b29ce25f0c5199e5f37a Mon Sep 17 00:00:00 2001 From: Zebble Date: Mon, 12 Oct 2026 18:39:11 -0400 Subject: [PATCH 403/435] [tca8418] Add TCA8418 keypad component (#18521) Co-authored-by: Claude Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tca8418/__init__.py | 156 ++++++++++++++++ .../tca8418/binary_sensor/__init__.py | 143 +++++++++++++++ .../binary_sensor/tca8418_binary_sensor.h | 26 +++ esphome/components/tca8418/tca8418.cpp | 170 ++++++++++++++++++ esphome/components/tca8418/tca8418.h | 85 +++++++++ esphome/core/defines.h | 1 + tests/component_tests/tca8418/__init__.py | 0 .../tca8418/test_final_validate.py | 156 ++++++++++++++++ .../component_tests/tca8418/test_validate.py | 142 +++++++++++++++ tests/components/tca8418/common.yaml | 36 ++++ tests/components/tca8418/test.esp32-idf.yaml | 6 + .../components/tca8418/test.esp8266-ard.yaml | 6 + 13 files changed, 928 insertions(+) create mode 100644 esphome/components/tca8418/__init__.py create mode 100644 esphome/components/tca8418/binary_sensor/__init__.py create mode 100644 esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h create mode 100644 esphome/components/tca8418/tca8418.cpp create mode 100644 esphome/components/tca8418/tca8418.h create mode 100644 tests/component_tests/tca8418/__init__.py create mode 100644 tests/component_tests/tca8418/test_final_validate.py create mode 100644 tests/component_tests/tca8418/test_validate.py create mode 100644 tests/components/tca8418/common.yaml create mode 100644 tests/components/tca8418/test.esp32-idf.yaml create mode 100644 tests/components/tca8418/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 09fdec59a0c..ea3cdaed294 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -581,6 +581,7 @@ esphome/components/t6615/* @tylermenezes esphome/components/tas2780/* @remcom esphome/components/tas58xx/* @mrtoy-me @remcom esphome/components/tc74/* @sethgirvan +esphome/components/tca8418/* @zebble esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py new file mode 100644 index 00000000000..67b38dc21ee --- /dev/null +++ b/esphome/components/tca8418/__init__.py @@ -0,0 +1,156 @@ +import logging + +from esphome import automation +import esphome.codegen as cg +from esphome.components import gpio_expander, i2c, key_provider +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c"] +DOMAIN = "tca8418" +AUTO_LOAD = ["key_provider", "gpio_expander"] +MULTI_CONF = True + +# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs +_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT") + + +async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None: + _request_listener_slot(str(keypad)) + cg.add(keypad.register_listener(var)) + + +CONF_GPI_EVENTS = "gpi_events" +CONF_KEYPAD_ID = "keypad_id" + +# Matrix keys are numbered row by row from 1 with ten columns per row; pins outside +# the matrix report as individual inputs 97..114 (ROW0..7, then COL0..9) +MATRIX_COLUMNS = 10 +MATRIX_KEY_MAX = 80 +GPI_KEY_MIN = 97 +GPI_KEY_MAX = 114 + + +def _matrix_key(row: int, col: int) -> int: + return row * MATRIX_COLUMNS + col + 1 + + +tca8418_ns = cg.esphome_ns.namespace("tca8418") +TCA8418Component = tca8418_ns.class_( + "TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice +) + + +def _ascii_string(value: str) -> str: + """A string of single byte characters, so it lines up with the matrix.""" + value = cv.string_strict(value) + if not value.isascii(): + raise cv.Invalid( + "Only ASCII characters can be used, since each key is one character" + ) + return value + + +def _validate(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS, 0) + columns = config.get(CONF_COLUMNS, 0) + if (keys := config.get(CONF_KEYS)) is not None: + if rows == 0: + raise cv.Invalid( + "'keys' maps the key matrix, so 'rows' and 'columns' are required", + path=[CONF_KEYS], + ) + if len(keys) != rows * columns: + raise cv.Invalid( + f"'keys' must have exactly {rows * columns} characters " + f"({rows} rows x {columns} columns), got {len(keys)}", + path=[CONF_KEYS], + ) + # Individual inputs report as 97..114, which are also the codes for the + # letters 'a' to 'r'. Both reach on_key and key collectors as a number. + if ( + keys is not None + and config[CONF_GPI_EVENTS] + and ( + clashing := sorted( + {k for k in keys if GPI_KEY_MIN <= ord(k) <= GPI_KEY_MAX} + ) + ) + ): + _LOGGER.warning( + "In '%s', the key(s) %s cannot be told apart from an individual input " + "on the '%s' and key collector paths, because both are reported as the " + "same number. Binary sensors are unaffected. Use other characters, or " + "set '%s: false', if this matters.", + CONF_KEYS, + ", ".join(f"'{k}'" for k in clashing), + CONF_ON_KEY, + CONF_GPI_EVENTS, + ) + if rows == 0 and not config[CONF_GPI_EVENTS]: + raise cv.Invalid( + "Nothing to report: set 'rows' and 'columns' for a key matrix, " + "or leave 'gpi_events' enabled to report individual inputs" + ) + return config + + +_MATRIX_MSG = ( + "'rows' and 'columns' must both be set to use the key matrix, " + "or both left unset to use every pin as an individual input" +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TCA8418Component), + # The key matrix occupies ROW0..ROW(rows-1) and COL0..COL(columns-1). + # Leave both unset to use every pin as an individual input instead. + cv.Inclusive(CONF_ROWS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=8 + ), + cv.Inclusive(CONF_COLUMNS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=10 + ), + cv.Optional(CONF_KEYS): _ascii_string, + # Report pins that are not part of the matrix as individual inputs. + cv.Optional(CONF_GPI_EVENTS, default=True): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, + cv.Optional(CONF_ON_KEY): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x34)), + _validate, +) + + +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) + + if (rows := config.get(CONF_ROWS)) is not None: + columns = config[CONF_COLUMNS] + cg.add(var.set_rows(rows)) + cg.add(var.set_columns(columns)) + if (keys := config.get(CONF_KEYS)) is not None: + # Indexed by key number - 1, with 0 where no key is assigned. + table = [0] * _matrix_key(rows - 1, columns - 1) + for index, char in enumerate(keys.encode()): + table[_matrix_key(*divmod(index, columns)) - 1] = char + array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table) + cg.add(var.set_keys(array, len(table))) + cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS])) + if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None: + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + for conf in config.get(CONF_ON_KEY, []): + await automation.build_callback_automation( + var, "add_on_key_callback", [(cg.uint8, "x")], conf + ) diff --git a/esphome/components/tca8418/binary_sensor/__init__.py b/esphome/components/tca8418/binary_sensor/__init__.py new file mode 100644 index 00000000000..3e1e766c579 --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/__init__.py @@ -0,0 +1,143 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_KEY, CONF_ROW +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import ( + CONF_GPI_EVENTS, + CONF_KEYPAD_ID, + GPI_KEY_MAX, + GPI_KEY_MIN, + MATRIX_COLUMNS, + MATRIX_KEY_MAX, + TCA8418Component, + _ascii_string, + _matrix_key, + register_listener, + tca8418_ns, +) + +DEPENDENCIES = ["tca8418"] + +CONF_KEY_CODE = "key_code" + +TCA8418BinarySensor = tca8418_ns.class_( + "TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff +) + + +def _key_code(value: int) -> int: + value = cv.int_(value) + if 1 <= value <= MATRIX_KEY_MAX or GPI_KEY_MIN <= value <= GPI_KEY_MAX: + return value + raise cv.Invalid( + f"Key numbers run from 1 to {MATRIX_KEY_MAX} for the key matrix, " + f"and {GPI_KEY_MIN} to {GPI_KEY_MAX} for individual inputs" + ) + + +_POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together" + +CONFIG_SCHEMA = cv.All( + binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend( + { + cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component), + cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=7 + ), + cv.Inclusive(CONF_COL, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=9 + ), + cv.Optional(CONF_KEY): cv.All( + cv.string, + _ascii_string, + cv.Length(min=1, max=1, msg="Key must be a single character"), + ), + cv.Optional(CONF_KEY_CODE): _key_code, + } + ), + cv.has_exactly_one_key(CONF_ROW, CONF_KEY, CONF_KEY_CODE), +) + + +def _final_validate(config: ConfigType) -> None: + """Resolve the key to the number the device reports, checked against the keypad's matrix.""" + full_config = fv.full_config.get() + keypad_path = full_config.get_path_for_id(config[CONF_KEYPAD_ID])[:-1] + keypad = full_config.get_config_for_path(keypad_path) + rows = keypad.get(CONF_ROWS, 0) + columns = keypad.get(CONF_COLUMNS, 0) + + if (row := config.get(CONF_ROW)) is not None: + col = config[CONF_COL] + if row >= rows or col >= columns: + raise cv.Invalid( + f"Position {row}/{col} is outside the {rows} x {columns} key " + "matrix of the keypad it belongs to", + path=[CONF_ROW], + ) + config[CONF_KEY_CODE] = _matrix_key(row, col) + return + + if (key_code := config.get(CONF_KEY_CODE)) is not None: + if key_code <= MATRIX_KEY_MAX: + row, col = divmod(key_code - 1, MATRIX_COLUMNS) + if row >= rows or col >= columns: + raise cv.Invalid( + f"Key number {key_code} is position {row}/{col}, which is " + f"outside the {rows} x {columns} key matrix of the keypad it " + "belongs to", + path=[CONF_KEY_CODE], + ) + return + if not keypad[CONF_GPI_EVENTS]: + raise cv.Invalid( + f"Key number {key_code} is an individual input, but the keypad has " + f"'{CONF_GPI_EVENTS}' turned off", + path=[CONF_KEY_CODE], + ) + # 97..104 are ROW0..ROW7 and 105..114 are COL0..COL9. A pin used by the + # matrix cannot also report on its own. + offset = key_code - GPI_KEY_MIN + if offset < 8: + in_matrix, pin = offset < rows, f"ROW{offset}" + else: + in_matrix, pin = offset - 8 < columns, f"COL{offset - 8}" + if in_matrix: + raise cv.Invalid( + f"Key number {key_code} is {pin}, which the key matrix is using, so " + "it cannot report as an individual input", + path=[CONF_KEY_CODE], + ) + return + + if (key := config.get(CONF_KEY)) is not None: + keys = keypad.get(CONF_KEYS) + if keys is None: + raise cv.Invalid( + f"'{CONF_KEY}' names a key from the keypad's 'keys', which is not set", + path=[CONF_KEY], + ) + if (index := keys.find(key)) < 0: + raise cv.Invalid( + f"'{key}' is not one of the keypad's keys", path=[CONF_KEY] + ) + if keys.count(key) > 1: + raise cv.Invalid( + f"'{key}' appears more than once in the keypad's keys, so it does " + f"not name one key. Use '{CONF_ROW}' and '{CONF_COL}' instead", + path=[CONF_KEY], + ) + config[CONF_KEY_CODE] = _matrix_key(*divmod(index, columns)) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE]) + keypad = await cg.get_variable(config[CONF_KEYPAD_ID]) + await register_listener(keypad, var) diff --git a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h new file mode 100644 index 00000000000..d6d58b93bc4 --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "../tca8418.h" + +namespace esphome::tca8418 { + +/// One key of a TCA8418, matched by the key number codegen resolved from the config +class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener { + public: + explicit TCA8418BinarySensor(uint8_t key) : key_(key) {} + + void key_pressed(uint8_t key) override { + if (key == this->key_) + this->publish_state(true); + } + void key_released(uint8_t key) override { + if (key == this->key_) + this->publish_state(false); + } + + protected: + const uint8_t key_; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp new file mode 100644 index 00000000000..7add67e9b09 --- /dev/null +++ b/esphome/components/tca8418/tca8418.cpp @@ -0,0 +1,170 @@ +#include "tca8418.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::tca8418 { + +ESPHOME_LOG_TAG(TAG, "tca8418"); + +// Minimum gap between event queue reads; the ten entry queue holds anything arriving sooner. +static constexpr uint32_t POLL_INTERVAL_MS = 50; + +void IRAM_ATTR TCA8418Component::interrupt_handler(TCA8418Component *arg) { arg->enable_loop_soon_any_context(); } + +void TCA8418Component::setup() { + // The configuration register reads back, so use it to check the device is there. + uint8_t config; + if (!this->read_byte(TCA8418_REG_CFG, &config)) { + ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); + this->mark_failed(); + return; + } + + if (!this->configure_pins_()) { + ESP_LOGE(TAG, "Failed to configure pins"); + this->mark_failed(); + return; + } + + // Report key events, and keep the interrupt asserted while events remain queued. + if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { + ESP_LOGE(TAG, "Failed to write the configuration register"); + this->mark_failed(); + return; + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // The interrupt output is active low, so a falling edge means an event was + // queued. The handler only wakes the loop; the queue is read from there. + this->interrupt_pin_->attach_interrupt(&TCA8418Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE); + } + + // Discard anything the device queued before it was configured, and clear + // every interrupt flag: leaving one set holds the interrupt output asserted. + if (!this->drain_events_(false)) { + ESP_LOGE(TAG, "Failed to clear the event queue"); + this->mark_failed(); + return; + } + + // With an interrupt pin there is nothing to do until it fires. Stay running + // if the device still has something queued, since that produces no new edge. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::configure_pins_() { + // Each of the three registers covers a group of pins: ROW0-7, COL0-7, COL8-9. + uint8_t matrix[3] = {0, 0, 0}; + if (this->rows_ > 0) { + matrix[0] = static_cast((1U << this->rows_) - 1U); + const uint8_t low_columns = this->columns_ > 8 ? 8 : this->columns_; + matrix[1] = static_cast((1U << low_columns) - 1U); + if (this->columns_ > 8) + matrix[2] = static_cast((1U << (this->columns_ - 8U)) - 1U); + } + + // Pins outside the matrix report individually when they are in event mode. + // The device ignores these bits for pins that belong to the matrix. + const uint8_t gpi[3] = { + static_cast(this->gpi_events_ ? ~matrix[0] : 0), + static_cast(this->gpi_events_ ? ~matrix[1] : 0), + static_cast(this->gpi_events_ ? (0x03 & ~matrix[2]) : 0), + }; + + for (uint8_t i = 0; i < 3; i++) { + // Enable the internal pull-ups (a 0 bit enables the pull-up) so that + // buttons can simply switch their pin to ground. + if (!this->write_byte(TCA8418_REG_GPIO_PULL1 + i, 0x00) || !this->write_byte(TCA8418_REG_KP_GPIO1 + i, matrix[i]) || + !this->write_byte(TCA8418_REG_GPI_EM1 + i, gpi[i]) || !this->write_byte(TCA8418_REG_GPIO_INT_EN1 + i, gpi[i])) { + return false; + } + } + return true; +} + +void TCA8418Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + + if (!this->drain_events_(true)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + // Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::drain_events_(bool dispatch) { + uint8_t count; + if (!this->read_byte(TCA8418_REG_KEY_LCK_EC, &count)) + return false; + count &= TCA8418_EVENT_COUNT_MASK; + // An idle poll has no flags to clear, unless INT is still asserted from a failed clear + if (count == 0 && dispatch && (this->interrupt_pin_ == nullptr || this->interrupt_pin_->digital_read())) + return true; + // The overflow flag stays clear with a full queue, so check the count + if (dispatch && count >= TCA8418_FIFO_DEPTH) { + ESP_LOGW(TAG, "Event queue is full - some key presses may have been lost"); + } + + uint8_t event; + for (uint8_t i = 0; i < count; i++) { + if (!this->read_byte(TCA8418_REG_KEY_EVENT_A, &event)) + return false; + if (dispatch) + this->dispatch_(event & TCA8418_KEY_CODE_MASK, (event & TCA8418_KEY_PRESSED) != 0); + } + return this->write_byte(TCA8418_REG_INT_STAT, TCA8418_INT_STAT_ALL); +} + +uint8_t TCA8418Component::key_char_(uint8_t key) const { + if (key == 0 || key > this->keys_size_) + return 0; + return progmem_read_byte(&this->keys_[key - 1]); +} + +void TCA8418Component::dispatch_(uint8_t key, bool pressed) { + ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); + +#ifdef TCA8418_LISTENER_COUNT + for (auto *listener : this->listeners_) { + if (pressed) { + listener->key_pressed(key); + } else { + listener->key_released(key); + } + } +#endif + + if (!pressed) + return; + + // Key collectors and automations receive the mapped character when a key map + // is configured, and the key number otherwise. + const uint8_t key_char = this->key_char_(key); + this->send_key_(key_char != 0 ? key_char : key); +} + +void TCA8418Component::dump_config() { + ESP_LOGCONFIG(TAG, + "TCA8418:\n" + " Individual inputs: %s", + YESNO(this->gpi_events_)); + if (this->rows_ > 0) { + ESP_LOGCONFIG(TAG, " Key matrix: %u rows x %u columns", this->rows_, this->columns_); + } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h new file mode 100644 index 00000000000..383182c1e0f --- /dev/null +++ b/esphome/components/tca8418/tca8418.h @@ -0,0 +1,85 @@ +#pragma once + +#include "esphome/components/key_provider/key_provider.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tca8418 { + +// Register map (see TI datasheet SCPS231) +static constexpr uint8_t TCA8418_REG_CFG = 0x01; +static constexpr uint8_t TCA8418_REG_INT_STAT = 0x02; +static constexpr uint8_t TCA8418_REG_KEY_LCK_EC = 0x03; +static constexpr uint8_t TCA8418_REG_KEY_EVENT_A = 0x04; // top of the event FIFO +static constexpr uint8_t TCA8418_REG_GPIO_INT_EN1 = 0x1A; +static constexpr uint8_t TCA8418_REG_KP_GPIO1 = 0x1D; +static constexpr uint8_t TCA8418_REG_GPI_EM1 = 0x20; +static constexpr uint8_t TCA8418_REG_GPIO_PULL1 = 0x2C; + +// CFG +static constexpr uint8_t TCA8418_CFG_KEY_INT_EN = 0x01; // interrupt on key events +static constexpr uint8_t TCA8418_CFG_INT_CFG = 0x10; // re-assert INT while events remain +// INT_STAT +static constexpr uint8_t TCA8418_INT_STAT_ALL = 0x1F; // every interrupt flag +// KEY_LCK_EC: the low nibble is the number of queued events +static constexpr uint8_t TCA8418_EVENT_COUNT_MASK = 0x0F; +// Key events: bit 7 set = pressed, clear = released; bits 6-0 are the key number +static constexpr uint8_t TCA8418_KEY_PRESSED = 0x80; +static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F; +// The event queue holds ten entries, which bounds how many can be read at once. +static constexpr uint8_t TCA8418_FIFO_DEPTH = 10; + +class TCA8418Listener { + public: + /// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs). + virtual void key_pressed(uint8_t key) {} + virtual void key_released(uint8_t key) {} +}; + +class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_rows(uint8_t rows) { this->rows_ = rows; } + void set_columns(uint8_t columns) { this->columns_ = columns; } + void set_gpi_events(bool gpi_events) { this->gpi_events_ = gpi_events; } + void set_keys(const uint8_t *keys, uint8_t size) { + this->keys_ = keys; + this->keys_size_ = size; + } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + +#ifdef TCA8418_LISTENER_COUNT + void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); } +#endif + + /// Wakes the loop so queued events are read. Runs in interrupt context. + static void IRAM_ATTR interrupt_handler(TCA8418Component *arg); + + protected: + /// Write the matrix / individual-input pin configuration. Returns false on I2C failure. + bool configure_pins_(); + /// Read every queued key event, dispatching them if asked, then clear the interrupt flags. + /// Returns false on I2C failure. + bool drain_events_(bool dispatch); + void dispatch_(uint8_t key, bool pressed); + /// The character mapped to a matrix key, or 0 when it has none. + uint8_t key_char_(uint8_t key) const; + + const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1 + InternalGPIOPin *interrupt_pin_{nullptr}; + uint32_t last_poll_{0}; +#ifdef TCA8418_LISTENER_COUNT + StaticVector listeners_; +#endif + uint8_t rows_{0}; + uint8_t columns_{0}; + uint8_t keys_size_{0}; + bool gpi_events_{true}; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a085cf9c0ac..87dc703b375 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -202,6 +202,7 @@ #define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 +#define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK diff --git a/tests/component_tests/tca8418/__init__.py b/tests/component_tests/tca8418/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/tca8418/test_final_validate.py b/tests/component_tests/tca8418/test_final_validate.py new file mode 100644 index 00000000000..48569f3f9e8 --- /dev/null +++ b/tests/component_tests/tca8418/test_final_validate.py @@ -0,0 +1,156 @@ +"""Tests for how the tca8418 binary sensor resolves its key against the keypad.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import CONF_GPI_EVENTS +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_COL, + CONF_ID, + CONF_KEY, + CONF_NAME, + CONF_ROW, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "keypad" + + +def _full_config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = "123456789*0#", + gpi_events: bool = True, +) -> Config: + """A full config carrying one keypad, as the ID pass leaves it.""" + hub: ConfigType = { + CONF_ID: ID(HUB_ID, is_declaration=True, type="tca8418"), + CONF_ROWS: rows, + CONF_COLUMNS: columns, + CONF_GPI_EVENTS: gpi_events, + } + if keys is not None: + hub[CONF_KEYS] = keys + full = Config() + full["tca8418"] = [hub] + full.declare_ids.append((hub[CONF_ID], ["tca8418", 0, CONF_ID])) + return full + + +def _sensor(**selector: object) -> ConfigType: + config: ConfigType = { + "keypad_id": ID(HUB_ID, is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + } + config.update(selector) + return config + + +def _validated(config: ConfigType) -> ConfigType: + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +@pytest.fixture +def keypad(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + + +# A key number is worked out here so the sensor only compares numbers at run +# time. Matrix keys are row * 10 + column + 1. +@pytest.mark.parametrize( + ("selector", "key_code"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, 1), + ({CONF_ROW: 3, CONF_COL: 2}, 33), + ({CONF_KEY: "1"}, 1), + ({CONF_KEY: "5"}, 12), + ({CONF_KEY: "#"}, 33), + ({CONF_KEY_CODE: 12}, 12), + ], +) +def test_key_is_resolved(keypad: None, selector: ConfigType, key_code: int) -> None: + assert _validated(_sensor(**selector))[CONF_KEY_CODE] == key_code + + +def test_position_outside_the_matrix_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="outside the 4 x 3 key matrix"): + _validated(_sensor(row=0, col=3)) + + +def test_key_number_outside_the_matrix_is_rejected(keypad: None) -> None: + """Key 5 is ROW0/COL4, which a three column matrix never reports.""" + with pytest.raises(cv.Invalid, match="position 0/4, which is outside"): + _validated(_sensor(key_code=5)) + + +def test_key_number_of_a_pin_the_matrix_uses_is_rejected(keypad: None) -> None: + """97 is ROW0, which belongs to the matrix, so it cannot report on its own.""" + with pytest.raises(cv.Invalid, match="ROW0, which the key matrix is using"): + _validated(_sensor(key_code=97)) + + +def test_individual_input_outside_the_matrix_passes(keypad: None) -> None: + """ROW5 is past the four rows of the matrix, so it reports on its own.""" + assert _validated(_sensor(key_code=102))[CONF_KEY_CODE] == 102 + + +def test_column_beyond_the_matrix_passes(keypad: None) -> None: + """COL3 is past the three columns of the matrix; 105 is COL0.""" + assert _validated(_sensor(key_code=108))[CONF_KEY_CODE] == 108 + + +def test_individual_input_needs_gpi_events( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(gpi_events=False) + ) + + with pytest.raises(cv.Invalid, match="'gpi_events' turned off"): + _validated(_sensor(key_code=102)) + + +def test_character_without_a_key_map_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config(keys=None)) + + with pytest.raises(cv.Invalid, match="which is not set"): + _validated(_sensor(key="5")) + + +def test_character_not_in_the_key_map_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="not one of the keypad's keys"): + _validated(_sensor(key="Z")) + + +def test_repeated_character_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A character appearing twice does not name one key.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(keys="1234567891*0") + ) + + with pytest.raises(cv.Invalid, match="appears more than once"): + _validated(_sensor(key="1")) + + +def test_key_given_as_a_number_is_accepted(keypad: None) -> None: + """'key: 5' is a reasonable way to name the key labelled 5.""" + assert _validated(_sensor(key=5))[CONF_KEY_CODE] == 12 diff --git a/tests/component_tests/tca8418/test_validate.py b/tests/component_tests/tca8418/test_validate.py new file mode 100644 index 00000000000..fbadd42363d --- /dev/null +++ b/tests/component_tests/tca8418/test_validate.py @@ -0,0 +1,142 @@ +"""Tests for the tca8418 keypad and binary sensor configuration schemas.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import ( + CONF_GPI_EVENTS, + CONFIG_SCHEMA as HUB_SCHEMA, + _validate, +) +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA as SENSOR_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_NAME, CONF_ROW +from esphome.core import ID +from esphome.types import ConfigType + + +def _config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = None, + gpi_events: bool = True, +) -> ConfigType: + config: ConfigType = {CONF_GPI_EVENTS: gpi_events} + if rows: + config[CONF_ROWS] = rows + if columns: + config[CONF_COLUMNS] = columns + if keys is not None: + config[CONF_KEYS] = keys + return config + + +def test_a_matrix_with_a_key_map_passes() -> None: + _validate(_config(keys="123456789*0#")) + + +def test_individual_inputs_only_passes() -> None: + """No matrix at all is fine as long as the pins report on their own.""" + _validate(_config(rows=0, columns=0)) + + +@pytest.mark.parametrize("half", [{CONF_ROWS: 4}, {CONF_COLUMNS: 3}]) +def test_half_a_matrix_is_rejected(half: ConfigType) -> None: + config = {CONF_ID: ID("k", is_declaration=True), "i2c_id": ID("bus"), **half} + with pytest.raises(cv.Invalid, match="must both be set"): + HUB_SCHEMA(config) + + +def test_key_map_without_a_matrix_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="'rows' and 'columns' are required"): + _validate(_config(rows=0, columns=0, keys="123")) + + +@pytest.mark.parametrize("keys", ["12345678901", "12345678"]) +def test_key_map_of_the_wrong_length_is_rejected(keys: str) -> None: + """A key map names every key of the matrix, so its length is fixed.""" + with pytest.raises(cv.Invalid, match="must have exactly 12 characters"): + _validate(_config(keys=keys)) + + +def test_a_keypad_that_reports_nothing_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="Nothing to report"): + _validate(_config(rows=0, columns=0, gpi_events=False)) + + +def test_letters_that_clash_with_individual_inputs_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + """97 to 114 are individual inputs and also the codes for 'a' to 'r'.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#")) + + assert "cannot be told apart" in caplog.text + assert "'a', 'b', 'c'" in caplog.text + + +def test_other_characters_do_not_warn(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + _validate(_config(keys="123456789*0#")) + + assert caplog.text == "" + + +def test_no_warning_without_individual_inputs( + caplog: pytest.LogCaptureFixture, +) -> None: + """With the individual inputs off there is nothing for a letter to clash with.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#", gpi_events=False)) + + assert caplog.text == "" + + +def _sensor(**selector: object) -> ConfigType: + return { + "keypad_id": ID("keypad", is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + **selector, + } + + +# Checks made before the keypad is consulted: a key is named exactly one way, +# a position has both halves, a character is one ASCII character, and a key +# number is one the device uses (81 to 96 are unused, numbering stops at 114). +@pytest.mark.parametrize( + ("selector", "error"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, None), + ({CONF_KEY: "5"}, None), + ({CONF_KEY: 5}, None), + ({CONF_KEY_CODE: 12}, None), + ({CONF_KEY_CODE: 102}, None), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY: "5"}, "more than one of"), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY_CODE: 12}, "more than one of"), + ({CONF_KEY: "5", CONF_KEY_CODE: 12}, "more than one of"), + ({}, "exactly one of"), + ({CONF_ROW: 0}, "must be given together"), + ({CONF_COL: 0}, "must be given together"), + ({CONF_KEY: "ab"}, "single character"), + ({CONF_KEY: ""}, "single character"), + ({CONF_KEY: "\u00e9"}, "Only ASCII characters"), + ({CONF_KEY_CODE: 0}, "Key numbers run from"), + ({CONF_KEY_CODE: 81}, "Key numbers run from"), + ({CONF_KEY_CODE: 96}, "Key numbers run from"), + ({CONF_KEY_CODE: 115}, "Key numbers run from"), + ], +) +def test_sensor_schema(selector: ConfigType, error: str | None) -> None: + if error is None: + SENSOR_SCHEMA(_sensor(**selector)) + return + with pytest.raises(cv.Invalid, match=error): + SENSOR_SCHEMA(_sensor(**selector)) diff --git a/tests/components/tca8418/common.yaml b/tests/components/tca8418/common.yaml new file mode 100644 index 00000000000..4a6b7150c4e --- /dev/null +++ b/tests/components/tca8418/common.yaml @@ -0,0 +1,36 @@ +tca8418: + # A 4x3 key matrix, with the remaining pins reporting as individual inputs. + - id: matrix_keypad_device + i2c_id: i2c_bus + address: 0x34 + rows: 4 + columns: 3 + keys: "123456789*0#" + # The interrupt output is open drain, so it needs a pull-up. Use the + # internal one unless the board already has its own. + interrupt_pin: + number: ${interrupt_pin} + mode: INPUT_PULLUP + on_key: + - logger.log: + format: "Key: %c" + args: [x] + # Every pin reporting as an individual input, polled over I2C. + - id: inputs_only_device + i2c_id: i2c_bus + address: 0x35 + +binary_sensor: + - platform: tca8418 + keypad_id: matrix_keypad_device + row: 0 + col: 0 + name: "Key top left" + - platform: tca8418 + keypad_id: matrix_keypad_device + key: "5" + name: "Key five" + - platform: tca8418 + keypad_id: inputs_only_device + key_code: 97 + name: "Input ROW0" diff --git a/tests/components/tca8418/test.esp32-idf.yaml b/tests/components/tca8418/test.esp32-idf.yaml new file mode 100644 index 00000000000..0621ab995e3 --- /dev/null +++ b/tests/components/tca8418/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO13 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + tca8418: !include common.yaml diff --git a/tests/components/tca8418/test.esp8266-ard.yaml b/tests/components/tca8418/test.esp8266-ard.yaml new file mode 100644 index 00000000000..9a9351cde82 --- /dev/null +++ b/tests/components/tca8418/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO12 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + tca8418: !include common.yaml From f9f41ef16e5ea8a61398ad250f483a48b39df10a Mon Sep 17 00:00:00 2001 From: JiriPrchal <163323169+JiriPrchal@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:45:08 +0200 Subject: [PATCH 404/435] [MAX31888] Add temperature sensor (#13567) 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/max31888/__init__.py | 2 + esphome/components/max31888/max31888.cpp | 95 +++++++++++++++++++ esphome/components/max31888/max31888.h | 21 ++++ esphome/components/max31888/sensor.py | 36 +++++++ esphome/components/one_wire/one_wire_bus.cpp | 3 + tests/components/max31888/common.yaml | 9 ++ tests/components/max31888/test.esp32-idf.yaml | 3 + .../components/max31888/test.esp8266-ard.yaml | 3 + .../components/max31888/test.rp2040-ard.yaml | 3 + tests/test_build_components/common/README.md | 5 + .../common/one_wire/esp32-idf.yaml | 10 ++ .../common/one_wire/esp8266-ard.yaml | 10 ++ .../common/one_wire/rp2040-ard.yaml | 10 ++ 14 files changed, 211 insertions(+) create mode 100644 esphome/components/max31888/__init__.py create mode 100644 esphome/components/max31888/max31888.cpp create mode 100644 esphome/components/max31888/max31888.h create mode 100644 esphome/components/max31888/sensor.py create mode 100644 tests/components/max31888/common.yaml create mode 100644 tests/components/max31888/test.esp32-idf.yaml create mode 100644 tests/components/max31888/test.esp8266-ard.yaml create mode 100644 tests/components/max31888/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/one_wire/esp32-idf.yaml create mode 100644 tests/test_build_components/common/one_wire/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/one_wire/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index ea3cdaed294..8c28f879772 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -343,6 +343,7 @@ esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell esphome/components/max31865/* @DAVe3283 +esphome/components/max31888/* @JiriPrchal esphome/components/max44009/* @berfenger esphome/components/max6956/* @looping40 esphome/components/max7219digit/* @rspaargaren diff --git a/esphome/components/max31888/__init__.py b/esphome/components/max31888/__init__.py new file mode 100644 index 00000000000..880005d6e77 --- /dev/null +++ b/esphome/components/max31888/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@JiriPrchal"] +DOMAIN = "max31888" diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp new file mode 100644 index 00000000000..5ff8e6f1ef0 --- /dev/null +++ b/esphome/components/max31888/max31888.cpp @@ -0,0 +1,95 @@ +#include "max31888.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::max31888 { + +ESPHOME_LOG_TAG(TAG, "max31888"); + +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; + +static constexpr uint8_t MAX31888_COMMAND_START_CONVERSION = 0x44; +static constexpr uint8_t MAX31888_COMMAND_READ = 0x33; +static constexpr uint8_t MAX31888_COMMAND_SOFT_RESET = 0x82; +static constexpr uint8_t MAX31888_REGISTER_FIFO_DATA = 0x08; +static constexpr uint8_t MAX31888_FIFO_READ_LENGTH = 0x01; // number of bytes to read, minus one +static constexpr uint16_t MAX31888_CONVERSION_TIME_MS = 20; +static constexpr float MAX31888_DEGREES_PER_LSB = 0.005f; + +// Sends a command and checks the checksum the device appends to it (Maxim CRC-16 over the command byte, +// sent inverted and low byte first) +bool MAX31888Sensor::send_checked_command_(uint8_t command) { + if (!this->send_command_(command)) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + const uint8_t low = this->bus_->read8(); + const uint8_t high = this->bus_->read8(); + if (crc16(&command, 1, 0, 0xa001, false, true) != encode_uint16(high, low)) { + this->status_set_warning(LOG_STR("command checksum invalid")); + return false; + } + return true; +} + +void MAX31888Sensor::setup() { + if (!this->check_address_or_index_()) + return; + if (!this->send_checked_command_(MAX31888_COMMAND_SOFT_RESET)) { + this->mark_failed(); + } +} + +void MAX31888Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "MAX31888 Sensor:"); + if (this->address_ == 0) { + ESP_LOGW(TAG, " Unable to select an address"); + return; + } + LOG_ONE_WIRE_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +void MAX31888Sensor::update() { + if (this->address_ == 0) + return; + + if (!this->send_checked_command_(MAX31888_COMMAND_START_CONVERSION)) { + this->publish_state(NAN); + return; + } + + this->set_timeout(CONVERSION_TIMEOUT_ID, MAX31888_CONVERSION_TIME_MS, [this] { + int16_t raw; + if (!this->read_temperature_(raw)) { + this->publish_state(NAN); + return; + } + const float temperature = raw * MAX31888_DEGREES_PER_LSB; + ESP_LOGD(TAG, "'%s': Got Temperature=%.3f°C", this->get_name().c_str(), temperature); + this->status_clear_warning(); + this->publish_state(temperature); + }); +} + +bool MAX31888Sensor::read_temperature_(int16_t &raw) { + // The checksum covers the command and its two arguments as well as the data + uint8_t frame[7] = {MAX31888_COMMAND_READ, MAX31888_REGISTER_FIFO_DATA, MAX31888_FIFO_READ_LENGTH}; + if (!this->send_command_(frame[0])) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + this->bus_->write8(frame[1]); + this->bus_->write8(frame[2]); + for (size_t i = 3; i < sizeof(frame); i++) { + frame[i] = this->bus_->read8(); + } + if (crc16(frame, 5, 0, 0xa001, false, true) != encode_uint16(frame[6], frame[5])) { + this->status_set_warning(LOG_STR("checksum invalid")); + return false; + } + raw = static_cast(encode_uint16(frame[3], frame[4])); + return true; +} + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/max31888.h b/esphome/components/max31888/max31888.h new file mode 100644 index 00000000000..666f301cb04 --- /dev/null +++ b/esphome/components/max31888/max31888.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/one_wire/one_wire.h" + +namespace esphome::max31888 { + +class MAX31888Sensor final : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool send_checked_command_(uint8_t command); + /// Reads the latest conversion from the FIFO; false when the bus or the checksum fails + bool read_temperature_(int16_t &raw); +}; + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/sensor.py b/esphome/components/max31888/sensor.py new file mode 100644 index 00000000000..2809e7531a0 --- /dev/null +++ b/esphome/components/max31888/sensor.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import one_wire, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +max31888_ns = cg.esphome_ns.namespace("max31888") + +MAX31888Sensor = max31888_ns.class_( + "MAX31888Sensor", + cg.PollingComponent, + sensor.Sensor, + one_wire.OneWireDevice, +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MAX31888Sensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(one_wire.one_wire_device_schema()) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await one_wire.register_one_wire_device(var, config) diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index 0a4880cf733..22d3afb7754 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -10,6 +10,7 @@ static const uint8_t DALLAS_MODEL_DS1822 = 0x22; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_MODEL_DS1825 = 0x3B; static const uint8_t DALLAS_MODEL_DS28EA00 = 0x42; +static const uint8_t MAXIM_MODEL_MAX31888 = 0x54; const uint8_t ONE_WIRE_ROM_SELECT = 0x55; const uint8_t ONE_WIRE_ROM_SEARCH = 0xF0; @@ -76,6 +77,8 @@ const LogString *OneWireBus::get_model_str(uint8_t model) { return LOG_STR("DS1825"); case DALLAS_MODEL_DS28EA00: return LOG_STR("DS28EA00"); + case MAXIM_MODEL_MAX31888: + return LOG_STR("MAX31888"); default: return LOG_STR("Unknown"); } diff --git a/tests/components/max31888/common.yaml b/tests/components/max31888/common.yaml new file mode 100644 index 00000000000..7b9ada2e823 --- /dev/null +++ b/tests/components/max31888/common.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: max31888 + one_wire_id: one_wire_bus + address: 0x1C0000031EDD2A54 + name: MAX31888 Temperature 1 + - platform: max31888 + one_wire_id: one_wire_bus + name: MAX31888 Temperature 2 + index: 1 diff --git a/tests/components/max31888/test.esp32-idf.yaml b/tests/components/max31888/test.esp32-idf.yaml new file mode 100644 index 00000000000..943a8c54a9d --- /dev/null +++ b/tests/components/max31888/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp32-idf.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.esp8266-ard.yaml b/tests/components/max31888/test.esp8266-ard.yaml new file mode 100644 index 00000000000..29ed6be26aa --- /dev/null +++ b/tests/components/max31888/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp8266-ard.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.rp2040-ard.yaml b/tests/components/max31888/test.rp2040-ard.yaml new file mode 100644 index 00000000000..80de52bae2e --- /dev/null +++ b/tests/components/max31888/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/rp2040-ard.yaml + max31888: !include common.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 010313db7f9..4a89a1fb916 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -125,6 +125,11 @@ Components using `modbus` packages automatically get `uart` as well. - **RP2040**: SCL=GPIO5, SDA=GPIO4 - **BK72xx**: SCL=P20, SDA=P21 +### 1-Wire +- **ESP32 IDF**: GPIO4 +- **ESP8266**: GPIO13 +- **RP2040**: GPIO10 + ### I2C Low Frequency (10kHz) Same pin allocations as standard I2C, but with 10kHz frequency for components requiring slower speeds. diff --git a/tests/test_build_components/common/one_wire/esp32-idf.yaml b/tests/test_build_components/common/one_wire/esp32-idf.yaml new file mode 100644 index 00000000000..b34e87a7589 --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp32-idf tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO4 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/esp8266-ard.yaml b/tests/test_build_components/common/one_wire/esp8266-ard.yaml new file mode 100644 index 00000000000..c4fefffd3eb --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp8266-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO13 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/rp2040-ard.yaml b/tests/test_build_components/common/one_wire/rp2040-ard.yaml new file mode 100644 index 00000000000..8023264e40e --- /dev/null +++ b/tests/test_build_components/common/one_wire/rp2040-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for rp2040-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO10 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} From 1c315c3bf29752958ae9b22d71128bc63dc67c81 Mon Sep 17 00:00:00 2001 From: tchilov <31223839+tchilov@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:49:07 +0200 Subject: [PATCH 405/435] [ns2009] Add Nsiway NS2009 touchscreen controller component (#10597) Co-authored-by: J. Nick Koston 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 | 2 + esphome/components/ns2009/__init__.py | 8 +++ .../components/ns2009/touchscreen/__init__.py | 34 ++++++++++ .../components/ns2009/touchscreen/ns2009.cpp | 65 +++++++++++++++++++ .../components/ns2009/touchscreen/ns2009.h | 25 +++++++ tests/components/ns2009/common.yaml | 27 ++++++++ tests/components/ns2009/test.esp32-idf.yaml | 8 +++ tests/components/ns2009/test.esp8266-ard.yaml | 8 +++ tests/components/ns2009/test.rp2040-ard.yaml | 8 +++ 9 files changed, 185 insertions(+) create mode 100644 esphome/components/ns2009/__init__.py create mode 100644 esphome/components/ns2009/touchscreen/__init__.py create mode 100644 esphome/components/ns2009/touchscreen/ns2009.cpp create mode 100644 esphome/components/ns2009/touchscreen/ns2009.h create mode 100644 tests/components/ns2009/common.yaml create mode 100644 tests/components/ns2009/test.esp32-idf.yaml create mode 100644 tests/components/ns2009/test.esp8266-ard.yaml create mode 100644 tests/components/ns2009/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8c28f879772..45aa0c49ab3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -413,6 +413,8 @@ esphome/components/noblex/* @AGalfra esphome/components/noise/* @esphome/core esphome/components/npi19/* @bakerkj esphome/components/nrf52/* @tomaszduda23 +esphome/components/ns2009/* @tchilov +esphome/components/ns2009/touchscreen/* @tchilov esphome/components/number/* @esphome/core esphome/components/one_wire/* @ssieb esphome/components/online_image/* @clydebarrow @guillempages diff --git a/esphome/components/ns2009/__init__.py b/esphome/components/ns2009/__init__.py new file mode 100644 index 00000000000..7cd4038a95a --- /dev/null +++ b/esphome/components/ns2009/__init__.py @@ -0,0 +1,8 @@ +import esphome.config_validation as cv + +CODEOWNERS = ["@tchilov"] +DOMAIN = "ns2009" + +CONFIG_SCHEMA = cv.invalid( + "This component should be used as platform of the Touchscreen component." +) diff --git a/esphome/components/ns2009/touchscreen/__init__.py b/esphome/components/ns2009/touchscreen/__init__.py new file mode 100644 index 00000000000..49bcf7a2462 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.types import ConfigType + +CODEOWNERS = ["@tchilov"] +DEPENDENCIES = ["i2c"] + +ns2009_ns = cg.esphome_ns.namespace("ns2009") +NS2009Component = ns2009_ns.class_( + "NS2009Component", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema(calibration_required=True) + .extend( + { + cv.GenerateID(): cv.declare_id(NS2009Component), + cv.Optional(CONF_THRESHOLD, default=40): cv.uint8_t, + } + ) + .extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await i2c.register_i2c_device(var, config) + await touchscreen.register_touchscreen(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) diff --git a/esphome/components/ns2009/touchscreen/ns2009.cpp b/esphome/components/ns2009/touchscreen/ns2009.cpp new file mode 100644 index 00000000000..ae1bdfe3736 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.cpp @@ -0,0 +1,65 @@ +#include "ns2009.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ns2009 { + +ESPHOME_LOG_TAG(TAG, "ns2009"); + +static constexpr uint8_t GET_X = 0xC0; +static constexpr uint8_t GET_Y = 0xD0; +static constexpr uint8_t GET_Z = 0xE2; + +void NS2009Component::setup() { + if (!this->read_byte(GET_Z).has_value()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void NS2009Component::update_touches() { + auto data_z = this->read_byte(GET_Z); + if (!data_z.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("pressure")); + this->skip_update_ = true; + return; + } + uint8_t z = *data_z; + if (z <= this->threshold_) { + return; + } + + auto x = this->read_axis_(GET_X); + if (!x.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("X position")); + this->skip_update_ = true; + return; + } + auto y = this->read_axis_(GET_Y); + if (!y.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("Y position")); + this->skip_update_ = true; + return; + } + + ESP_LOGV(TAG, "X %4d Y %4d Z %3d", *x, *y, z); + this->add_raw_touch_position_(0, *x, *y, z); +} + +optional NS2009Component::read_axis_(uint8_t cmd) { + auto data = this->read_bytes<2>(cmd); + if (!data.has_value()) { + return {}; + } + return encode_uint16((*data)[0], (*data)[1]) >> 4; // 12 bit followed by 4 0's +} + +void NS2009Component::dump_config() { + ESP_LOGCONFIG(TAG, + "NS2009 Touchscreen:\n" + " Threshold: %u", + this->threshold_); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::ns2009 diff --git a/esphome/components/ns2009/touchscreen/ns2009.h b/esphome/components/ns2009/touchscreen/ns2009.h new file mode 100644 index 00000000000..75b675bfe96 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" + +namespace esphome::ns2009 { + +class NS2009Component : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Set the threshold for the touch detection. + void set_threshold(uint8_t threshold) { this->threshold_ = threshold; } + + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + /// Read a 12 bit axis value, or nothing when the read fails + optional read_axis_(uint8_t cmd); + + /// @brief The threshold at which a touch is registered. + uint8_t threshold_{40}; +}; + +} // namespace esphome::ns2009 diff --git a/tests/components/ns2009/common.yaml b/tests/components/ns2009/common.yaml new file mode 100644 index 00000000000..f2472d2d5db --- /dev/null +++ b/tests/components/ns2009/common.yaml @@ -0,0 +1,27 @@ +display: + - platform: mipi_spi + model: ILI9341 + id: ns_display + dc_pin: ${dc_pin} + cs_pin: ${cs_pin} + color_depth: 8 + invert_colors: false + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: ns2009 + id: ns_touchscreen + i2c_id: i2c_bus + display: ns_display + transform: + mirror_y: true + calibration: + x_min: 400 + x_max: 3700 + y_min: 300 + y_max: 3700 + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] diff --git a/tests/components/ns2009/test.esp32-idf.yaml b/tests/components/ns2009/test.esp32-idf.yaml new file mode 100644 index 00000000000..80b28696a12 --- /dev/null +++ b/tests/components/ns2009/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO15 + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.esp8266-ard.yaml b/tests/components/ns2009/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ca78f2b0291 --- /dev/null +++ b/tests/components/ns2009/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.rp2040-ard.yaml b/tests/components/ns2009/test.rp2040-ard.yaml new file mode 100644 index 00000000000..c10519f9d03 --- /dev/null +++ b/tests/components/ns2009/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + ns2009: !include common.yaml From c5baae35d57b3ef185f8943bb36c7afddd049d16 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:51:48 -0400 Subject: [PATCH 406/435] [ezsp_proxy_tap] Add SiLabs EZSP auto-ACK implementation (#20304) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ezsp_proxy_tap/__init__.py | 34 ++++ .../ezsp_proxy_tap/ash_acknowledger.cpp | 126 ++++++++++++ .../ezsp_proxy_tap/ash_acknowledger.h | 54 +++++ .../ezsp_proxy_tap/ash_protocol.cpp | 19 ++ .../components/ezsp_proxy_tap/ash_protocol.h | 39 ++++ .../ezsp_proxy_tap/ezsp_proxy_tap.cpp | 38 ++++ .../ezsp_proxy_tap/ezsp_proxy_tap.h | 49 +++++ .../components/serial_proxy/serial_proxy.cpp | 14 ++ .../components/serial_proxy/serial_proxy.h | 9 + esphome/core/defines.h | 1 + .../ezsp_proxy_tap/ash_acknowledger_test.cpp | 185 ++++++++++++++++++ tests/components/ezsp_proxy_tap/common.yaml | 15 ++ .../ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml | 27 +++ .../ezsp_proxy_tap/test.esp32-idf.yaml | 3 + .../ezsp_proxy_tap/test.esp8266-ard.yaml | 3 + .../ezsp_proxy_tap/test.rp2040-ard.yaml | 3 + 17 files changed, 620 insertions(+) create mode 100644 esphome/components/ezsp_proxy_tap/__init__.py create mode 100644 esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ash_acknowledger.h create mode 100644 esphome/components/ezsp_proxy_tap/ash_protocol.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ash_protocol.h create mode 100644 esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h create mode 100644 tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp create mode 100644 tests/components/ezsp_proxy_tap/common.yaml create mode 100644 tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.esp32-idf.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 45aa0c49ab3..23bac1efae1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,6 +202,7 @@ esphome/components/exponential_moving_average/* @clydebarrow esphome/components/exposure_notifications/* @OttoWinter esphome/components/ezo/* @ssieb esphome/components/ezo_pmp/* @carlos-sarmiento +esphome/components/ezsp_proxy_tap/* @kbx81 @puddly esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi diff --git a/esphome/components/ezsp_proxy_tap/__init__.py b/esphome/components/ezsp_proxy_tap/__init__.py new file mode 100644 index 00000000000..207e0cb8b15 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import serial_proxy +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@puddly"] +DEPENDENCIES = ["serial_proxy"] +DOMAIN = "ezsp_proxy_tap" + +CONF_SERIAL_PROXY_ID = "serial_proxy_id" + +ezsp_proxy_tap_ns = cg.esphome_ns.namespace("ezsp_proxy_tap") +EzspProxyTap = ezsp_proxy_tap_ns.class_( + "EzspProxyTap", cg.Component, serial_proxy.SerialProxyTap +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(EzspProxyTap), + cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) + var = cg.new_Pvariable(config[CONF_ID], sp) + await cg.register_component(var, config) + + cg.add_define("USE_EZSP_PROXY_TAP") + # Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe + cg.add_define("USE_SERIAL_PROXY_TAP") diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp new file mode 100644 index 00000000000..f8010f46186 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp @@ -0,0 +1,126 @@ +#include "ash_acknowledger.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +// Control byte of an RSTACK, and the only ASH version byte that can follow it +static constexpr uint8_t ASH_RSTACK_CONTROL = 0xC1; +static constexpr uint8_t ASH_PROTOCOL_VERSION = 0x02; +static constexpr size_t ASH_RSTACK_BODY_SIZE = 3; // control, version, reset code +// A DATA frame carries an EZSP frame of at least 3 bytes after its control byte +static constexpr size_t ASH_MIN_DATA_BODY_SIZE = 1 + 3; +static constexpr size_t ASH_CRC_SIZE = 2; +// Smallest legal frame on the wire: a bare control byte plus its CRC +static constexpr size_t ASH_MIN_FRAME_SIZE = 1 + ASH_CRC_SIZE; + +void AshFrameScanner::reset() { + this->index_ = 0; + this->crc_ = ASH_CRC_INIT; + this->escaped_ = false; + this->poisoned_ = false; + this->discarding_ = false; +} + +bool AshFrameScanner::feed(uint8_t byte) { + if (byte == ASH_FLAG_BYTE) { + // Running the CRC over the body and its trailing CRC bytes leaves zero when correct + const bool valid = !this->discarding_ && !this->poisoned_ && !this->escaped_ && + this->index_ >= ASH_MIN_FRAME_SIZE && this->crc_ == 0; + if (valid) { + this->frame_length_ = this->index_ - ASH_CRC_SIZE; + } + this->reset(); + return valid; + } + + // CANCEL also ends a SUBSTITUTE's discard, so the bytes after it start a new frame + if (byte == ASH_CANCEL_BYTE) { + this->reset(); + return false; + } + + if (this->discarding_ || (byte == ASH_WAKE_BYTE && this->index_ == 0)) { + return false; + } + + switch (byte) { + case ASH_SUBSTITUTE_BYTE: + this->discarding_ = true; + return false; + + case ASH_XON_BYTE: + case ASH_XOFF_BYTE: + return false; + + case ASH_ESCAPE_BYTE: + this->escaped_ = true; + return false; + + default: + break; + } + + uint8_t value = byte; + if (this->escaped_) { + this->escaped_ = false; + value = byte ^ ASH_XOR_BYTE; + } + + if (this->index_ >= MAX_ASH_FRAME_SIZE) { + this->poisoned_ = true; + return false; + } + + if (this->index_ < ASH_HEADER_SIZE) { + this->header_[this->index_] = value; + } + this->index_++; + this->crc_ = crc16be(&value, 1, this->crc_); + return false; +} + +void AshAcknowledger::reset() { + this->scanner_.reset(); + this->rx_sequence_ = 0; + this->synced_ = false; +} + +bool AshAcknowledger::feed(uint8_t byte) { return this->scanner_.feed(byte) && this->handle_frame_(); } + +bool AshAcknowledger::handle_frame_() { + const uint8_t *body = this->scanner_.header(); + const uint8_t control = body[0]; + + // An RSTACK restarts the NCP's frame numbering + if (control == ASH_RSTACK_CONTROL) { + if (this->scanner_.length() == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION) { + this->rx_sequence_ = 0; + this->synced_ = true; + } + return false; + } + + if ((control & 0x80) != 0) { + return false; // ACK/NAK/RST/ERROR: nothing is owed for these + } + + if (this->scanner_.length() < ASH_MIN_DATA_BODY_SIZE) { + return false; + } + + const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE; + const bool re_tx = (control & 0x08) != 0; + // Without an RSTACK, the session began before we were watching: follow the NCP's numbering + if (!this->synced_) { + this->rx_sequence_ = frame_num; + this->synced_ = true; + } + if (frame_num == this->rx_sequence_) { + this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; + return true; + } + return re_tx; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.h b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h new file mode 100644 index 00000000000..1c39e8ec4ad --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ash_protocol.h" + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames. Only +// the first ASH_HEADER_SIZE bytes of a frame are kept, as the CRC is checked as bytes +// arrive and acknowledging needs nothing past the header. +class AshFrameScanner { + public: + // Returns true when the byte completes a frame with a valid CRC + bool feed(uint8_t byte); + void reset(); + + // The first min(length(), ASH_HEADER_SIZE) bytes of the last completed frame's body. + // Valid only until the next feed() call, which begins overwriting them. + const uint8_t *header() const { return this->header_; } + size_t length() const { return this->frame_length_; } + + private: + uint8_t header_[ASH_HEADER_SIZE]; + size_t index_{0}; // body bytes read so far in the frame being read + size_t frame_length_{0}; // body length of the last completed frame + uint16_t crc_{ASH_CRC_INIT}; + bool escaped_{false}; + bool discarding_{false}; + bool poisoned_{false}; +}; + +// Works out which acknowledgements an NCP is owed while a client has handed them to this +// device. Whether to acknowledge at all is the client's decision, made by putting the port +// in PROTOCOL mode, so this only follows the NCP's frame numbering. +class AshAcknowledger { + public: + // Forgets the NCP's frame numbering. The next RSTACK or DATA frame sets it again. + void reset(); + + // Feed a byte from the NCP. Returns true when it completes a frame that is owed ack_num(). + bool feed(uint8_t byte); + uint8_t ack_num() const { return this->rx_sequence_; } + + protected: + bool handle_frame_(); + + AshFrameScanner scanner_; + uint8_t rx_sequence_{0}; + bool synced_{false}; +}; + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.cpp b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp new file mode 100644 index 00000000000..bbe9cacc2de --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp @@ -0,0 +1,19 @@ +#include "ash_protocol.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num) { + // ACK control byte: 100nrPPP, where PPP is the next frame number expected + const uint8_t control = 0x80 | (ack_num & ASH_MAX_SEQUENCE); + const uint16_t crc = crc16be(&control, 1, ASH_CRC_INIT); + + output[0] = ASH_FLAG_BYTE; + output[1] = control; + output[2] = crc >> 8; + output[3] = crc & 0xFF; + output[4] = ASH_FLAG_BYTE; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.h b/esphome/components/ezsp_proxy_tap/ash_protocol.h new file mode 100644 index 00000000000..4694c745018 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// ASH Protocol Constants +static constexpr uint8_t ASH_FLAG_BYTE = 0x7E; // Frame delimiter +static constexpr uint8_t ASH_ESCAPE_BYTE = 0x7D; // Escape/substitution byte +static constexpr uint8_t ASH_XOR_BYTE = 0x20; // XOR mask for escaped bytes +static constexpr uint8_t ASH_SUBSTITUTE_BYTE = 0x18; // Substitution for invalid bytes + +static constexpr uint8_t ASH_XON_BYTE = 0x11; // Resume transmission +static constexpr uint8_t ASH_XOFF_BYTE = 0x13; // Pause transmission +static constexpr uint8_t ASH_CANCEL_BYTE = 0x1A; // Discards the partial frame before it +// Sent between frames by sleepy NCPs to signal pending callbacks. No control byte is 0xFF. +static constexpr uint8_t ASH_WAKE_BYTE = 0xFF; + +static constexpr size_t ASH_MAX_DATA_FIELD_SIZE = 220; +static constexpr size_t MAX_ASH_FRAME_SIZE = 1 + ASH_MAX_DATA_FIELD_SIZE + 2; + +// The control byte, then the first Data Field byte (an RSTACK's version) +static constexpr size_t ASH_HEADER_SIZE = 2; + +// Protocol limits +static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7) + +// The frame CRC is CRC-CCITT: crc16be() with this initial value, transmitted big-endian +static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; + +// FLAG, control byte, two CRC bytes, FLAG. None of the eight ACKs has a byte that needs +// escaping, so the size is fixed. +static constexpr size_t ASH_ACK_FRAME_SIZE = 5; + +// Writes the ACK frame for `ack_num` into `output` +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num); + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp new file mode 100644 index 00000000000..c8557fcc9be --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp @@ -0,0 +1,38 @@ +#include "ezsp_proxy_tap.h" + +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/core/log.h" + +namespace esphome::ezsp_proxy_tap { + +ESPHOME_LOG_TAG(TAG, "ezsp_proxy_tap"); + +void EzspProxyTap::setup() { this->parent_->set_tap(this); } + +void EzspProxyTap::dump_config() { ESP_LOGCONFIG(TAG, "EZSP Proxy Tap:\n Port: %s", this->parent_->get_name()); } + +void EzspProxyTap::on_device_rx(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + if (this->acknowledger_.feed(data[i])) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, this->acknowledger_.ack_num()); + this->parent_->write_from_tap(frame, sizeof(frame)); + ESP_LOGV(TAG, "Sent ACK %u", this->acknowledger_.ack_num()); + } + } +} + +void EzspProxyTap::on_protocol_enabled() { + // Numbering from an earlier session is stale + this->acknowledger_.reset(); +} + +void EzspProxyTap::on_device_disconnected() { + // The frame numbering ended with the NCP's power; whatever boots next starts over + this->acknowledger_.reset(); +} + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h new file mode 100644 index 00000000000..2e5a444bec9 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/components/serial_proxy/serial_proxy.h" +#include "esphome/core/component.h" +#include "ash_acknowledger.h" + +namespace esphome::ezsp_proxy_tap { + +// Acknowledges the ASH frames of an EZSP NCP on behalf of a remote client, so the NCP's +// ack timeout is measured against this device rather than against the network round trip +// to the client. The client suppresses its own acknowledgements, making these the only +// ones the NCP sees. +// +// It never carries client traffic: the serial proxy owns the port and the bytes, and this +// component only observes them. The sole exception is the acknowledgement itself, sent +// only while a client has put the port in PROTOCOL mode, which only an EZSP client does. +class EzspProxyTap : public serial_proxy::SerialProxyTap, public Component { + public: + explicit EzspProxyTap(serial_proxy::SerialProxy *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + // SerialProxyTap + void on_device_rx(const uint8_t *data, size_t len) override; + // ACKs depend only on what the NCP sends + void on_client_tx(const uint8_t *data, size_t len) override {} + // Acknowledging is only ever useful on a client's behalf, so with nobody subscribed + // there is nothing to do and the port need not be read. + bool tap_needs_port() const override { return false; } + void on_protocol_enabled() override; + // Nothing is acknowledged outside PROTOCOL mode, and entering it starts afresh + void on_protocol_disabled() override {} + void on_device_disconnected() override; + + protected: + // The port this component observes. Owns the UART and the bytes; every write we make + // goes through it. + serial_proxy::SerialProxy *parent_; + + AshAcknowledger acknowledger_; +}; + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index d512362fa01..9623eb21b3c 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -277,10 +277,16 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); #ifdef USE_SERIAL_PROXY_TAP + const bool entering_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_PROTOCOL && mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL; const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; this->mode_ = mode; + if (entering_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_enabled(); + } + // Only for an explicit client request, not for reset_mode_() at the end of a session: // an ordinary disconnect says nothing about the device, whereas a client deliberately // asking for raw bytes usually precedes changing what the device is. @@ -426,6 +432,14 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: void SerialProxy::on_usb_connection_changed_(bool connected) { ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]", connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_); +#ifdef USE_SERIAL_PROXY_TAP + // Before telling clients, so a tap never acknowledges a frame from the old device after + // a client has been told it is gone. The subscriber and the mode stay: both belong to + // the client's session, and only the client knows whether that session is over. + if (!connected && this->tap_ != nullptr) { + this->tap_->on_device_disconnected(); + } +#endif #ifdef USE_API if (api::global_api_server == nullptr) { return; diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 1a406bc0891..2b8f933f889 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -89,11 +89,20 @@ class SerialProxyTap { /// subscriber holds the port; with one attached, the port mode alone decides. virtual bool tap_needs_port() const = 0; + /// A client turned protocol handling on for this port. The tap has seen none of the + /// traffic since it was last on, so anything it remembers from then is stale. + virtual void on_protocol_enabled() = 0; + /// A client explicitly turned protocol handling off for this port. Distinct from the /// automatic reset when a session ends: this one means a client intends to do something /// else with the device -- reflash it, most likely -- so anything the tap believes about /// it should be treated as suspect. virtual void on_protocol_disabled() = 0; + + /// The device behind the port went away -- unplugged, or its power was cut. Whatever + /// session the tap had observed ended with it; the device that appears next starts from + /// scratch and may not even be the same one. Only a USB UART can report this. + virtual void on_device_disconnected() = 0; }; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 87dc703b375..a1b32b39f55 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -73,6 +73,7 @@ #define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP_NOW_HOSTED #define USE_EVENT +#define USE_EZSP_PROXY_TAP #define USE_FAN #define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT diff --git a/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp new file mode 100644 index 00000000000..5ee22ae9d46 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp @@ -0,0 +1,185 @@ +#include + +#include +#include +#include + +#include "esphome/components/ezsp_proxy_tap/ash_acknowledger.h" +#include "esphome/components/ezsp_proxy_tap/ash_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap::testing { + +namespace { + +// Whole frames as they appear on the wire, each ending in its FLAG. RSTACK and ACK(1) +// are the examples in Silicon Labs UG101. +const std::vector RSTACK = {0xC1, 0x02, 0x02, 0x9B, 0x7B, 0x7E}; +const std::vector DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0x50, 0x9B, 0x98, 0x7E}; +const std::vector DATA_FRAME_1 = {0x10, 0x43, 0x21, 0xA8, 0x50, 0x9F, 0xC2, 0x7E}; +const std::vector DATA_FRAME_0_RETRANSMITTED = {0x08, 0x43, 0x21, 0xA8, 0x50, 0x99, 0xB5, 0x7E}; +const std::vector DATA_FRAME_5 = {0x50, 0x43, 0x21, 0xA8, 0x50, 0x8E, 0xAA, 0x7E}; +const std::vector DATA_FRAME_5_RETRANSMITTED = {0x58, 0x43, 0x21, 0xA8, 0x50, 0x8C, 0x87, 0x7E}; +const std::vector DATA_FRAME_6 = {0x60, 0x43, 0x21, 0xA8, 0x50, 0x82, 0x44, 0x7E}; +// Data frame 0 with a 2-byte and a 3-byte data field, below and at the minimum +const std::vector SHORT_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA0, 0x40, 0x7E}; +const std::vector MIN_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0xC1, 0x08, 0x7E}; +// Body {0x00, 0x7E, 0x7D, 0x11, 0x13, 0x18, 0x1A}: every reserved byte, each escaped +const std::vector ESCAPED_FRAME = {0x00, 0x7D, 0x5E, 0x7D, 0x5D, 0x7D, 0x31, 0x7D, + 0x33, 0x7D, 0x38, 0x7D, 0x3A, 0x46, 0x8C, 0x7E}; + +const std::vector RESERVED_BYTES = {ASH_FLAG_BYTE, ASH_ESCAPE_BYTE, ASH_XON_BYTE, + ASH_XOFF_BYTE, ASH_SUBSTITUTE_BYTE, ASH_CANCEL_BYTE}; + +// Data frame 0 with `data_size` bytes of 0x42 and a valid CRC, none of which needs escaping +std::vector unstuffed_frame(size_t data_size) { + std::vector frame(1 + data_size, 0x42); + frame[0] = 0x00; + const uint16_t crc = crc16be(frame.data(), frame.size(), ASH_CRC_INIT); + frame.push_back(crc >> 8); + frame.push_back(crc & 0xFF); + frame.push_back(ASH_FLAG_BYTE); + return frame; +} + +std::vector concat(std::vector a, const std::vector &b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +// Returns whether the last byte completed a valid frame +bool feed(AshFrameScanner &scanner, const std::vector &bytes) { + bool complete = false; + for (uint8_t byte : bytes) + complete = scanner.feed(byte); + return complete; +} + +// Feeds whole frames and returns the ACK owed after the last, or -1 if none is +int feed(AshAcknowledger &acknowledger, const std::vector &bytes) { + bool owed = false; + for (uint8_t byte : bytes) + owed = acknowledger.feed(byte); + return owed ? acknowledger.ack_num() : -1; +} + +} // namespace + +TEST(AshProtocol, AckFrameMatchesReference) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, 1); + EXPECT_EQ(std::vector(frame, frame + sizeof(frame)), (std::vector{0x7E, 0x81, 0x60, 0x59, 0x7E})); +} + +TEST(AshProtocol, AckFramesNeedNoEscaping) { + for (uint8_t ack_num = 0; ack_num <= ASH_MAX_SEQUENCE; ack_num++) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, ack_num); + EXPECT_EQ(frame[0], ASH_FLAG_BYTE); + EXPECT_EQ(frame[4], ASH_FLAG_BYTE); + for (size_t i = 1; i < 4; i++) { + EXPECT_EQ(std::count(RESERVED_BYTES.begin(), RESERVED_BYTES.end(), frame[i]), 0) + << "ack_num " << int(ack_num) << " byte " << i; + } + } +} + +TEST(AshFrameScanner, UnescapesReservedBytes) { + AshFrameScanner scanner; + // The CRC covers the unescaped body, so a valid frame proves every byte was restored + ASSERT_TRUE(feed(scanner, ESCAPED_FRAME)); + EXPECT_EQ(scanner.length(), 7u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x7E})); +} + +TEST(AshFrameScanner, RejectsBadCrc) { + AshFrameScanner scanner; + std::vector corrupt = DATA_FRAME_0; + corrupt[corrupt.size() - 2] ^= 0x01; + EXPECT_FALSE(feed(scanner, corrupt)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelDiscardsPartialFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, SubstituteDiscardsUntilFlag) { + AshFrameScanner scanner; + std::vector substituted = DATA_FRAME_0; + substituted.insert(substituted.begin() + 2, ASH_SUBSTITUTE_BYTE); + EXPECT_FALSE(feed(scanner, substituted)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelEndsSubstitute) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, ASH_SUBSTITUTE_BYTE, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); +} + +TEST(AshFrameScanner, IgnoresWakeBetweenFrames) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({ASH_WAKE_BYTE, ASH_FLAG_BYTE, ASH_WAKE_BYTE, ASH_WAKE_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, IgnoresFlowControlInsideFrame) { + AshFrameScanner scanner; + std::vector interrupted = DATA_FRAME_0; + interrupted.insert(interrupted.begin() + 3, ASH_XOFF_BYTE); + interrupted.insert(interrupted.begin() + 1, ASH_XON_BYTE); + ASSERT_TRUE(feed(scanner, interrupted)); + EXPECT_EQ(scanner.length(), 5u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x43})); +} + +TEST(AshFrameScanner, RejectsOversizedFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE))); + EXPECT_EQ(scanner.length(), 1 + ASH_MAX_DATA_FIELD_SIZE); + EXPECT_FALSE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE + 1))); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshAcknowledger, AcknowledgesFramesInSequence) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + // A repeat not marked as a retransmission is out of sequence + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), -1); + // A retransmission means the NCP missed our ACK, so it gets the same one again + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0_RETRANSMITTED), 1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_1), 2); +} + +TEST(AshAcknowledger, RstackRestartsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + EXPECT_EQ(feed(acknowledger, RSTACK), -1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, IgnoresShortDataFrames) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, SHORT_DATA_FRAME_0), -1); + EXPECT_EQ(feed(acknowledger, MIN_DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, SyncsToFirstFrameMidSession) { + AshAcknowledger acknowledger; + // The NCP is retransmitting a frame nobody acknowledged before the tap was watching + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5_RETRANSMITTED), 6); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_6), 7); +} + +TEST(AshAcknowledger, ResetForgetsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + acknowledger.reset(); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5), 6); +} + +} // namespace esphome::ezsp_proxy_tap::testing diff --git a/tests/components/ezsp_proxy_tap/common.yaml b/tests/components/ezsp_proxy_tap/common.yaml new file mode 100644 index 00000000000..64391dc080b --- /dev/null +++ b/tests/components/ezsp_proxy_tap/common.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +# The port owns the UART and carries every byte; ezsp_proxy_tap only taps it +serial_proxy: + - id: ezsp_serial + name: Zigbee + port_type: TTL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_serial diff --git a/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml new file mode 100644 index 00000000000..4c64260e869 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,27 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +usb_host: + +usb_uart: + - type: CDC_ACM + vid: 0x303A + pid: 0x831A + channels: + - id: ezsp_usb_channel + baud_rate: 460800 + +# The tapped port may be a USB CDC ACM channel just as well as a hardware UART: +# ezsp_proxy_tap never touches the UART itself, so it does not care which it is. +serial_proxy: + - id: ezsp_usb_serial + uart_id: ezsp_usb_channel + name: Zigbee + port_type: USB_SERIAL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_usb_serial diff --git a/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml new file mode 100644 index 00000000000..afdbf0e6760 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml new file mode 100644 index 00000000000..fd21fecf7f6 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml new file mode 100644 index 00000000000..a979e3fda19 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + ezsp_proxy_tap: !include common.yaml From 88668f448e0638149e000963867867c5b0d38b88 Mon Sep 17 00:00:00 2001 From: B48D81EFCC <111175947+B48D81EFCC@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:53:16 +0900 Subject: [PATCH 407/435] [max17048] Add MAX17048 Li+ Cell Fuel Gauge IC (#13585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Martín Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/max17048/__init__.py | 2 + esphome/components/max17048/max17048.cpp | 77 +++++++++++++++++++ esphome/components/max17048/max17048.h | 20 +++++ esphome/components/max17048/sensor.py | 66 ++++++++++++++++ tests/components/max17048/common.yaml | 8 ++ tests/components/max17048/test.esp32-idf.yaml | 3 + .../components/max17048/test.esp8266-ard.yaml | 3 + .../components/max17048/test.rp2040-ard.yaml | 3 + 9 files changed, 183 insertions(+) create mode 100644 esphome/components/max17048/__init__.py create mode 100644 esphome/components/max17048/max17048.cpp create mode 100644 esphome/components/max17048/max17048.h create mode 100644 esphome/components/max17048/sensor.py create mode 100644 tests/components/max17048/common.yaml create mode 100644 tests/components/max17048/test.esp32-idf.yaml create mode 100644 tests/components/max17048/test.esp8266-ard.yaml create mode 100644 tests/components/max17048/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 23bac1efae1..050f4d19d92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -343,6 +343,7 @@ esphome/components/m5stack_8angle/* @rnauber esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell +esphome/components/max17048/* @B48D81EFCC esphome/components/max31865/* @DAVe3283 esphome/components/max31888/* @JiriPrchal esphome/components/max44009/* @berfenger diff --git a/esphome/components/max17048/__init__.py b/esphome/components/max17048/__init__.py new file mode 100644 index 00000000000..ecd6a79dd5c --- /dev/null +++ b/esphome/components/max17048/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@B48D81EFCC"] +DOMAIN = "max17048" diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp new file mode 100644 index 00000000000..67f9dbaac3d --- /dev/null +++ b/esphome/components/max17048/max17048.cpp @@ -0,0 +1,77 @@ +#include "max17048.h" +#include "esphome/core/log.h" + +namespace esphome::max17048 { + +// MAX17048/MAX17049 1 and 2 cell fuel gauges with ModelGauge +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/MAX17048-MAX17049.pdf + +ESPHOME_LOG_TAG(TAG, "max17048"); + +static constexpr uint8_t REG_VCELL = 0x02; // cell voltage +static constexpr uint8_t REG_SOC = 0x04; // state of charge +static constexpr uint8_t REG_VERSION = 0x08; // 0x001x +static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed + +static constexpr uint16_t VERSION_MASK = 0xFFF0; +static constexpr uint16_t VERSION_FAMILY = 0x0010; + +static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f; +static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f; +static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f; + +void MAX17048Component::setup() { + uint16_t version; + if (!this->read_byte_16(REG_VERSION, &version)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((version & VERSION_MASK) != VERSION_FAMILY) { + ESP_LOGW(TAG, "Unexpected version 0x%04X", version); + } +} + +void MAX17048Component::update() { + bool ok = true; + uint16_t raw; + if (this->battery_voltage_sensor_ != nullptr) { + if (this->read_byte_16(REG_VCELL, &raw)) { + this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB); + } else { + ok = false; + } + } + if (this->battery_level_sensor_ != nullptr) { + if (this->read_byte_16(REG_SOC, &raw)) { + this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB); + } else { + ok = false; + } + } + if (this->rate_sensor_ != nullptr) { + if (this->read_byte_16(REG_CRATE, &raw)) { + this->rate_sensor_->publish_state(static_cast(raw) * CRATE_PERCENT_PER_HOUR_PER_LSB); + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Register read failed")); + } +} + +void MAX17048Component::dump_config() { + ESP_LOGCONFIG(TAG, "MAX17048:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Rate", this->rate_sensor_); +} + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/max17048.h b/esphome/components/max17048/max17048.h new file mode 100644 index 00000000000..906d4a7c3f3 --- /dev/null +++ b/esphome/components/max17048/max17048.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::max17048 { + +class MAX17048Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(battery_voltage) + SUB_SENSOR(battery_level) + SUB_SENSOR(rate) + + public: + void setup() override; + void dump_config() override; + void update() override; +}; + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py new file mode 100644 index 00000000000..30cd0ec59f0 --- /dev/null +++ b/esphome/components/max17048/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_BATTERY_VOLTAGE, + CONF_ID, + CONF_RATE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +UNIT_PERCENT_PER_HOUR = "%/h" + +max17048_ns = cg.esphome_ns.namespace("max17048") +MAX17048Component = max17048_ns.class_( + "MAX17048Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MAX17048Component), + cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT_PER_HOUR, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x36)) +) + + +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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_RATE, var.set_rate_sensor) diff --git a/tests/components/max17048/common.yaml b/tests/components/max17048/common.yaml new file mode 100644 index 00000000000..de0247ed4e2 --- /dev/null +++ b/tests/components/max17048/common.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: max17048 + battery_voltage: + name: Battery Voltage + battery_level: + name: Battery Level + rate: + name: Battery Rate diff --git a/tests/components/max17048/test.esp32-idf.yaml b/tests/components/max17048/test.esp32-idf.yaml new file mode 100644 index 00000000000..e9930c953d3 --- /dev/null +++ b/tests/components/max17048/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.esp8266-ard.yaml b/tests/components/max17048/test.esp8266-ard.yaml new file mode 100644 index 00000000000..95f0f76f143 --- /dev/null +++ b/tests/components/max17048/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.rp2040-ard.yaml b/tests/components/max17048/test.rp2040-ard.yaml new file mode 100644 index 00000000000..7f24480cdce --- /dev/null +++ b/tests/components/max17048/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + max17048: !include common.yaml From d08d324a8342897c9fce17e1d45cfc3a67375e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Tue, 13 Oct 2026 00:54:35 +0200 Subject: [PATCH 408/435] [bq25186] Add bq25186 1-Cell, 1A Linear Battery Charger component (part 1) (#15700) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq25186/__init__.py | 314 ++++++++++++++++++ esphome/components/bq25186/bq25186.cpp | 79 +++++ esphome/components/bq25186/bq25186.h | 44 +++ tests/component_tests/bq25186/__init__.py | 0 .../bq25186/test_pack_registers.py | 87 +++++ tests/components/bq25186/common.yaml | 25 ++ tests/components/bq25186/test.esp32-idf.yaml | 3 + .../components/bq25186/test.esp8266-ard.yaml | 3 + 9 files changed, 556 insertions(+) create mode 100644 esphome/components/bq25186/__init__.py create mode 100644 esphome/components/bq25186/bq25186.cpp create mode 100644 esphome/components/bq25186/bq25186.h create mode 100644 tests/component_tests/bq25186/__init__.py create mode 100644 tests/component_tests/bq25186/test_pack_registers.py create mode 100644 tests/components/bq25186/common.yaml create mode 100644 tests/components/bq25186/test.esp32-idf.yaml create mode 100644 tests/components/bq25186/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 050f4d19d92..d5866286fdb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -101,6 +101,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq25186/* @Rapsssito esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py new file mode 100644 index 00000000000..e57860786f5 --- /dev/null +++ b/esphome/components/bq25186/__init__.py @@ -0,0 +1,314 @@ +from collections.abc import Callable +from typing import Any + +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.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@Rapsssito"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bq25186" +MULTI_CONF = True + +CONF_CONFIGURE_ON_BOOT = "configure_on_boot" +CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" +CONF_FAST_CHARGE_CURRENT = "fast_charge_current" +CONF_CHARGE_ENABLED = "charge_enabled" +CONF_FLASH_CHARGING_MODE = "flash_charging_mode" +CONF_PRECHARGE_IS_ITERM = "precharge_is_iterm" +CONF_TERMINATION_PERCENT = "termination_percent" +CONF_VINDPM_MODE = "vindpm_mode" +CONF_THERMAL_REGULATION = "thermal_regulation" +CONF_BATTERY_OCP_LIMIT = "battery_ocp_limit" +CONF_BATTERY_UVLO = "battery_uvlo" +CONF_MASK_CHARGE_STATUS_INTERRUPT = "mask_charge_status_interrupt" +CONF_MASK_ILIM_INTERRUPT = "mask_ilim_interrupt" +CONF_MASK_VINDPM_INTERRUPT = "mask_vindpm_interrupt" +CONF_TS_AUTO_FUNCTION = "ts_auto_function" +CONF_VLOWV = "vlowv" +CONF_RECHARGE_THRESHOLD = "recharge_threshold" +CONF_DOUBLE_TIMER_DURING_DPM = "double_timer_during_dpm" +CONF_SAFETY_TIMER = "safety_timer" +CONF_LONG_PRESS_DURATION = "long_press_duration" +CONF_HW_RESET_REQUIRES_VIN = "hw_reset_requires_vin" +CONF_AUTOWAKE = "autowake" +CONF_INPUT_CURRENT_LIMIT = "input_current_limit" +CONF_PUSH_BUTTON_LONG_PRESS_ACTION = "push_button_long_press_action" +CONF_WAKE1_TIMER = "wake1_timer" +CONF_WAKE2_TIMER = "wake2_timer" +CONF_ENABLE_PUSH_BUTTON = "enable_push_button" +CONF_SYSTEM_REGULATION = "system_regulation" +CONF_SYS_MODE = "sys_mode" +CONF_DISABLE_VDPPM = "disable_vdppm" +CONF_TS_HOT = "ts_hot" +CONF_TS_COLD = "ts_cold" +CONF_TS_WARM_DISABLE = "ts_warm_disable" +CONF_TS_COOL_DISABLE = "ts_cool_disable" +CONF_TS_ICHG = "ts_ichg" +CONF_TS_VRCG = "ts_vrcg" +CONF_MASK_TS_INTERRUPT = "mask_ts_interrupt" +CONF_MASK_TREG_INTERRUPT = "mask_treg_interrupt" +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) + +REG_VBAT_CTRL = 0x03 +REG_ICHG_CTRL = 0x04 +REG_CHARGECTRL0 = 0x05 +REG_CHARGECTRL1 = 0x06 +REG_IC_CTRL = 0x07 +REG_TMR_ILIM = 0x08 +REG_SHIP_RST = 0x09 +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} +THERM_REGULATION_OPTIONS = {"100c": 0, "80c": 1, "60c": 2, "disabled": 3} +BATTERY_OCP_LIMIT_OPTIONS = {"500ma": 0, "1000ma": 1, "1500ma": 2, "3000ma": 3} +BATTERY_UVLO_OPTIONS = { + "3.0v": 0, + "2.8v": 3, + "2.6v": 4, + "2.4v": 5, + "2.2v": 6, + "2.0v": 7, +} +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} +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 = { + "50ma": 0, + "100ma": 1, + "200ma": 2, + "300ma": 3, + "400ma": 4, + "500ma": 5, + "665ma": 6, + "1050ma": 7, +} +PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS = { + "none": 0, + "hardware_reset": 1, + "ship_mode": 2, + "shutdown_mode": 3, +} +WAKE1_TIMER_OPTIONS = {"300ms": 0, "1s": 1} +WAKE2_TIMER_OPTIONS = {"2s": 0, "3s": 1} +SYSTEM_REGULATION_OPTIONS = { + "battery_tracking": 0, + "4.4v": 1, + "4.5v": 2, + "4.6v": 3, + "4.7v": 4, + "4.8v": 5, + "4.9v": 6, + "pass_through": 7, +} +SYS_MODE_OPTIONS = { + "normal": 0, + "battery_only": 1, + "floating_off": 2, + "pulldown_off": 3, +} +TS_HOT_OPTIONS = {"60c": 0, "65c": 1, "50c": 2, "45c": 3} +TS_COLD_OPTIONS = {"0c": 0, "3c": 1, "5c": 2, "-3c": 3} +TS_ICHG_OPTIONS = {"0.5x": 0, "0.2x": 1} +TS_VRCG_OPTIONS = {"-100mv": 0, "-200mv": 1} + + +def _on_voltage_step(volts: float) -> float: + millivolts = volts * 1000 + if abs(millivolts - round(millivolts / 10) * 10) > 1e-6: + raise cv.Invalid("battery_regulation_voltage must be a multiple of 10 mV") + return volts + + +def _on_current_step(amps: float) -> float: + milliamps = amps * 1000 + step = 1 if milliamps < 36 else 10 + if abs(milliamps - round(milliamps / step) * step) > 1e-6 or 35 < milliamps < 40: + raise cv.Invalid( + "fast_charge_current must be a whole number of mA up to 35mA, then a multiple of 10 mA" + ) + return amps + + +# (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. +FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( + ( + CONF_BATTERY_REGULATION_VOLTAGE, + REG_VBAT_CTRL, + 0, + cv.All(cv.voltage, cv.Range(min=3.5, max=4.65), _on_voltage_step), + "4.2V", + ), + (CONF_CHARGE_ENABLED, REG_ICHG_CTRL, 7, cv.boolean, True), + ( + CONF_FAST_CHARGE_CURRENT, + REG_ICHG_CTRL, + 0, + cv.All(cv.current, cv.Range(min=0.005, max=1.0), _on_current_step), + "10mA", + ), + (CONF_FLASH_CHARGING_MODE, REG_CHARGECTRL0, 7, cv.boolean, False), + (CONF_PRECHARGE_IS_ITERM, REG_CHARGECTRL0, 6, cv.boolean, False), + (CONF_TERMINATION_PERCENT, REG_CHARGECTRL0, 4, TERMINATION_PERCENT_OPTIONS, "10%"), + (CONF_VINDPM_MODE, REG_CHARGECTRL0, 2, VINDPM_MODE_OPTIONS, "4.5v"), + (CONF_THERMAL_REGULATION, REG_CHARGECTRL0, 0, THERM_REGULATION_OPTIONS, "100c"), + (CONF_BATTERY_OCP_LIMIT, REG_CHARGECTRL1, 6, BATTERY_OCP_LIMIT_OPTIONS, "3000ma"), + (CONF_BATTERY_UVLO, REG_CHARGECTRL1, 3, BATTERY_UVLO_OPTIONS, "3.0v"), + (CONF_MASK_CHARGE_STATUS_INTERRUPT, REG_CHARGECTRL1, 2, cv.boolean, True), + (CONF_MASK_ILIM_INTERRUPT, REG_CHARGECTRL1, 1, cv.boolean, True), + (CONF_MASK_VINDPM_INTERRUPT, REG_CHARGECTRL1, 0, cv.boolean, False), + (CONF_TS_AUTO_FUNCTION, REG_IC_CTRL, 7, cv.boolean, True), + (CONF_VLOWV, REG_IC_CTRL, 6, VLOWV_OPTIONS, "3.0v"), + (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_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"), + (CONF_INPUT_CURRENT_LIMIT, REG_TMR_ILIM, 0, INPUT_CURRENT_LIMIT_OPTIONS, "500ma"), + ( + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + REG_SHIP_RST, + 3, + PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS, + "ship_mode", + ), + (CONF_WAKE1_TIMER, REG_SHIP_RST, 2, WAKE1_TIMER_OPTIONS, "300ms"), + (CONF_WAKE2_TIMER, REG_SHIP_RST, 1, WAKE2_TIMER_OPTIONS, "2s"), + (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_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"), + (CONF_TS_WARM_DISABLE, REG_TS_CONTROL, 3, cv.boolean, False), + (CONF_TS_COOL_DISABLE, REG_TS_CONTROL, 2, cv.boolean, False), + (CONF_TS_ICHG, REG_TS_CONTROL, 1, TS_ICHG_OPTIONS, "0.5x"), + (CONF_TS_VRCG, REG_TS_CONTROL, 0, TS_VRCG_OPTIONS, "-100mv"), + (CONF_MASK_TS_INTERRUPT, REG_MASK_ID, 7, cv.boolean, False), + (CONF_MASK_TREG_INTERRUPT, REG_MASK_ID, 6, cv.boolean, True), + (CONF_MASK_BAT_INTERRUPT, REG_MASK_ID, 5, cv.boolean, False), + (CONF_MASK_PG_INTERRUPT, REG_MASK_ID, 4, cv.boolean, False), +) + + +def _battery_regulation_code(volts: float) -> int: + """VBAT_CTRL[6:0]: 3.5 V plus 10 mV per step.""" + return (round(volts * 1000) - 3500) // 10 + + +def _fast_charge_code(amps: float) -> int: + """ICHG_CTRL[6:0]: 1 mA steps from 5 mA to 35 mA, then 10 mA steps from 40 mA.""" + milliamps = round(amps * 1000) + if milliamps <= 35: + return milliamps - 5 + return milliamps // 10 + 27 + + +_ENCODERS: dict[str, Callable[[Any], int]] = { + CONF_BATTERY_REGULATION_VOLTAGE: _battery_regulation_code, + CONF_FAST_CHARGE_CURRENT: _fast_charge_code, + CONF_CHARGE_ENABLED: lambda enabled: not enabled, +} + + +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): + value = validator[value] + elif (encode := _ENCODERS.get(key)) is not None: + value = encode(value) + regs[register - REG_VBAT_CTRL] |= int(value) << shift + return regs + + +def _field_schema(with_defaults: bool) -> cv.Schema: + schema = {} + for key, _, _, validator, default in FIELDS: + if isinstance(validator, dict): + validator = cv.enum(validator, lower=True) + optional = ( + cv.Optional(key, default=default) if with_defaults else cv.Optional(key) + ) + schema[optional] = validator + return cv.Schema(schema) + + +BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ25186Component), + cv.Optional(CONF_CONFIGURE_ON_BOOT, default=True): cv.boolean, + } + ) + .extend(BQ25186_CONFIG_SCHEMA) + .extend(i2c.i2c_device_schema(0x6A)) +) + +CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(BQ25186Component), + } + ).extend(_field_schema(with_defaults=False)) +) + + +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) + + if config[CONF_CONFIGURE_ON_BOOT]: + cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config)))) + + +@automation.register_action( + "bq25186.apply_configuration", + automation.LambdaAction, + CONFIGURE_ACTION_SCHEMA, + synchronous=True, +) +async def bq25186_configure_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + parent = await cg.get_variable(config[CONF_ID]) + + # 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}) + + text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) + lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) + return automation.new_lambda_pvariable( + action_id, lambda_, automation.StatelessLambdaAction, template_arg + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp new file mode 100644 index 00000000000..e660ec63d14 --- /dev/null +++ b/esphome/components/bq25186/bq25186.cpp @@ -0,0 +1,79 @@ +#include "bq25186.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +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 +static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { + 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, +}; + +bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(reg, value)) { + ESP_LOGW(TAG, "Failed to write register 0x%02X", reg); + return false; + } + return true; +} + +bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t reg_value; + if (!this->read_byte(reg, ®_value)) { + ESP_LOGW(TAG, "Failed to read register 0x%02X", reg); + return false; + } + reg_value = (reg_value & ~mask) | (value & mask); + return this->write_register_(reg, reg_value); +} + +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; + } + 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]); + if (!ok) { + this->status_set_warning(LOG_STR("Configuration partly applied")); + return false; + } + } + this->status_clear_warning(); + return true; +} + +void BQ25186Component::setup() { + uint8_t mask_id; + if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Another charger at this address could lay its registers out differently, so do not write into it + const uint8_t device_id = mask_id & 0x0F; + if (device_id != 0x01) { + ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id); + this->mark_failed(LOG_STR("Not a BQ25186")); + return; + } + + // 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_); +} + +void BQ25186Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ25186:"); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h new file mode 100644 index 00000000000..a6920871475 --- /dev/null +++ b/esphome/components/bq25186/bq25186.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::bq25186 { + +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; +static constexpr uint8_t BQ25186_REG_CHARGECTRL1 = 0x06; +static constexpr uint8_t BQ25186_REG_IC_CTRL = 0x07; +static constexpr uint8_t BQ25186_REG_TMR_ILIM = 0x08; +static constexpr uint8_t BQ25186_REG_SHIP_RST = 0x09; +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; + +// Register values for VBAT_CTRL..MASK_ID, packed by code generation +using BQ25186ConfigRegisters = std::array; + +class BQ25186Component : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_setup_registers(const BQ25186ConfigRegisters ®s) { + this->setup_regs_ = regs; + this->configure_on_boot_ = true; + } + bool apply_config(const BQ25186ConfigRegisters ®s); + + protected: + bool write_register_(uint8_t reg, uint8_t value); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + BQ25186ConfigRegisters setup_regs_{}; + bool configure_on_boot_{false}; +}; + +} // namespace esphome::bq25186 diff --git a/tests/component_tests/bq25186/__init__.py b/tests/component_tests/bq25186/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py new file mode 100644 index 00000000000..2ebf6087e7b --- /dev/null +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -0,0 +1,87 @@ +"""Tests for the bq25186 register packer.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.bq25186 import ( + BQ25186_CONFIG_SCHEMA, + CONF_BATTERY_REGULATION_VOLTAGE, + CONF_CHARGE_ENABLED, + CONF_FAST_CHARGE_CURRENT, + CONF_MASK_TS_INTERRUPT, + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + REG_ICHG_CTRL, + REG_MASK_ID, + REG_SHIP_RST, + REG_VBAT_CTRL, + pack_registers, +) +from esphome.types import ConfigType + +# Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the +# watchdog disabled (0x87 instead of 0x84) and MASK_ID carries only its +# configurable bits 7:4 (0x40 instead of 0x41 with the device id). +DEFAULT_REGISTERS = [0x46, 0x05, 0x24, 0xC6, 0x87, 0x4D, 0x11, 0x40, 0x00, 0x40] + + +def _packed(reg: int, config: ConfigType | None = None) -> int: + regs = pack_registers(BQ25186_CONFIG_SCHEMA(config or {})) + return regs[reg - REG_VBAT_CTRL] + + +def test_defaults_match_power_on_values() -> None: + assert pack_registers(BQ25186_CONFIG_SCHEMA({})) == DEFAULT_REGISTERS + + +def test_battery_regulation_voltage_code() -> None: + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.1V"}) == 0x3C + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "3.5V"}) == 0x00 + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.65V"}) == 0x73 + + +def test_fast_charge_current_code() -> None: + # 1 mA steps from 5 mA to 35 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "5mA"}) == 0x00 + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "20mA"}) == 0x0F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "35mA"}) == 0x1E + # 10 mA steps from 40 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "40mA"}) == 0x1F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "200mA"}) == 0x2F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "1A"}) == 0x7F + + +def test_charge_disabled_sets_bit_7() -> None: + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: False}) == 0x85 + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: True}) == 0x05 + + +def test_masked_registers_only_carry_configurable_bits() -> None: + config: ConfigType = { + CONF_PUSH_BUTTON_LONG_PRESS_ACTION: "shutdown_mode", + CONF_MASK_TS_INTERRUPT: True, + } + # SHIP_RST bits 7:5 are reset triggers + ship_rst = _packed(REG_SHIP_RST, config) + assert ship_rst == 0x19 + assert ship_rst & ~0x1F == 0 + # MASK_ID bits 3:0 are the device id + mask_id = _packed(REG_MASK_ID, config) + assert mask_id == 0xC0 + assert mask_id & ~0xF0 == 0 + + +@pytest.mark.parametrize( + "key, value", + [ + (CONF_BATTERY_REGULATION_VOLTAGE, "4.105V"), + (CONF_FAST_CHARGE_CURRENT, "36mA"), + (CONF_FAST_CHARGE_CURRENT, "39mA"), + (CONF_FAST_CHARGE_CURRENT, "49mA"), + (CONF_FAST_CHARGE_CURRENT, "5.5mA"), + (CONF_FAST_CHARGE_CURRENT, "34.6mA"), + (CONF_BATTERY_REGULATION_VOLTAGE, "4.2005V"), + ], +) +def test_values_between_steps_are_rejected(key: str, value: str) -> None: + with pytest.raises(cv.Invalid): + BQ25186_CONFIG_SCHEMA({key: value}) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml new file mode 100644 index 00000000000..9f4b117cd3d --- /dev/null +++ b/tests/components/bq25186/common.yaml @@ -0,0 +1,25 @@ +esphome: + on_boot: + priority: -100 + then: + # Just the defined configuration + - bq25186.apply_configuration: bq25186_charger + # Just the defined configuration with some overrides + - bq25186.apply_configuration: + id: bq25186_charger + battery_regulation_voltage: 4.1V + fast_charge_current: 200mA + +bq25186: + - id: bq25186_charger + configure_on_boot: false + fast_charge_current: 150mA + battery_regulation_voltage: 4.2V + input_current_limit: 500ma + system_regulation: 4.5v + charge_enabled: true + # Second charger with the boot-time write enabled + - id: bq25186_boot + address: 0x6B + configure_on_boot: true + fast_charge_current: 20mA diff --git a/tests/components/bq25186/test.esp32-idf.yaml b/tests/components/bq25186/test.esp32-idf.yaml new file mode 100644 index 00000000000..8854333e249 --- /dev/null +++ b/tests/components/bq25186/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq25186: !include common.yaml diff --git a/tests/components/bq25186/test.esp8266-ard.yaml b/tests/components/bq25186/test.esp8266-ard.yaml new file mode 100644 index 00000000000..88ac37a74fa --- /dev/null +++ b/tests/components/bq25186/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq25186: !include common.yaml From 1e13b7f9736112cfc127e07e9d714874eae22054 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 13 Oct 2026 00:56:28 +0200 Subject: [PATCH 409/435] [tinyusb] Add `tinyusb_keyboard` component (#16079) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tinyusb/__init__.py | 2 +- .../components/tinyusb/tinyusb_component.cpp | 3 + .../components/tinyusb/tinyusb_component.h | 2 + .../components/tinyusb_keyboard/__init__.py | 110 ++++++++++++++++++ .../components/tinyusb_keyboard/keyboard.cpp | 90 ++++++++++++++ .../components/tinyusb_keyboard/keyboard.h | 66 +++++++++++ tests/components/tinyusb_keyboard/common.yaml | 43 +++++++ .../tinyusb_keyboard/test.esp32-s3-idf.yaml | 2 + 9 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 esphome/components/tinyusb_keyboard/__init__.py create mode 100644 esphome/components/tinyusb_keyboard/keyboard.cpp create mode 100644 esphome/components/tinyusb_keyboard/keyboard.h create mode 100644 tests/components/tinyusb_keyboard/common.yaml create mode 100644 tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d5866286fdb..d18ad6e6b4f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -605,6 +605,7 @@ esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core esphome/components/tinyusb/* @kbx81 +esphome/components/tinyusb_keyboard/* @frederikbolding esphome/components/tlc5947/* @rnauber esphome/components/tlc5971/* @IJIJI esphome/components/tm1621/* @Philippe12 diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 586b69d603b..92a8e439d2a 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -34,7 +34,7 @@ CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # esp_tinyusb's driver install (descriptors_set fails with no class and no # user-provided full_speed_config), which trips the task watchdog before # loop() ever runs. -_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) +_USB_CLASS_COMPONENTS = ("usb_cdc_acm", "tinyusb_keyboard") tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 14291fb3284..ba2fbded92e 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,8 @@ void TinyUSB::setup() { } // Start from esp_tinyusb defaults to keep required task settings valid across esp_tinyusb updates. + // Setters write into tusb_cfg_ before setup(), so anything they set has to survive the reset. + const uint8_t *full_speed_config = this->tusb_cfg_.descriptor.full_speed_config; this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; @@ -39,6 +41,7 @@ void TinyUSB::setup() { .device = &this->usb_descriptor_, .string = this->string_descriptor_, .string_count = SIZE, + .full_speed_config = full_speed_config, }; // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 030356bc03e..e9f7283b01d 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -49,6 +49,8 @@ class TinyUSB final : public Component { void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } + /// Configuration descriptor from a class component that esp_tinyusb has no built-in default for (HID). + void set_full_speed_config(const uint8_t *descriptor) { this->tusb_cfg_.descriptor.full_speed_config = descriptor; } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english diff --git a/esphome/components/tinyusb_keyboard/__init__.py b/esphome/components/tinyusb_keyboard/__init__.py new file mode 100644 index 00000000000..18b49a95bd3 --- /dev/null +++ b/esphome/components/tinyusb_keyboard/__init__.py @@ -0,0 +1,110 @@ +import string + +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, +) +from esphome.components.tinyusb import TinyUSB +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_KEY +from esphome.types import ConfigType + +CODEOWNERS = ["@frederikbolding"] +# The keyboard supplies the whole configuration descriptor, with no room for the CDC interfaces +CONFLICTS_WITH = ["usb_cdc_acm"] +DEPENDENCIES = ["tinyusb"] +DOMAIN = "tinyusb_keyboard" + +CONF_MODIFIERS = "modifiers" +CONF_TINYUSB_ID = "tinyusb_id" +CONF_USAGE = "usage" + +# Keyboard page usage ids run without gaps from HID_KEY_A (0x04) through a..z, 1..9 and 0 +HID_KEY_A = 0x04 +KEY_CODES = { + char: HID_KEY_A + index + for index, char in enumerate(string.ascii_lowercase + "1234567890") +} + +tinyusb_keyboard_ns = cg.esphome_ns.namespace("tinyusb_keyboard") +TinyUSBKeyboard = tinyusb_keyboard_ns.class_("TinyUSBKeyboard", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TinyUSBKeyboard), + cv.GenerateID(CONF_TINYUSB_ID): cv.use_id(TinyUSB), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), +) + +_KEY_CODE = cv.Any(cv.enum(KEY_CODES), cv.int_range(min=10, max=255)) + + +def key_code(value: object) -> int: + """A single lowercase letter or digit, or a keyboard usage id. + + Usage ids 0 to 9 are never typed keys (error codes and the letters a to f), so an unquoted + digit is read as that digit. + """ + if isinstance(value, int) and 0 <= value <= 9: + value = str(value) + return _KEY_CODE(value) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + tinyusb = await cg.get_variable(config[CONF_TINYUSB_ID]) + cg.add(tinyusb.set_full_speed_config(tinyusb_keyboard_ns.CONFIGURATION_DESCRIPTOR)) + add_idf_sdkconfig_option("CONFIG_TINYUSB_HID_COUNT", 1) + + +KEYBOARD_ID_SCHEMA = {cv.GenerateID(): cv.use_id(TinyUSBKeyboard)} + +automation.register_apply_action( + "tinyusb_keyboard.press", + automation.maybe_simple_id( + { + **KEYBOARD_ID_SCHEMA, + cv.Required(CONF_KEY): cv.templatable(key_code), + cv.Optional(CONF_MODIFIERS, default=0): cv.templatable(cv.uint8_t), + } + ), + automation.ApplyCall( + "press_key({}, {})", ((CONF_KEY, cg.uint8), (CONF_MODIFIERS, cg.uint8)) + ), +) +automation.register_apply_action( + "tinyusb_keyboard.release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_keys()"), +) +automation.register_apply_action( + "tinyusb_keyboard.media_press", + automation.maybe_simple_id( + {**KEYBOARD_ID_SCHEMA, cv.Required(CONF_USAGE): cv.templatable(cv.uint16_t)} + ), + automation.ApplyCall("press_media({})", ((CONF_USAGE, cg.uint16),)), +) +automation.register_apply_action( + "tinyusb_keyboard.media_release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_media()"), +) diff --git a/esphome/components/tinyusb_keyboard/keyboard.cpp b/esphome/components/tinyusb_keyboard/keyboard.cpp new file mode 100644 index 00000000000..6d2be7f7eba --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.cpp @@ -0,0 +1,90 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "keyboard.h" +#include "esphome/core/log.h" + +namespace esphome::tinyusb_keyboard { + +ESPHOME_LOG_TAG(TAG, "tinyusb_keyboard"); + +void TinyUSBKeyboard::dump_config() { ESP_LOGCONFIG(TAG, "TinyUSB Keyboard"); } + +void TinyUSBKeyboard::set_keyboard_(uint8_t keycode, uint8_t modifiers) { + this->keycode_ = keycode; + this->modifiers_ = modifiers; + this->keyboard_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::set_consumer_(uint16_t usage) { + this->usage_ = usage; + this->consumer_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::flush_() { + if (!tud_mounted()) { + // No host is listening, and a key held from before it attached would be wrong, so drop the state + ESP_LOGV(TAG, "No host, dropping report"); + this->keyboard_pending_ = false; + this->consumer_pending_ = false; + this->wakeup_requested_ = false; + this->disable_loop(); + return; + } + if (tud_suspended()) { + // A key press wakes a sleeping host, like a real keyboard; the report goes out once the bus resumes. + // Signal once per suspend: a host that did not arm remote wakeup will not answer, and one that did + // must not be signalled on every pass. + if (!this->wakeup_requested_) { + this->wakeup_requested_ = true; + if (!tud_remote_wakeup()) { + ESP_LOGV(TAG, "Host did not allow remote wakeup"); + } + } + this->enable_loop(); + return; + } + this->wakeup_requested_ = false; + // tud_hid_*_report() returns false while the endpoint still holds the previous report, which is + // what happens for a press and a release in one action list. Keep retrying so the release gets out. + if (this->keyboard_pending_) { + uint8_t keycodes[6] = {this->keycode_}; + if (tud_hid_keyboard_report(REPORT_ID_KEYBOARD, this->modifiers_, keycodes)) { + this->keyboard_pending_ = false; + } + } + if (this->consumer_pending_ && !this->keyboard_pending_ && + tud_hid_report(REPORT_ID_CONSUMER, &this->usage_, sizeof(this->usage_))) { + this->consumer_pending_ = false; + } + if (this->keyboard_pending_ || this->consumer_pending_) { + this->enable_loop(); + } else { + this->disable_loop(); + } +} + +void TinyUSBKeyboard::loop() { this->flush_(); } + +} // namespace esphome::tinyusb_keyboard + +// TinyUSB resolves these at link time when HID is enabled +extern "C" { +const uint8_t *tud_hid_descriptor_report_cb(uint8_t /*instance*/) { + return esphome::tinyusb_keyboard::HID_REPORT_DESCRIPTOR; +} + +uint16_t tud_hid_get_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + uint8_t * /*buffer*/, uint16_t /*reqlen*/) { + return 0; +} + +// Keyboard LED state from the host; nothing consumes it yet +void tud_hid_set_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + const uint8_t * /*buffer*/, uint16_t /*bufsize*/) {} +} + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb_keyboard/keyboard.h b/esphome/components/tinyusb_keyboard/keyboard.h new file mode 100644 index 00000000000..4c86ee599c2 --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.h @@ -0,0 +1,66 @@ +#pragma once + +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "esphome/core/component.h" + +#include "tusb.h" +// tusb.h only pulls the HID class in when CFG_TUD_HID is set, which the static analysis build does not do +#include "class/hid/hid_device.h" + +namespace esphome::tinyusb_keyboard { + +static constexpr uint8_t REPORT_ID_KEYBOARD = 1; +static constexpr uint8_t REPORT_ID_CONSUMER = 2; +static constexpr uint8_t ENDPOINT_IN = 0x81; +static constexpr uint8_t POLL_INTERVAL_MS = 10; + +// One HID interface carrying a keyboard and a consumer control (media keys), told apart by report id. +// Report ids rule out the boot protocol, so the interface declares no boot protocol, as TinyUSB's +// composite example does. +inline constexpr uint8_t HID_REPORT_DESCRIPTOR[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), + TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)), +}; + +// esp_tinyusb ships default configuration descriptors for CDC, MSC and NCM only, so the keyboard supplies its own +inline constexpr uint8_t CONFIGURATION_DESCRIPTOR[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(HID_REPORT_DESCRIPTOR), ENDPOINT_IN, CFG_TUD_HID_EP_BUFSIZE, + POLL_INTERVAL_MS), +}; + +class TinyUSBKeyboard final : public Component { + public: + void setup() override { this->disable_loop(); } + void loop() override; + void dump_config() override; + + /// Press one key (HID usage id) with the given modifier bitmask; every other key is reported released. + void press_key(uint8_t keycode, uint8_t modifiers) { this->set_keyboard_(keycode, modifiers); } + void release_keys() { this->set_keyboard_(0, 0); } + /// Press one consumer control usage, for example 0xE9 for volume up. + void press_media(uint16_t usage) { this->set_consumer_(usage); } + void release_media() { this->set_consumer_(0); } + + protected: + void set_keyboard_(uint8_t keycode, uint8_t modifiers); + void set_consumer_(uint16_t usage); + /// Sends whatever changed; a report the endpoint cannot take yet is retried from loop() + void flush_(); + + // The wanted state of each report, resent until the endpoint accepts it so a release is never lost. + // Only the latest state is kept, so consecutive key actions need a short delay between them. + uint16_t usage_{0}; + uint8_t keycode_{0}; + uint8_t modifiers_{0}; + bool keyboard_pending_{false}; + bool consumer_pending_{false}; + bool wakeup_requested_{false}; +}; + +} // namespace esphome::tinyusb_keyboard + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/tests/components/tinyusb_keyboard/common.yaml b/tests/components/tinyusb_keyboard/common.yaml new file mode 100644 index 00000000000..8982d61b4a2 --- /dev/null +++ b/tests/components/tinyusb_keyboard/common.yaml @@ -0,0 +1,43 @@ +tinyusb: + +tinyusb_keyboard: + id: kb + +binary_sensor: + - platform: template + id: kb_letter + on_press: + - tinyusb_keyboard.press: + id: kb + key: a + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_shift_digit + on_press: + - tinyusb_keyboard.press: + id: kb + key: "1" + modifiers: 0x02 # Left Shift + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_raw_code + on_press: + - tinyusb_keyboard.press: + id: kb + key: 0x28 # Enter + modifiers: !lambda return 0; + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_volume_up + on_press: + - tinyusb_keyboard.media_press: + id: kb + usage: 0xE9 # Volume Up + on_release: + - tinyusb_keyboard.media_release: kb diff --git a/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..763cc106fcc --- /dev/null +++ b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + tinyusb_keyboard: !include common.yaml From 150e32c4bedf3e86d9b4e40b6a98881356b306c3 Mon Sep 17 00:00:00 2001 From: Celsius Aray Date: Mon, 12 Oct 2026 18:56:50 -0400 Subject: [PATCH 410/435] [renogy_inverter_ble] Add Renogy inverter BLE sensor component (#16754) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../renogy_inverter_ble/__init__.py | 40 +++ .../renogy_inverter_ble.cpp | 248 ++++++++++++++++++ .../renogy_inverter_ble/renogy_inverter_ble.h | 59 +++++ .../components/renogy_inverter_ble/sensor.py | 99 +++++++ .../renogy_inverter_ble/common.yaml | 31 +++ .../renogy_inverter_ble/test.esp32-idf.yaml | 3 + 7 files changed, 481 insertions(+) create mode 100644 esphome/components/renogy_inverter_ble/__init__.py create mode 100644 esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp create mode 100644 esphome/components/renogy_inverter_ble/renogy_inverter_ble.h create mode 100644 esphome/components/renogy_inverter_ble/sensor.py create mode 100644 tests/components/renogy_inverter_ble/common.yaml create mode 100644 tests/components/renogy_inverter_ble/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d18ad6e6b4f..826c5e70038 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet esphome/components/rd03d/* @jasstrong +esphome/components/renogy_inverter_ble/* @emilioaray-dev esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz diff --git a/esphome/components/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py new file mode 100644 index 00000000000..808f27a67a8 --- /dev/null +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import ble_client +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["ble_client"] +DOMAIN = "renogy_inverter_ble" +MULTI_CONF = True + +CONF_RENOGY_INVERTER_BLE_ID = "renogy_inverter_ble_id" + +renogy_inverter_ble_ns = cg.esphome_ns.namespace(DOMAIN) +RenogyInverterBle = renogy_inverter_ble_ns.class_( + "RenogyInverterBle", ble_client.BLEClientNode, cg.PollingComponent +) + +RENOGY_INVERTER_BLE_COMPONENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RENOGY_INVERTER_BLE_ID): cv.use_id(RenogyInverterBle), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RenogyInverterBle), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + + +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) diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp new file mode 100644 index 00000000000..4cb43b5efbd --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp @@ -0,0 +1,248 @@ +#include "renogy_inverter_ble.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::renogy_inverter_ble { + +ESPHOME_LOG_TAG(TAG, "renogy_inverter_ble"); + +static constexpr uint16_t SERVICE_NOTIFY_UUID = 0xFFF0; +static constexpr uint16_t CHAR_NOTIFY_UUID = 0xFFF1; +static constexpr uint16_t SERVICE_WRITE_UUID = 0xFFD0; +static constexpr uint16_t CHAR_WRITE_UUID = 0xFFD1; +static constexpr uint16_t CHAR_INIT_UUID = 0xFFD4; + +static constexpr uint8_t MODBUS_DEVICE_ID = 0x20; +static constexpr uint8_t MODBUS_READ_HOLDING = 0x03; +static constexpr uint16_t REG_MAIN = 4000; +static constexpr uint16_t REG_MAIN_WORDS = 32; +static constexpr uint16_t REG_LOAD = 4408; +static constexpr uint16_t REG_LOAD_WORDS = 6; + +// Watchdog: returns the cycle to IDLE when the inverter stops answering +static constexpr uint32_t CYCLE_TIMEOUT_MS = 3000; +// Gap the inverter needs between two consecutive register reads +static constexpr uint32_t LOAD_READ_DELAY_MS = 300; + +static constexpr uint32_t CYCLE_TIMEOUT_ID = 0; +static constexpr uint32_t LOAD_READ_ID = 1; + +static void publish(sensor::Sensor *s, float value) { + if (s != nullptr) { + s->publish_state(value); + } +} + +void RenogyInverterBle::dump_config() { + ESP_LOGCONFIG(TAG, "Renogy Inverter BLE:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "AC input voltage", this->ac_input_voltage_sensor_); + LOG_SENSOR(" ", "AC output voltage", this->ac_output_voltage_sensor_); + LOG_SENSOR(" ", "AC output current", this->ac_output_current_sensor_); + LOG_SENSOR(" ", "AC output frequency", this->ac_output_frequency_sensor_); + LOG_SENSOR(" ", "Input frequency", this->input_frequency_sensor_); + LOG_SENSOR(" ", "Battery voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Load current", this->load_current_sensor_); + LOG_SENSOR(" ", "Load active power", this->load_active_power_sensor_); + LOG_SENSOR(" ", "Load apparent power", this->load_apparent_power_sensor_); +} + +void RenogyInverterBle::abort_cycle_() { + this->cancel_timeout(CYCLE_TIMEOUT_ID); + this->cancel_timeout(LOAD_READ_ID); + this->frame_len_ = 0; + this->state_ = State::IDLE; +} + +void RenogyInverterBle::read_register_(uint16_t start_register, uint16_t word_count) { + std::array req{MODBUS_DEVICE_ID, + MODBUS_READ_HOLDING, + static_cast(start_register >> 8), + static_cast(start_register), + static_cast(word_count >> 8), + static_cast(word_count)}; + const uint16_t crc = crc16(req.data(), 6); + req[6] = crc & 0xFF; // Modbus sends the CRC low byte first + req[7] = crc >> 8; + + this->frame_len_ = 0; + const esp_err_t status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->write_handle_, + req.size(), req.data(), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "write_char (reg %u) failed, status=%d", start_register, status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::start_cycle_() { + this->set_timeout(CYCLE_TIMEOUT_ID, CYCLE_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "Read cycle timed out (state=%u); resetting", static_cast(this->state_)); + this->abort_cycle_(); + }); + if (this->init_handle_ == 0) { + // No init characteristic discovered; try the main read directly + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + return; + } + this->state_ = State::INIT; + const esp_err_t status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->init_handle_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "init read_char failed, status=%d", status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::update() { + if (this->node_state != espbt::ClientState::ESTABLISHED) { + ESP_LOGD(TAG, "Not connected yet; skipping poll"); + return; + } + if (this->state_ != State::IDLE) { + ESP_LOGD(TAG, "Previous read cycle still in progress; skipping"); + return; + } + this->start_cycle_(); +} + +void RenogyInverterBle::on_frame_complete_(uint16_t len) { + // A Modbus frame with its CRC appended checks to zero + if (len < 5 || crc16(this->frame_.data(), len) != 0) { + ESP_LOGW(TAG, "CRC mismatch (state=%u, len=%u)", static_cast(this->state_), len); + this->abort_cycle_(); + return; + } + // Exception response: function code with the high bit set, third byte is the exception code + if ((this->frame_[1] & 0x80) != 0) { + ESP_LOGW(TAG, "Modbus exception 0x%02X (state=%u)", this->frame_[2], static_cast(this->state_)); + this->abort_cycle_(); + return; + } + // Only the reply to the outstanding read is accepted: same device, same function, the requested word count + const bool awaiting_reply = this->state_ == State::MAIN || this->state_ == State::LOAD; + const uint16_t expected_words = this->state_ == State::MAIN ? REG_MAIN_WORDS : REG_LOAD_WORDS; + if (!awaiting_reply || this->frame_[0] != MODBUS_DEVICE_ID || this->frame_[1] != MODBUS_READ_HOLDING || + this->frame_[2] != 2 * expected_words) { + ESP_LOGW(TAG, "Unexpected response %02X %02X %02X (state=%u)", this->frame_[0], this->frame_[1], this->frame_[2], + static_cast(this->state_)); + this->abort_cycle_(); + return; + } + auto reg16 = [this](uint8_t i) { return encode_uint16(this->frame_[3 + 2 * i], this->frame_[4 + 2 * i]); }; + + if (this->state_ == State::MAIN) { + publish(this->ac_input_voltage_sensor_, reg16(0) * 0.1f); + publish(this->ac_output_voltage_sensor_, reg16(2) * 0.1f); + publish(this->ac_output_current_sensor_, reg16(3) * 0.01f); + publish(this->ac_output_frequency_sensor_, reg16(4) * 0.01f); + publish(this->battery_voltage_sensor_, reg16(5) * 0.1f); + publish(this->temperature_sensor_, reg16(6) * 0.1f); + publish(this->input_frequency_sensor_, reg16(9) * 0.01f); + this->frame_len_ = 0; + this->set_timeout(LOAD_READ_ID, LOAD_READ_DELAY_MS, [this]() { + this->state_ = State::LOAD; + this->read_register_(REG_LOAD, REG_LOAD_WORDS); + }); + return; + } + publish(this->load_current_sensor_, reg16(0) * 0.01f); + publish(this->load_active_power_sensor_, static_cast(reg16(1))); + publish(this->load_apparent_power_sensor_, static_cast(reg16(2))); + this->abort_cycle_(); +} + +void RenogyInverterBle::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + this->abort_cycle_(); + this->node_state = espbt::ClientState::IDLE; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + auto *notify_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_NOTIFY_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_NOTIFY_UUID)); + auto *write_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_WRITE_UUID)); + auto *init_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_INIT_UUID)); + if (notify_chr == nullptr || write_chr == nullptr) { + ESP_LOGE(TAG, "Required characteristics not found; not a Renogy inverter?"); + break; + } + this->notify_handle_ = notify_chr->handle; + this->write_handle_ = write_chr->handle; + this->init_handle_ = (init_chr != nullptr) ? init_chr->handle : 0; + ESP_LOGI(TAG, "handles: notify=%u write=%u init=%u", this->notify_handle_, this->write_handle_, + this->init_handle_); + const esp_err_t status = this->parent_->register_for_notify(this->notify_handle_); + if (status != ESP_OK) { + ESP_LOGW(TAG, "register_for_notify failed, status=%d", status); + } + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + // Other nodes on the same client see this event too + if (param->reg_for_notify.handle != this->notify_handle_) { + break; + } + if (param->reg_for_notify.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "Notification registration failed, status=%d", param->reg_for_notify.status); + break; + } + this->node_state = espbt::ClientState::ESTABLISHED; + ESP_LOGI(TAG, "Connected; will poll the inverter every update interval"); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (this->state_ != State::INIT || param->read.handle != this->init_handle_) { + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "init read failed, status=%d", param->read.status); + this->abort_cycle_(); + break; + } + // Init handshake done; the inverter now answers Modbus reads + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.handle != this->notify_handle_) { + break; + } + const uint16_t value_len = param->notify.value_len; + if (this->frame_len_ + value_len > MAX_FRAME) { + ESP_LOGW(TAG, "Frame overflow; resetting"); + this->abort_cycle_(); + break; + } + memcpy(&this->frame_[this->frame_len_], param->notify.value, value_len); + this->frame_len_ += value_len; + if (this->frame_len_ < 3) { + break; + } + // An exception frame ([id][func|0x80][exc][crc][crc]) is 5 bytes; a normal response is + // [id][func][byte_count][...data][crc][crc] + const uint16_t expected_len = ((this->frame_[1] & 0x80) != 0) ? 5 : 3 + this->frame_[2] + 2; + if (this->frame_len_ >= expected_len) { + this->on_frame_complete_(expected_len); + } + break; + } + default: + break; + } +} + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h new file mode 100644 index 00000000000..41bcdddf4ba --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::renogy_inverter_ble { + +namespace espbt = esphome::esp32_ble_tracker; + +// Largest Modbus response: 3 header + 2 * 32 data + 2 CRC bytes +static constexpr size_t MAX_FRAME = 69; + +// Renogy inverter BLE GATT (RIV1230PU). Notify char 0xFFF1 (svc 0xFFF0), write char 0xFFD1 +// (svc 0xFFD0), init char 0xFFD4 (svc 0xFFD0). Modbus device id 0x20, function 0x03. +class RenogyInverterBle : public ble_client::BLEClientNode, public PollingComponent { + SUB_SENSOR(ac_input_voltage) + SUB_SENSOR(ac_output_voltage) + SUB_SENSOR(ac_output_current) + SUB_SENSOR(ac_output_frequency) + SUB_SENSOR(input_frequency) + SUB_SENSOR(battery_voltage) + SUB_SENSOR(temperature) + SUB_SENSOR(load_current) + SUB_SENSOR(load_active_power) + SUB_SENSOR(load_apparent_power) + + public: + void dump_config() override; + void update() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + + protected: + // Read cycle: IDLE -> (update) INIT read(0xFFD4) -> MAIN read(4000) -> LOAD read(4408) -> IDLE + enum class State : uint8_t { IDLE, INIT, MAIN, LOAD }; + + void start_cycle_(); + void read_register_(uint16_t start_register, uint16_t word_count); + void on_frame_complete_(uint16_t len); + void abort_cycle_(); + + State state_{State::IDLE}; + uint8_t frame_len_{0}; + uint16_t notify_handle_{0}; + uint16_t write_handle_{0}; + uint16_t init_handle_{0}; + // Reassembles the Modbus response, which arrives in MTU-sized notification chunks + std::array frame_; +}; + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/sensor.py b/esphome/components/renogy_inverter_ble/sensor.py new file mode 100644 index 00000000000..9b87cc6503b --- /dev/null +++ b/esphome/components/renogy_inverter_ble/sensor.py @@ -0,0 +1,99 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_VOLTAGE, + CONF_TEMPERATURE, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_HERTZ, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) +from esphome.types import ConfigType + +from . import CONF_RENOGY_INVERTER_BLE_ID, RENOGY_INVERTER_BLE_COMPONENT_SCHEMA + +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["renogy_inverter_ble"] + +CONF_AC_INPUT_VOLTAGE = "ac_input_voltage" +CONF_AC_OUTPUT_VOLTAGE = "ac_output_voltage" +CONF_AC_OUTPUT_CURRENT = "ac_output_current" +CONF_AC_OUTPUT_FREQUENCY = "ac_output_frequency" +CONF_INPUT_FREQUENCY = "input_frequency" +CONF_LOAD_CURRENT = "load_current" +CONF_LOAD_ACTIVE_POWER = "load_active_power" +CONF_LOAD_APPARENT_POWER = "load_apparent_power" + +VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + 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, +) +FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = RENOGY_INVERTER_BLE_COMPONENT_SCHEMA.extend( + { + cv.Optional(CONF_AC_INPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_INPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_BATTERY_VOLTAGE): VOLTAGE_SCHEMA, + 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_LOAD_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_LOAD_ACTIVE_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_RENOGY_INVERTER_BLE_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_INPUT_VOLTAGE, hub.set_ac_input_voltage_sensor) + await sensors(CONF_AC_OUTPUT_VOLTAGE, hub.set_ac_output_voltage_sensor) + await sensors(CONF_AC_OUTPUT_CURRENT, hub.set_ac_output_current_sensor) + await sensors(CONF_AC_OUTPUT_FREQUENCY, hub.set_ac_output_frequency_sensor) + await sensors(CONF_INPUT_FREQUENCY, hub.set_input_frequency_sensor) + await sensors(CONF_BATTERY_VOLTAGE, hub.set_battery_voltage_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_LOAD_CURRENT, hub.set_load_current_sensor) + await sensors(CONF_LOAD_ACTIVE_POWER, hub.set_load_active_power_sensor) + await sensors(CONF_LOAD_APPARENT_POWER, hub.set_load_apparent_power_sensor) diff --git a/tests/components/renogy_inverter_ble/common.yaml b/tests/components/renogy_inverter_ble/common.yaml new file mode 100644 index 00000000000..a4f6e8e16fc --- /dev/null +++ b/tests/components/renogy_inverter_ble/common.yaml @@ -0,0 +1,31 @@ +ble_client: + - mac_address: 14:9C:EF:01:02:03 + id: inverter_client + +renogy_inverter_ble: + - id: inv0 + ble_client_id: inverter_client + +sensor: + - platform: renogy_inverter_ble + renogy_inverter_ble_id: inv0 + ac_input_voltage: + name: AC input voltage + ac_output_voltage: + name: AC output voltage + ac_output_current: + name: AC output current + ac_output_frequency: + name: AC output frequency + input_frequency: + name: Input frequency + battery_voltage: + name: Battery voltage + temperature: + name: Temperature + load_current: + name: Load current + load_active_power: + name: Load active power + load_apparent_power: + name: Load apparent power diff --git a/tests/components/renogy_inverter_ble/test.esp32-idf.yaml b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml new file mode 100644 index 00000000000..5ef5be8856c --- /dev/null +++ b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + renogy_inverter_ble: !include common.yaml From 54fdeb7b1b6c327a4b27bdb14aa1b61e6c93e396 Mon Sep 17 00:00:00 2001 From: Georgi Filipov Date: Tue, 13 Oct 2026 02:01:22 +0300 Subject: [PATCH 411/435] [samsung] Add climate IR samsung support (#8004) Co-authored-by: Georgi Filipov Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> 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/samsung/__init__.py | 1 + esphome/components/samsung/climate.py | 16 ++ esphome/components/samsung/samsung.cpp | 235 ++++++++++++++++++ esphome/components/samsung/samsung.h | 31 +++ tests/components/samsung/common.yaml | 5 + tests/components/samsung/test.esp32-idf.yaml | 4 + .../components/samsung/test.esp8266-ard.yaml | 4 + 8 files changed, 297 insertions(+) create mode 100644 esphome/components/samsung/__init__.py create mode 100644 esphome/components/samsung/climate.py create mode 100644 esphome/components/samsung/samsung.cpp create mode 100644 esphome/components/samsung/samsung.h create mode 100644 tests/components/samsung/common.yaml create mode 100644 tests/components/samsung/test.esp32-idf.yaml create mode 100644 tests/components/samsung/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 826c5e70038..c74ae5616bf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -492,6 +492,7 @@ esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti +esphome/components/samsung/* @jorofi esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core esphome/components/sdl/* @bdm310 @clydebarrow diff --git a/esphome/components/samsung/__init__.py b/esphome/components/samsung/__init__.py new file mode 100644 index 00000000000..fb22727b36a --- /dev/null +++ b/esphome/components/samsung/__init__.py @@ -0,0 +1 @@ +DOMAIN = "samsung" diff --git a/esphome/components/samsung/climate.py b/esphome/components/samsung/climate.py new file mode 100644 index 00000000000..ae59d17f96a --- /dev/null +++ b/esphome/components/samsung/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.types import ConfigType + +CODEOWNERS = ["@jorofi"] + +AUTO_LOAD = ["climate_ir"] + +samsung_ns = cg.esphome_ns.namespace("samsung") +SamsungClimate = samsung_ns.class_("SamsungClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SamsungClimate) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/samsung/samsung.cpp b/esphome/components/samsung/samsung.cpp new file mode 100644 index 00000000000..65f1cd10d6f --- /dev/null +++ b/esphome/components/samsung/samsung.cpp @@ -0,0 +1,235 @@ +#include "samsung.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::samsung { + +ESPHOME_LOG_TAG(TAG, "samsung.climate"); + +static constexpr uint32_t CARRIER_FREQUENCY_HZ = 38000; +static constexpr uint32_t HEADER_MARK_US = 3000; +static constexpr uint32_t HEADER_SPACE_US = 9000; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t ONE_SPACE_US = 1500; +static constexpr uint32_t ZERO_SPACE_US = 500; +static constexpr uint32_t SECTION_SPACE_US = 2000; + +static constexpr uint8_t EXTENDED_LENGTH = 3 * SAMSUNG_AC_SECTION_LENGTH; + +// A powered-on unit at 24 °C, cool mode, fan auto, swing off (from IRremoteESP8266); checksums are refilled on send +static const uint8_t RESET_STATE[SAMSUNG_AC_STATE_LENGTH] PROGMEM = {0x02, 0x92, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x01, 0xE2, 0xFE, 0x71, 0x80, 0x11, 0xF0}; +// Middle section of a power on/off frame: an all-zero timer block +static const uint8_t TIMER_SECTION[SAMSUNG_AC_SECTION_LENGTH] PROGMEM = {0x01, 0xD2, 0x0F, 0x00, 0x00, 0x00, 0x00}; + +// Where each setting lives in the 14 byte state +struct Field { + uint8_t byte; + uint8_t shift; + uint8_t mask; +}; +static constexpr Field POWER_1{6, 4, 0b11}; +static constexpr Field SWING{9, 4, 0b111}; +static constexpr Field TEMP{11, 4, 0b1111}; +static constexpr Field FAN{12, 1, 0b111}; +static constexpr Field MODE{12, 4, 0b111}; +static constexpr Field POWER_2{13, 4, 0b11}; +static constexpr uint8_t POWER_ON = 0b11; + +static uint8_t get_field(const uint8_t *state, Field field) { return (state[field.byte] >> field.shift) & field.mask; } +static void set_field(uint8_t *state, Field field, uint8_t value) { + state[field.byte] = (state[field.byte] & ~(field.mask << field.shift)) | ((value & field.mask) << field.shift); +} +static bool is_powered_on(const uint8_t *state) { + return get_field(state, POWER_1) == POWER_ON && get_field(state, POWER_2) == POWER_ON; +} + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +// The first entry of each table is the fallback in both directions +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, 0}, {climate::CLIMATE_MODE_COOL, 1}, {climate::CLIMATE_MODE_DRY, 2}, + {climate::CLIMATE_MODE_FAN_ONLY, 3}, {climate::CLIMATE_MODE_HEAT, 4}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0}, + {climate::CLIMATE_FAN_LOW, 2}, + {climate::CLIMATE_FAN_MEDIUM, 4}, + {climate::CLIMATE_FAN_HIGH, 5}, +}; +static constexpr Code SWINGS[] PROGMEM = { + {climate::CLIMATE_SWING_OFF, 0b111}, + {climate::CLIMATE_SWING_VERTICAL, 0b010}, + {climate::CLIMATE_SWING_HORIZONTAL, 0b011}, + {climate::CLIMATE_SWING_BOTH, 0b100}, +}; + +// Returns the entry whose value (or code) matches key, else the first entry. Entries are copied out of PROGMEM. +static Code find_entry(const Code *table, size_t count, uint8_t key, bool match_code) { + Code entry{}; + for (size_t i = 0; i < count; i++) { + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + progmem_memcpy(&entry, table, sizeof(entry)); + return entry; +} +template static uint8_t to_code(const Code (&table)[N], uint8_t value) { + return find_entry(table, N, value, false).code; +} +template static T from_code(const Code (&table)[N], uint8_t code) { + return static_cast(find_entry(table, N, code, true).value); +} + +// Inverted count of the one bits in a section, skipping the two nibbles that hold the checksum itself. +// See https://github.com/crankyoldgit/IRremoteESP8266/issues/1538#issuecomment-894645947 +static uint8_t section_checksum(const uint8_t *section) { + uint32_t ones = + __builtin_popcount(section[0]) + __builtin_popcount(section[1] & 0x0F) + __builtin_popcount(section[2] & 0xF0); + for (uint8_t i = 3; i < SAMSUNG_AC_SECTION_LENGTH; i++) { + ones += __builtin_popcount(section[i]); + } + return ~static_cast(ones); +} +static uint8_t stored_checksum(const uint8_t *section) { return (section[1] >> 4) | (section[2] << 4); } +static void store_checksum(uint8_t *section) { + const uint8_t sum = section_checksum(section); + section[1] = (section[1] & 0x0F) | (sum << 4); + section[2] = (section[2] & 0xF0) | (sum >> 4); +} + +SamsungClimate::SamsungClimate() + : climate_ir::ClimateIR( + SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_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}) { + progmem_memcpy(this->state_.data(), RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + // The unit's power state is unknown at boot, so the first "on" command sends a power frame + set_field(this->state_.data(), POWER_1, 0); + set_field(this->state_.data(), POWER_2, 0); +} + +void SamsungClimate::transmit_state() { + uint8_t *state = this->state_.data(); + const bool was_on = is_powered_on(state); + const bool on = this->mode != climate::CLIMATE_MODE_OFF; + + if (on) { + progmem_memcpy(state, RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + set_field(state, MODE, to_code(MODES, this->mode)); + } // else keep the last mode so the power off frame still carries a valid state + + const uint8_t temp = clamp(this->target_temperature, SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX); + set_field(state, TEMP, temp - SAMSUNG_AC_TEMP_MIN); + set_field(state, SWING, to_code(SWINGS, this->swing_mode)); + set_field(state, FAN, to_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO))); + set_field(state, POWER_1, on ? POWER_ON : 0); + set_field(state, POWER_2, on ? POWER_ON : 0); + + if (on != was_on) { + // A power change is sent as an extended frame with the timer block between the two state sections + uint8_t timer[SAMSUNG_AC_SECTION_LENGTH]; + progmem_memcpy(timer, TIMER_SECTION, SAMSUNG_AC_SECTION_LENGTH); + const uint8_t *sections[] = {state, timer, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 3); + } else { + const uint8_t *sections[] = {state, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 2); + } +} + +void SamsungClimate::send_sections_(const uint8_t *const *sections, uint8_t count) { + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + // Header(2) + (sections - 1) * separator(4) + bits(2 each) + trailing mark(1) + data->reserve(2 + (count - 1) * 4 + count * SAMSUNG_AC_SECTION_LENGTH * 8 * 2 + 1); + data->set_carrier_frequency(CARRIER_FREQUENCY_HZ); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + + for (uint8_t s = 0; s < count; s++) { + if (s != 0) { + data->item(BIT_MARK_US, SECTION_SPACE_US); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + } + uint8_t section[SAMSUNG_AC_SECTION_LENGTH]; + memcpy(section, sections[s], SAMSUNG_AC_SECTION_LENGTH); + store_checksum(section); + for (uint8_t byte : section) { + for (uint8_t bit = 0; bit < 8; bit++, byte >>= 1) { + data->item(BIT_MARK_US, (byte & 1) ? ONE_SPACE_US : ZERO_SPACE_US); + } + } + } + + data->mark(BIT_MARK_US); + transmit.perform(); +} + +bool SamsungClimate::on_receive(remote_base::RemoteReceiveData data) { + if (!data.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) { + return false; + } + + // Decode into a local buffer so a bad frame leaves the stored state untouched + uint8_t frame[EXTENDED_LENGTH] = {0}; + uint8_t length = EXTENDED_LENGTH; + for (uint8_t i = 0; i < EXTENDED_LENGTH; i++) { + if (i != 0 && i % SAMSUNG_AC_SECTION_LENGTH == 0) { + const bool have_separator = + data.expect_item(BIT_MARK_US, SECTION_SPACE_US) && data.expect_item(HEADER_MARK_US, HEADER_SPACE_US); + if (!have_separator) { + // A third section is optional; a missing separator after the second one ends the frame + if (i >= SAMSUNG_AC_STATE_LENGTH) { + length = i; + break; + } + ESP_LOGW(TAG, "Missing section separator before byte %u", i); + return false; + } + } + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(BIT_MARK_US, ONE_SPACE_US)) { + frame[i] |= 1 << bit; + } else if (!data.expect_item(BIT_MARK_US, ZERO_SPACE_US)) { + ESP_LOGW(TAG, "Bad bit %u in byte %u", bit, i); + return false; + } + } + } + if (!data.expect_mark(BIT_MARK_US)) { + ESP_LOGVV(TAG, "Footer fail"); + return false; + } + for (uint8_t offset = 0; offset < length; offset += SAMSUNG_AC_SECTION_LENGTH) { + if (stored_checksum(frame + offset) != section_checksum(frame + offset)) { + ESP_LOGW(TAG, "Checksum mismatch in section %u", offset / SAMSUNG_AC_SECTION_LENGTH); + return false; + } + } + + // In an extended frame the middle section is the timer block and the last one the second state section + uint8_t *state = this->state_.data(); + memcpy(state, frame, SAMSUNG_AC_SECTION_LENGTH); + memcpy(state + SAMSUNG_AC_SECTION_LENGTH, frame + length - SAMSUNG_AC_SECTION_LENGTH, SAMSUNG_AC_SECTION_LENGTH); + + this->swing_mode = from_code(SWINGS, get_field(state, SWING)); + this->target_temperature = get_field(state, TEMP) + SAMSUNG_AC_TEMP_MIN; + this->fan_mode = from_code(FANS, get_field(state, FAN)); + this->mode = + is_powered_on(state) ? from_code(MODES, get_field(state, MODE)) : climate::CLIMATE_MODE_OFF; + + ESP_LOGD(TAG, "Received %u byte frame, power %s", length, ONOFF(is_powered_on(state))); + this->publish_state(); + return true; +} + +} // namespace esphome::samsung diff --git a/esphome/components/samsung/samsung.h b/esphome/components/samsung/samsung.h new file mode 100644 index 00000000000..608cffd0287 --- /dev/null +++ b/esphome/components/samsung/samsung.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/components/climate/climate_mode.h" +#include "esphome/components/climate_ir/climate_ir.h" + +#include + +namespace esphome::samsung { + +static constexpr uint8_t SAMSUNG_AC_TEMP_MIN = 16; +static constexpr uint8_t SAMSUNG_AC_TEMP_MAX = 30; +// A frame is made of 7 byte sections; the normal state is two sections and a power change adds a timer section +static constexpr uint8_t SAMSUNG_AC_SECTION_LENGTH = 7; +static constexpr uint8_t SAMSUNG_AC_STATE_LENGTH = 2 * SAMSUNG_AC_SECTION_LENGTH; + +class SamsungClimate : public climate_ir::ClimateIR { + public: + SamsungClimate(); + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + /// Send the given sections as one frame, each with its checksum filled in + void send_sections_(const uint8_t *const *sections, uint8_t count); + + // The last state sent or received; its power bits say whether the next frame has to switch power + std::array state_{}; +}; + +} // namespace esphome::samsung diff --git a/tests/components/samsung/common.yaml b/tests/components/samsung/common.yaml new file mode 100644 index 00000000000..534500ca3af --- /dev/null +++ b/tests/components/samsung/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: samsung + name: SAMSUNG Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/samsung/test.esp32-idf.yaml b/tests/components/samsung/test.esp32-idf.yaml new file mode 100644 index 00000000000..193f830e47e --- /dev/null +++ b/tests/components/samsung/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + samsung: !include common.yaml diff --git a/tests/components/samsung/test.esp8266-ard.yaml b/tests/components/samsung/test.esp8266-ard.yaml new file mode 100644 index 00000000000..451bf93b94e --- /dev/null +++ b/tests/components/samsung/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + samsung: !include common.yaml From 81c275777d9212622f64e295c99fcd5b578aa115 Mon Sep 17 00:00:00 2001 From: Dennis Gaida <2392217+DennisGaida@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:08:41 +0200 Subject: [PATCH 412/435] [bmi270] Add optional BMM150 magnetometer support via aux interface (#18436) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/bmi270/bmi270.cpp | 195 ++++++++++++++++-- esphome/components/bmi270/bmi270.h | 118 +++++++++-- esphome/components/bmi270/bmm150_aux.h | 123 +++++++++++ esphome/components/bmi270/motion.py | 17 ++ esphome/components/bmi270/sensor.py | 88 ++++++-- esphome/core/defines.h | 1 + tests/components/bmi270/common-plain.yaml | 11 + tests/components/bmi270/common.yaml | 16 ++ .../bmi270/test-plain.esp32-idf.yaml | 3 + tests/components/bmi270/test.esp32-idf.yaml | 4 +- 10 files changed, 518 insertions(+), 58 deletions(-) create mode 100644 esphome/components/bmi270/bmm150_aux.h create mode 100644 tests/components/bmi270/common-plain.yaml create mode 100644 tests/components/bmi270/test-plain.esp32-idf.yaml diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index a1709c1fa56..37f718fb7eb 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -5,12 +5,16 @@ namespace esphome::bmi270 { +#ifdef USE_BMI270_BMM150 +using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device definitions +#endif + ESPHOME_LOG_TAG(TAG, "bmi270"); #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const size_t MAX_I2C_BUFFER_SIZE = 32; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 32; #else -static const size_t MAX_I2C_BUFFER_SIZE = 256; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 256; #endif // Configuration blob upload @@ -19,12 +23,12 @@ static const size_t MAX_I2C_BUFFER_SIZE = 256; bool BMI270Component::load_config_file_() { // 1. Disable advanced power-save so the config port is accessible - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_ADV_POWER_SAVE_OFF)) return false; delay(1); // 2. Prepare config load: write 0x00 to INIT_CTRL to start - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_START)) return false; // 3. Burst-write the config in pages @@ -50,7 +54,7 @@ bool BMI270Component::load_config_file_() { } // 4. Signal end of config load - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_DONE)) return false; delay(20); // spec: wait ≥20 ms for init to complete @@ -58,7 +62,7 @@ bool BMI270Component::load_config_file_() { uint8_t status = 0; if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) return false; - if ((status & 0x0F) != 0x01) { + if ((status & BMI270_INTERNAL_STATUS_MASK) != BMI270_INTERNAL_STATUS_INIT_OK) { ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); return false; } @@ -84,7 +88,7 @@ void BMI270Component::setup() { ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) - if (!this->write_byte(0x7E, 0xB6)) { + if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { this->mark_failed(); return; } @@ -100,7 +104,8 @@ void BMI270Component::setup() { // 5. Configure accelerometer // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) - uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + uint8_t acc_conf = + (uint8_t) (accel_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | BMI270_ACC_CONF_PERF_MODE; if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { this->mark_failed(); return; @@ -112,7 +117,8 @@ void BMI270Component::setup() { // 6. Configure gyroscope // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) - uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | + BMI270_GYR_CONF_NOISE_PERF | BMI270_GYR_CONF_FILTER_PERF; if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { this->mark_failed(); return; @@ -124,7 +130,7 @@ void BMI270Component::setup() { // 7. Enable accelerometer, gyroscope, and temperature sensor // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] - if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU)) { this->mark_failed(); return; } @@ -132,14 +138,133 @@ void BMI270Component::setup() { // 8. Re-enable advanced power save (optional; keeps current low between reads) // Disabled here for simplicity – leave in performance mode - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup this->mark_failed(); return; } + // 9. Optionally bring up whatever is wired to the aux interface. + // This is entirely additive: it is off by default, and failure only disables + // magnetometer reporting rather than the whole component. +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + this->magnetometer_ready_ = this->setup_magnetometer_(); + if (!this->magnetometer_ready_) { + ESP_LOGW(TAG, "BMM150 magnetometer setup failed; continuing without it"); + } + } +#endif + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); } +#ifdef USE_BMI270_BMM150 +bool BMI270Component::wait_for_aux_idle_(uint32_t timeout_ms) { + // The STATUS aux-busy bit is set while an aux transaction is in flight. Aux transaction + // duration depends on the attached device's own timing, so poll with a bounded + // timeout instead of guessing a fixed delay. + uint32_t start = millis(); + while (true) { + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_STATUS, &status)) + return false; + if ((status & BMI270_STATUS_AUX_BUSY) == 0) + return true; + if (millis() - start >= timeout_ms) + return false; + delay(1); + } +} + +bool BMI270Component::write_aux_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(BMI270_REG_AUX_WR_DATA, value)) + return false; + if (!this->write_byte(BMI270_REG_AUX_WR_ADDR, reg)) + return false; + return this->wait_for_aux_idle_(20); +} + +bool BMI270Component::read_aux_block_(uint8_t reg, uint8_t *buf) { + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, reg)) + return false; + if (!this->wait_for_aux_idle_(20)) + return false; + return this->read_bytes(BMI270_REG_AUX_X_LSB, buf, BMI270_AUX_DATA_LEN); +} + +bool BMI270Component::read_magnetometer_trim_() { + uint8_t regs[BMM150_TRIM_BLOCKS * BMM150_TRIM_BLOCK_LEN]; + for (uint8_t i = 0; i < BMM150_TRIM_BLOCKS; i++) { + if (!this->read_aux_block_(BMM150_REG_TRIM_START + i * BMM150_TRIM_BLOCK_LEN, regs + i * BMM150_TRIM_BLOCK_LEN)) + return false; + } + this->mag_trim_ = bmm150_parse_trim(regs); + return true; +} + +bool BMI270Component::setup_magnetometer_() { + // Sequence adapted from the Bosch BMI270 aux-interface protocol: enable the secondary + // I2C master in manual mode, point it at the BMM150's address, power the BMM150 up + // and soft-reset it, verify its chip ID, read its factory trim values, put it in + // normal/30Hz mode, then switch the aux interface to auto-burst mode so BMI270 keeps + // AUX_X/Y/Z/R refreshed from the BMM150's data registers. + ESP_LOGD(TAG, "Setting up BMM150 magnetometer via BMI270 aux interface..."); + + if (!this->write_byte(BMI270_REG_IF_CONF, BMI270_IF_CONF_AUX_IF_EN)) + return false; + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_MANUAL_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_DEV_ID, (uint8_t) (this->aux_device_address_ << 1))) + return false; + + // Suspend -> sleep, then a soft reset so the BMM150 starts from a known state even if it + // stayed powered across a restart. The chip ID is only readable once the power bit is set. + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_POWER_ON)) { + ESP_LOGW(TAG, "BMM150 power-on write timed out"); + return false; + } + delay(BMM150_START_UP_TIME_MS); + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_SOFT_RESET)) { + ESP_LOGW(TAG, "BMM150 soft-reset write timed out"); + return false; + } + delay(BMM150_SOFT_RESET_TIME_MS); + + uint8_t chip_id[BMI270_AUX_DATA_LEN]; + if (!this->read_aux_block_(BMM150_REG_CHIP_ID, chip_id)) { + ESP_LOGW(TAG, "BMM150 chip-ID read failed"); + return false; + } + if (chip_id[0] != BMM150_CHIP_ID_VALUE) { + ESP_LOGW(TAG, "BMM150 not detected (chip ID 0x%02X, expected 0x%02X)", chip_id[0], BMM150_CHIP_ID_VALUE); + return false; + } + + if (!this->read_magnetometer_trim_()) { + ESP_LOGW(TAG, "BMM150 trim data read failed"); + return false; + } + + if (!this->write_aux_register_(BMM150_REG_OP_MODE, BMM150_CMD_NORMAL_MODE_ODR_30HZ)) { + ESP_LOGW(TAG, "BMM150 mode write timed out"); + return false; + } + + // Switch to auto-burst mode: BMI270 will keep polling 8 bytes from the BMM150's + // data registers (0x42) into its own AUX_X/Y/Z/R registers automatically. + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_FCU_WRITE_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8 | + BMI270_AUX_IF_CONF_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, BMM150_REG_DATA_X_LSB)) + return false; + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU | BMI270_PWR_CTRL_AUX_EN)) + return false; + + ESP_LOGD(TAG, "BMM150 magnetometer ready"); + return true; +} +#endif // USE_BMI270_BMM150 + void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI270 IMU:"); LOG_I2C_DEVICE(this); @@ -153,6 +278,12 @@ void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)", + this->magnetometer_ready_ ? LOG_STR_LITERAL("ready") : LOG_STR_LITERAL("NOT DETECTED")); + } +#endif MotionComponent::dump_config(); } @@ -163,9 +294,22 @@ bool BMI270Component::update_data(motion::MotionData &data) { if (this->is_failed()) return false; - // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) - uint8_t raw_data[REG_READ_LEN]; - if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + // When the magnetometer is active, the 8 aux data bytes (0x04–0x0B) sit directly before + // the accel data, so one burst read from AUX_X_LSB fetches both. +#ifdef USE_BMI270_BMM150 + const bool read_mag = this->magnetometer_ready_ && !this->magnetometer_callback_.empty(); + uint8_t buf[BMI270_AUX_DATA_LEN + REG_READ_LEN]; + const uint8_t *mag_raw = buf; + const uint8_t *raw_data = read_mag ? buf + BMI270_AUX_DATA_LEN : buf; + const bool read_ok = read_mag ? this->read_bytes(BMI270_REG_AUX_X_LSB, buf, sizeof(buf)) + : this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#else + uint8_t buf[REG_READ_LEN]; + const uint8_t *raw_data = buf; + const bool read_ok = this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#endif + if (!read_ok) { ESP_LOGW(TAG, "Failed to read IMU data"); return false; } @@ -195,14 +339,21 @@ bool BMI270Component::update_data(motion::MotionData &data) { data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; - if (this->temperature_callback_.empty()) - return true; - // Temperature: registers 0x22–0x23 - // Formula from datasheet: T[°C] = raw / 512 + 23 - static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); - float temperature = (raw_t / 512.0f) + 23.0f; - this->temperature_callback_.call(temperature); + if (!this->temperature_callback_.empty()) { + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + } + +#ifdef USE_BMI270_BMM150 + if (read_mag) { + const BMM150Data mag_data = bmm150_convert(this->mag_trim_, mag_raw); + this->magnetometer_callback_.call(mag_data); + } +#endif return true; } diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 56d6a609522..a1fefdffe80 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -4,35 +4,81 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/defines.h" +#ifdef USE_BMI270_BMM150 +#include "bmm150_aux.h" +#endif #include namespace esphome::bmi270 { // Register map -static const uint8_t BMI270_REG_CHIP_ID = 0x00; -static const uint8_t BMI270_REG_ERR_REG = 0x02; -static const uint8_t BMI270_REG_STATUS = 0x03; -static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB -static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB -static const uint8_t BMI270_REG_TEMP_0 = 0x22; -static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) +static constexpr uint8_t BMI270_REG_CHIP_ID = 0x00; +static constexpr uint8_t BMI270_REG_ERR_REG = 0x02; +static constexpr uint8_t BMI270_REG_STATUS = 0x03; +static constexpr uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static constexpr uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static constexpr uint8_t BMI270_REG_TEMP_0 = 0x22; +static constexpr uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) static constexpr uint8_t REG_READ_LEN = BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) -static const uint8_t BMI270_REG_PWR_CONF = 0x7C; -static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; -static const uint8_t BMI270_REG_INIT_CTRL = 0x59; -static const uint8_t BMI270_REG_INIT_DATA = 0x5E; -static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; -static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; -static const uint8_t BMI270_REG_ACC_CONF = 0x40; -static const uint8_t BMI270_REG_ACC_RANGE = 0x41; -static const uint8_t BMI270_REG_GYR_CONF = 0x42; -static const uint8_t BMI270_REG_GYR_RANGE = 0x43; +static constexpr uint8_t BMI270_REG_PWR_CONF = 0x7C; +static constexpr uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static constexpr uint8_t BMI270_REG_INIT_CTRL = 0x59; +static constexpr uint8_t BMI270_REG_INIT_DATA = 0x5E; +static constexpr uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static constexpr uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static constexpr uint8_t BMI270_REG_ACC_CONF = 0x40; +static constexpr uint8_t BMI270_REG_ACC_RANGE = 0x41; +static constexpr uint8_t BMI270_REG_GYR_CONF = 0x42; +static constexpr uint8_t BMI270_REG_GYR_RANGE = 0x43; -static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; +static constexpr uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Auxiliary (secondary I2C master) interface registers. +// Used to optionally drive a BMM150 magnetometer wired to the BMI270's AUX pins +// instead of the main I2C bus - the BMM150 has no address of its own on that bus. +static constexpr uint8_t BMI270_REG_AUX_X_LSB = 0x04; // start of aux data: X,Y,Z,R (8 bytes) +static constexpr uint8_t BMI270_REG_AUX_DEV_ID = 0x4B; +static constexpr uint8_t BMI270_REG_AUX_IF_CONF = 0x4C; +static constexpr uint8_t BMI270_REG_AUX_RD_ADDR = 0x4D; +static constexpr uint8_t BMI270_REG_AUX_WR_ADDR = 0x4E; +static constexpr uint8_t BMI270_REG_AUX_WR_DATA = 0x4F; +static constexpr uint8_t BMI270_REG_IF_CONF = 0x6B; +static constexpr uint8_t BMI270_REG_CMD = 0x7E; + +// Register values and bitfields +static constexpr uint8_t BMI270_CMD_SOFT_RESET = 0xB6; +static constexpr uint8_t BMI270_PWR_CONF_ADV_POWER_SAVE_OFF = 0x00; +static constexpr uint8_t BMI270_PWR_CONF_FIFO_SELF_WAKEUP = 0x02; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_START = 0x00; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_DONE = 0x01; +static constexpr uint8_t BMI270_INTERNAL_STATUS_MASK = 0x0F; +static constexpr uint8_t BMI270_INTERNAL_STATUS_INIT_OK = 0x01; +static constexpr uint8_t BMI270_CONF_BWP_NORMAL = 0x02; // filter bandwidth, bits 5:4 +static constexpr uint8_t BMI270_CONF_BWP_SHIFT = 4; +static constexpr uint8_t BMI270_ACC_CONF_PERF_MODE = 0x80; +static constexpr uint8_t BMI270_GYR_CONF_NOISE_PERF = 0x40; +static constexpr uint8_t BMI270_GYR_CONF_FILTER_PERF = 0x80; +static constexpr uint8_t BMI270_STATUS_AUX_BUSY = 0x04; +static constexpr uint8_t BMI270_IF_CONF_AUX_IF_EN = 0x20; +static constexpr uint8_t BMI270_PWR_CTRL_AUX_EN = 0x01; +static constexpr uint8_t BMI270_PWR_CTRL_ACC_EN = 0x02; +static constexpr uint8_t BMI270_PWR_CTRL_GYR_EN = 0x04; +static constexpr uint8_t BMI270_PWR_CTRL_TEMP_EN = 0x08; +static constexpr uint8_t BMI270_PWR_CTRL_IMU = + BMI270_PWR_CTRL_ACC_EN | BMI270_PWR_CTRL_GYR_EN | BMI270_PWR_CTRL_TEMP_EN; +static constexpr uint8_t BMI270_AUX_IF_CONF_MANUAL_EN = 0x80; +static constexpr uint8_t BMI270_AUX_IF_CONF_FCU_WRITE_EN = 0x40; +static constexpr uint8_t BMI270_AUX_IF_CONF_MAN_RD_BURST_8 = 0x0C; // bits 3:2 = 3 +static constexpr uint8_t BMI270_AUX_IF_CONF_RD_BURST_8 = 0x03; // bits 1:0 = 3 + +// AUX_X_LSB..AUX_R_MSB (8 bytes) sit directly before the accel data, so one burst can read both. +static constexpr uint8_t BMI270_AUX_DATA_LEN = 8; +static_assert(BMI270_REG_AUX_X_LSB + BMI270_AUX_DATA_LEN == BMI270_REG_DATA_8, "aux data must precede accel data"); // Accelerometer range options enum BMI270AccelRange : uint8_t { @@ -75,7 +121,14 @@ enum BMI270GyroODR : uint8_t { BMI270_GYRO_ODR_3200 = 0x0D, }; -// ---Data class +#ifdef USE_BMI270_BMM150 +// What is wired to this instance's auxiliary (secondary I2C master) interface; another +// instance in the same build may have nothing attached. +enum BMI270AuxDevice : uint8_t { + BMI270_AUX_DEVICE_NONE = 0, + BMI270_AUX_DEVICE_BMM150 = 1, +}; +#endif // Main component class class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { @@ -91,18 +144,43 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; } template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } +#ifdef USE_BMI270_BMM150 + void set_aux_device(BMI270AuxDevice device) { this->aux_device_ = device; } + void set_aux_device_address(uint8_t address) { this->aux_device_address_ = address; } + template void add_magnetometer_listener(F &&cb) { this->magnetometer_callback_.add(std::forward(cb)); } +#endif protected: bool update_data(motion::MotionData &data) override; bool load_config_file_(); +#ifdef USE_BMI270_BMM150 + // Brings up the BMM150 over the BMI270's aux interface. Optional: on failure this only + // disables magnetometer reporting, it does not fail the whole component (accel/gyro still work). + bool setup_magnetometer_(); + // Blocking poll of the aux-transaction-busy bit; aux transaction time depends on the + // attached device, so this is bounded by retries/timeout rather than a fixed delay. + bool wait_for_aux_idle_(uint32_t timeout_ms); + // Manual-mode aux read of 8 bytes starting at `reg` on the aux device. + bool read_aux_block_(uint8_t reg, uint8_t *buf); + // Manual-mode aux write of one byte to `reg` on the aux device. + bool write_aux_register_(uint8_t reg, uint8_t value); + // Reads the BMM150 factory trim values used to compensate its samples. + bool read_magnetometer_trim_(); +#endif // Config BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; - LazyCallbackManager temperature_callback_{}; +#ifdef USE_BMI270_BMM150 + LazyCallbackManager magnetometer_callback_{}; + bmm150::BMM150Trim mag_trim_{}; + BMI270AuxDevice aux_device_{BMI270_AUX_DEVICE_NONE}; + uint8_t aux_device_address_{bmm150::BMM150_DEFAULT_I2C_ADDRESS}; + bool magnetometer_ready_{false}; +#endif }; } // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h new file mode 100644 index 00000000000..747a0898fa8 --- /dev/null +++ b/esphome/components/bmi270/bmm150_aux.h @@ -0,0 +1,123 @@ +#pragma once +#include +#include +#include "esphome/core/helpers.h" + +// BMM150 magnetometer definitions - the chip that can optionally be wired to the BMI270's +// auxiliary (secondary I2C master) interface. Lives in its own header/namespace because this +// is BMM150-specific driver knowledge, not BMI270 knowledge - it just can't be a standalone +// ESPHome component, since the BMM150 has no address of its own on the main I2C bus and is only +// reachable by having the BMI270 proxy register reads/writes through its aux interface. +namespace esphome::bmi270::bmm150 { + +static constexpr uint8_t BMM150_DEFAULT_I2C_ADDRESS = 0x10; +static constexpr uint8_t BMM150_REG_CHIP_ID = 0x40; +static constexpr uint8_t BMM150_REG_DATA_X_LSB = 0x42; +static constexpr uint8_t BMM150_REG_POWER_CONTROL = 0x4B; +static constexpr uint8_t BMM150_REG_OP_MODE = 0x4C; +static constexpr uint8_t BMM150_CHIP_ID_VALUE = 0x32; +static constexpr uint8_t BMM150_CMD_POWER_ON = 0x01; // power control bit: suspend -> sleep (3 ms start-up) +static constexpr uint8_t BMM150_CMD_SOFT_RESET = 0x83; // soft reset bits 7 and 1, power control bit kept set +static constexpr uint8_t BMM150_CMD_NORMAL_MODE_ODR_30HZ = 0x38; // data rate 111b (30 Hz), opmode 00b (normal) +static constexpr uint32_t BMM150_START_UP_TIME_MS = 3; +static constexpr uint32_t BMM150_SOFT_RESET_TIME_MS = 1; + +// Factory trim registers 0x5D-0x71, read once at setup and used to compensate every sample. +static constexpr uint8_t BMM150_REG_TRIM_START = 0x5D; +static constexpr uint8_t BMM150_TRIM_BLOCK_LEN = 8; // one aux burst +static constexpr uint8_t BMM150_TRIM_BLOCKS = 3; // 0x5D, 0x65, 0x6D -> covers 0x5D-0x74 +static constexpr uint8_t BMM150_TRIM_X1 = 0x5D; +static constexpr uint8_t BMM150_TRIM_Y1 = 0x5E; +static constexpr uint8_t BMM150_TRIM_Z4 = 0x62; // 16-bit, LSB first +static constexpr uint8_t BMM150_TRIM_X2 = 0x64; +static constexpr uint8_t BMM150_TRIM_Y2 = 0x65; +static constexpr uint8_t BMM150_TRIM_Z2 = 0x68; // 16-bit +static constexpr uint8_t BMM150_TRIM_Z1 = 0x6A; // 16-bit +static constexpr uint8_t BMM150_TRIM_XYZ1 = 0x6C; // 16-bit, 15 valid bits +static constexpr uint8_t BMM150_TRIM_Z3 = 0x6E; // 16-bit +static constexpr uint8_t BMM150_TRIM_XY2 = 0x70; +static constexpr uint8_t BMM150_TRIM_XY1 = 0x71; +static constexpr uint16_t BMM150_TRIM_XYZ1_MASK = 0x7FFF; + +// Raw ADC values that mean "overflow" per the datasheet. +static constexpr int16_t BMM150_XY_OVERFLOW_ADCVAL = -4096; +static constexpr int16_t BMM150_Z_OVERFLOW_ADCVAL = -16384; + +// Result of a BMM150 magnetometer reading, in µT. An axis is NaN when the sensor reported overflow. +struct BMM150Data { + float x; + float y; + float z; +}; + +// Factory trim values (BMM150 datasheet, compensation section). +struct BMM150Trim { + int8_t x1, y1, x2, y2, xy2; + uint8_t xy1; + int16_t z2, z3, z4; + uint16_t z1, xyz1; +}; + +// A little-endian 16-bit trim word, addressed by its register +inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) { + const uint8_t offset = reg - BMM150_REG_TRIM_START; + return encode_uint16(regs[offset + 1], regs[offset]); +} + +// Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21). +inline BMM150Trim bmm150_parse_trim(const uint8_t *regs) { + BMM150Trim trim; + trim.x1 = static_cast(regs[BMM150_TRIM_X1 - BMM150_REG_TRIM_START]); + trim.y1 = static_cast(regs[BMM150_TRIM_Y1 - BMM150_REG_TRIM_START]); + trim.z4 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z4)); + trim.x2 = static_cast(regs[BMM150_TRIM_X2 - BMM150_REG_TRIM_START]); + trim.y2 = static_cast(regs[BMM150_TRIM_Y2 - BMM150_REG_TRIM_START]); + trim.z2 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z2)); + trim.z1 = bmm150_trim_word(regs, BMM150_TRIM_Z1); + trim.xyz1 = bmm150_trim_word(regs, BMM150_TRIM_XYZ1) & BMM150_TRIM_XYZ1_MASK; + trim.z3 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z3)); + trim.xy2 = static_cast(regs[BMM150_TRIM_XY2 - BMM150_REG_TRIM_START]); + trim.xy1 = regs[BMM150_TRIM_XY1 - BMM150_REG_TRIM_START]; + return trim; +} + +// Compensates an X or Y sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_xy(const BMM150Trim &trim, int8_t trim_a, int8_t trim_b, int16_t raw, uint16_t rhall) { + if (raw == BMM150_XY_OVERFLOW_ADCVAL || rhall == 0 || trim.xyz1 == 0) + return NAN; + float retval = (float) trim.xyz1 * 16384.0f / (float) rhall - 16384.0f; + float comp1 = (float) trim.xy2 * (retval * retval / 268435456.0f); + float comp2 = comp1 + retval * (float) trim.xy1 / 16384.0f; + float comp3 = (float) trim_b + 160.0f; + float comp4 = (float) raw * ((comp2 + 256.0f) * comp3); + return ((comp4 / 8192.0f) + (float) trim_a * 8.0f) / 16.0f; +} + +// Compensates a Z sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_z(const BMM150Trim &trim, int16_t raw, uint16_t rhall) { + if (raw == BMM150_Z_OVERFLOW_ADCVAL || trim.z2 == 0 || trim.z1 == 0 || trim.xyz1 == 0 || rhall == 0) + return NAN; + float comp0 = (float) raw - (float) trim.z4; + float comp1 = (float) rhall - (float) trim.xyz1; + float comp2 = (float) trim.z3 * comp1; + float comp3 = (float) trim.z1 * (float) rhall / 32768.0f; + float comp4 = (float) trim.z2 + comp3; + float comp5 = comp0 * 131072.0f - comp2; + return (comp5 / (comp4 * 4.0f)) / 16.0f; +} + +// Converts 8 raw bytes from the BMM150 data registers (X, Y, Z, RHALL; 0x42-0x49) to compensated µT. +inline BMM150Data bmm150_convert(const BMM150Trim &trim, const uint8_t *raw) { + // X/Y are 13-bit (LSB byte bits 7:3), Z is 15-bit (bits 7:1), RHALL is 14-bit (bits 7:2). + int16_t x = (int16_t) (((int16_t) (int8_t) raw[1] * 32) | (raw[0] >> 3)); + int16_t y = (int16_t) (((int16_t) (int8_t) raw[3] * 32) | (raw[2] >> 3)); + int16_t z = (int16_t) (((int16_t) (int8_t) raw[5] * 128) | (raw[4] >> 1)); + uint16_t rhall = (uint16_t) (((uint16_t) raw[7] << 6) | (raw[6] >> 2)); + return BMM150Data{ + .x = bmm150_compensate_xy(trim, trim.x1, trim.x2, x, rhall), + .y = bmm150_compensate_xy(trim, trim.y1, trim.y2, y, rhall), + .z = bmm150_compensate_z(trim, z, rhall), + }; +} + +} // namespace esphome::bmi270::bmm150 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index ad36e592d14..97127fd2dd5 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -14,7 +14,11 @@ from . import BMI270Component, bmi270_ns DEPENDENCIES = ["i2c"] +CONF_AUX_ADDRESS = "aux_address" +CONF_AUX_DEVICE = "aux_device" + # Enum proxies (must match the C++ enum values exactly) +BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice") BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") ACCEL_RANGE_OPTIONS = { "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, @@ -73,6 +77,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( GYRO_ODR_OPTIONS, upper=True ), + # Optional device wired to the BMI270's aux interface (not the main I2C bus). + # Defaults to NONE so existing configs/boards are unaffected. + cv.Optional(CONF_AUX_DEVICE, default="NONE"): cv.one_of( + "NONE", "BMM150", upper=True + ), + # Default matches the BMM150's fixed I2C address; irrelevant when aux_device is NONE. + cv.Optional(CONF_AUX_ADDRESS, default=0x10): cv.i2c_address, } ) .extend(i2c.i2c_device_schema(0x68)) @@ -90,3 +101,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) + # The aux interface code only compiles when some instance has a BMM150; the + # instance still records its own device, as another one may have none + if config[CONF_AUX_DEVICE] == "BMM150": + cg.add_define("USE_BMI270_BMM150") + cg.add(var.set_aux_device(BMI270AuxDevice.BMI270_AUX_DEVICE_BMM150)) + cg.add(var.set_aux_device_address(config[CONF_AUX_ADDRESS])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py index 0e1b0604a3d..242b86614d8 100644 --- a/esphome/components/bmi270/sensor.py +++ b/esphome/components/bmi270/sensor.py @@ -6,37 +6,97 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, DEVICE_CLASS_TEMPERATURE, + ICON_MAGNET, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_MICROTESLA, ) from esphome.cpp_generator import MockObj +import esphome.final_validate as fv from esphome.types import ConfigType -from . import CONF_BMI270_ID, BMI270Component +from . import CONF_BMI270_ID, BMI270Component, bmi270_ns +from .motion import CONF_AUX_DEVICE AUTO_LOAD = ["bmi270"] -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( +CONF_MAGNETIC_FIELD_X = "magnetic_field_x" +CONF_MAGNETIC_FIELD_Y = "magnetic_field_y" +CONF_MAGNETIC_FIELD_Z = "magnetic_field_z" + +BMM150Data = bmi270_ns.namespace("bmm150").class_("BMM150Data") + +_MAGNETIC_FIELDS = (CONF_MAGNETIC_FIELD_X, CONF_MAGNETIC_FIELD_Y, CONF_MAGNETIC_FIELD_Z) + + +def _parent_schema() -> cv.Schema: + return cv.Schema({cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component)}) + + +def _temperature_sensor_schema() -> cv.Schema: + return 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(_parent_schema()) + + +def _magnetic_field_sensor_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROTESLA, + icon=ICON_MAGNET, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(_parent_schema()) + + +# `type` defaults to temperature so existing configs that omit it keep working unchanged. +CONFIG_SCHEMA = cv.typed_schema( { - cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), - cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component), - } + CONF_TEMPERATURE: _temperature_sensor_schema(), + **{x: _magnetic_field_sensor_schema() for x in _MAGNETIC_FIELDS}, + }, + default_type=CONF_TEMPERATURE, ) +def _final_validate(config: ConfigType) -> None: + if config[CONF_TYPE] not in _MAGNETIC_FIELDS: + return + full_config = fv.full_config.get() + bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1] + bmi270_config = full_config.get_config_for_path(bmi270_path) + if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150": + raise cv.Invalid( + "This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'", + path=[CONF_TYPE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + sensor_type = config[CONF_TYPE] var = await sensor.new_sensor(config) parent = await cg.get_variable(config[CONF_BMI270_ID]) + + if sensor_type == CONF_TEMPERATURE: + 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)) + return + + axis = sensor_type[-1:] data = MockObj("data") value_lambda = await cg.process_lambda( - var.publish_state(data), - [(cg.float_, str(data))], + var.publish_state(getattr(data, axis)), + [(BMM150Data.operator("const").operator("ref"), str(data))], ) - cg.add(parent.add_temperature_listener(value_lambda)) + cg.add(parent.add_magnetometer_listener(value_lambda)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a1b32b39f55..c645428c7c1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,7 @@ #define USE_BINARY_SENSOR_FILTER #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK +#define USE_BMI270_BMM150 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/components/bmi270/common-plain.yaml b/tests/components/bmi270/common-plain.yaml new file mode 100644 index 00000000000..13091521a83 --- /dev/null +++ b/tests/components/bmi270/common-plain.yaml @@ -0,0 +1,11 @@ +motion: + - platform: bmi270 + id: plain_imu + i2c_id: i2c_bus + address: 0x69 + +sensor: + - platform: bmi270 + bmi270_id: plain_imu + type: temperature + name: Plain IMU Temperature diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 41f30fd5c61..390def04a15 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -1,7 +1,21 @@ sensor: - platform: bmi270 + bmi270_id: bmi270_motion name: "BMI270 Temperature" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_x + name: "BMM150 Magnetic Field X" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_y + name: "BMM150 Magnetic Field Y" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_z + name: "BMM150 Magnetic Field Z" + - platform: motion motion_id: bmi270_motion type: acceleration_x @@ -75,6 +89,8 @@ motion: # Gyroscope output data rate: 25HZ | 50HZ | 100HZ | 200HZ | # 400HZ | 800HZ | 1600HZ | 3200HZ gyroscope_odr: 200HZ + aux_device: BMM150 + aux_address: 0x10 axis_map: x: y y: x diff --git a/tests/components/bmi270/test-plain.esp32-idf.yaml b/tests/components/bmi270/test-plain.esp32-idf.yaml new file mode 100644 index 00000000000..aafcb7af3ab --- /dev/null +++ b/tests/components/bmi270/test-plain.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bmi270: !include common-plain.yaml diff --git a/tests/components/bmi270/test.esp32-idf.yaml b/tests/components/bmi270/test.esp32-idf.yaml index b47e39c3898..63ea3c5a1a1 100644 --- a/tests/components/bmi270/test.esp32-idf.yaml +++ b/tests/components/bmi270/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bmi270: !include common.yaml + bmi270_plain: !include common-plain.yaml From 73d5bb3c97534aec5105f3e7e9d546ae43fd0c40 Mon Sep 17 00:00:00 2001 From: naKruul <40398952+naKruul@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:09:40 +0200 Subject: [PATCH 413/435] [remko_ar715] Add Remko AR-715 IR climate platform (#17173) 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/remko_ar715/__init__.py | 2 + esphome/components/remko_ar715/climate.py | 22 ++++ .../components/remko_ar715/remko_ar715.cpp | 93 +++++++++++++++ esphome/components/remko_ar715/remko_ar715.h | 37 ++++++ tests/components/remko_ar715/__init__.py | 7 ++ tests/components/remko_ar715/common.yaml | 3 + .../remko_ar715/remko_ar715_tests.cpp | 106 ++++++++++++++++++ .../remko_ar715/test.esp32-idf.yaml | 3 + .../remko_ar715/test.esp8266-ard.yaml | 3 + 10 files changed, 277 insertions(+) create mode 100644 esphome/components/remko_ar715/__init__.py create mode 100644 esphome/components/remko_ar715/climate.py create mode 100644 esphome/components/remko_ar715/remko_ar715.cpp create mode 100644 esphome/components/remko_ar715/remko_ar715.h create mode 100644 tests/components/remko_ar715/__init__.py create mode 100644 tests/components/remko_ar715/common.yaml create mode 100644 tests/components/remko_ar715/remko_ar715_tests.cpp create mode 100644 tests/components/remko_ar715/test.esp32-idf.yaml create mode 100644 tests/components/remko_ar715/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c74ae5616bf..5e88b7d7e78 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet esphome/components/rd03d/* @jasstrong +esphome/components/remko_ar715/* @naKruul esphome/components/renogy_inverter_ble/* @emilioaray-dev esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core diff --git a/esphome/components/remko_ar715/__init__.py b/esphome/components/remko_ar715/__init__.py new file mode 100644 index 00000000000..18eb60df336 --- /dev/null +++ b/esphome/components/remko_ar715/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@naKruul"] +DOMAIN = "remko_ar715" diff --git a/esphome/components/remko_ar715/climate.py b/esphome/components/remko_ar715/climate.py new file mode 100644 index 00000000000..d640993a4cc --- /dev/null +++ b/esphome/components/remko_ar715/climate.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL +import esphome.config_validation as cv +from esphome.const import CONF_SUPPORTS_HEAT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +remko_ar715_ns = cg.esphome_ns.namespace("remko_ar715") +RemkoAr715Climate = remko_ar715_ns.class_("RemkoAr715Climate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_schema(RemkoAr715Climate).extend( + { + cv.Optional(CONF_SUPPORTS_HEAT, default=False): cv.boolean_false, + cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false, + } +) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/remko_ar715/remko_ar715.cpp b/esphome/components/remko_ar715/remko_ar715.cpp new file mode 100644 index 00000000000..f6d5a2f62c9 --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.cpp @@ -0,0 +1,93 @@ +#include "remko_ar715.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome::remko_ar715 { + +ESPHOME_LOG_TAG(TAG, "remko_ar715.climate"); + +// Timings from ir_ClimaButler.cpp (IRremoteESP8266), in microseconds +static constexpr uint16_t BIT_MARK = 511; +static constexpr uint16_t HDR_SPACE = 3492; +static constexpr uint16_t ONE_SPACE = 1540; +static constexpr uint16_t ZERO_SPACE = 548; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint8_t BITS = 52; + +static constexpr uint8_t POWER_ON = 0x3; +static constexpr uint8_t POWER_OFF = 0x0; +static constexpr uint8_t SWING_ON = 0x5; +static constexpr uint8_t SWING_OFF = 0x7; + +// Mode/fan byte: high nibble N3, low nibble N2 +static constexpr uint8_t MODE_FAN_AUTO = 0x80; +static constexpr uint8_t MODE_FAN_LOW = 0xA2; +static constexpr uint8_t MODE_FAN_MEDIUM = 0x91; +static constexpr uint8_t MODE_FAN_HIGH = 0x88; +static constexpr uint8_t MODE_DRY = 0xA4; +static constexpr uint8_t MODE_FAN_ONLY = 0xA3; + +static uint8_t fan_mode_byte(climate::ClimateFanMode fan_mode) { + switch (fan_mode) { + case climate::CLIMATE_FAN_HIGH: + return MODE_FAN_HIGH; + case climate::CLIMATE_FAN_MEDIUM: + return MODE_FAN_MEDIUM; + case climate::CLIMATE_FAN_LOW: + return MODE_FAN_LOW; + default: + return MODE_FAN_AUTO; + } +} + +// 52-bit word, 13 nibbles MSB first: +// N12=0x8 (fixed), N11=power, N10..N5=0 (timer, unused), N4=swing, +// N3,N2=mode/fan, N1=temp-16, N0=checksum (0xF - sum(N12..N1)) & 0xF +static uint64_t build_frame(uint8_t power, uint8_t swing, uint8_t mode_fan, uint8_t temp) { + const uint8_t temp_nibble = (temp - 16) & 0xF; + const uint8_t sum = 0x8 + power + swing + (mode_fan >> 4) + (mode_fan & 0xF) + temp_nibble; + const uint8_t checksum = (0xF - sum) & 0xF; + return (uint64_t{0x8} << 48) | (uint64_t{power} << 44) | (uint32_t{swing} << 16) | (uint32_t{mode_fan} << 8) | + (temp_nibble << 4) | checksum; +} + +static void send_frame(remote_base::RemoteTransmitData *data, uint64_t code) { + data->set_carrier_frequency(CARRIER_FREQUENCY); + data->reserve(2 + BITS * 2 + 2); + data->item(BIT_MARK, HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) + data->item(BIT_MARK, (code >> i) & 1 ? ONE_SPACE : ZERO_SPACE); + // Stop bit and inter-frame gap + data->item(BIT_MARK, HDR_SPACE); +} + +void RemkoAr715Climate::transmit_state() { + const uint8_t temp = lroundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX)); + const bool power = this->mode != climate::CLIMATE_MODE_OFF; + const bool swing = this->swing_mode == climate::CLIMATE_SWING_VERTICAL; + + // DRY and FAN_ONLY carry no fan speed: mode and fan share the same nibbles + uint8_t mode_fan; + switch (this->mode) { + case climate::CLIMATE_MODE_DRY: + mode_fan = MODE_DRY; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + mode_fan = MODE_FAN_ONLY; + break; + default: + mode_fan = fan_mode_byte(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + break; + } + + const uint64_t code = build_frame(power ? POWER_ON : POWER_OFF, swing ? SWING_ON : SWING_OFF, mode_fan, temp); + ESP_LOGV(TAG, "Sending power=%s temp=%u swing=%s mode_fan=0x%02X code=0x%05" PRIX32 "%08" PRIX32, ONOFF(power), temp, + ONOFF(swing), mode_fan, static_cast(code >> 32), static_cast(code)); + + auto transmit = this->transmitter_->transmit(); + send_frame(transmit.get_data(), code); + transmit.perform(); +} + +} // namespace esphome::remko_ar715 diff --git a/esphome/components/remko_ar715/remko_ar715.h b/esphome/components/remko_ar715/remko_ar715.h new file mode 100644 index 00000000000..463617bbc65 --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::remko_ar715 { + +// Remko AR-715 IR remote: 52-bit pulse-distance frame, full state in every command. +// IRremoteESP8266 calls it the ClimaButler protocol after a rebrand; Remko is the manufacturer. +// Protocol analysis: https://github.com/crankyoldgit/IRremoteESP8266/issues/1812 +// +// Supported devices: +// - Remko RKL series (RKL 490, 491, 494, 495) with AR-715 remote +// - Fischer ClimaButler RCS-SD43UAI / RCS-SD43UWI +// - TROTEC PAC 4600 +// - Novamatic CL 990 / CL 1590 +// - Rexair C15000N +// - freecom RCS-SD43UAI / RCS-SD43UWI + +static constexpr float TEMP_MIN = 16.0f; +static constexpr float TEMP_MAX = 30.0f; + +class RemkoAr715Climate final : public climate_ir::ClimateIR { + public: + RemkoAr715Climate() + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, + 1.0f, // temperature step + true, // supports dry + true, // supports fan only + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + protected: + void transmit_state() override; +}; + +} // namespace esphome::remko_ar715 diff --git a/tests/components/remko_ar715/__init__.py b/tests/components/remko_ar715/__init__.py new file mode 100644 index 00000000000..ec1dafdf15c --- /dev/null +++ b/tests/components/remko_ar715/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/remko_ar715/common.yaml b/tests/components/remko_ar715/common.yaml new file mode 100644 index 00000000000..b284bc20c2a --- /dev/null +++ b/tests/components/remko_ar715/common.yaml @@ -0,0 +1,3 @@ +climate: + - platform: remko_ar715 + name: Test RemkoAr715 diff --git a/tests/components/remko_ar715/remko_ar715_tests.cpp b/tests/components/remko_ar715/remko_ar715_tests.cpp new file mode 100644 index 00000000000..bb1cece60fb --- /dev/null +++ b/tests/components/remko_ar715/remko_ar715_tests.cpp @@ -0,0 +1,106 @@ +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/remko_ar715/remko_ar715.h" + +namespace esphome::remko_ar715::testing { + +static constexpr int32_t BIT_MARK = 511; +static constexpr int32_t HDR_SPACE = 3492; +static constexpr int32_t ONE_SPACE = 1540; +static constexpr int32_t ZERO_SPACE = 548; +static constexpr int BITS = 52; + +/// The waveform the component must produce for a code: header, 52 bits MSB first, stop bit +static remote_base::RawTimings waveform(uint64_t code) { + remote_base::RawTimings raw; + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) { + raw.push_back(BIT_MARK); + raw.push_back((code >> i) & 1 ? -ONE_SPACE : -ZERO_SPACE); + } + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + return raw; +} + +/// Decodes a frame back into its code so a mismatch is readable +static uint64_t code_of(const remote_base::RawTimings &raw) { + uint64_t code = 0; + for (int bit = 0; bit < BITS; bit++) { + const size_t space = 3 + 2 * bit; + code = (code << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0); + } + return code; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + RemkoAr715Climate climate; + + Fixture() { + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.perform(); + } + + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } +}; + +static void expect_code(const remote_base::RawTimings &actual, uint64_t code) { + EXPECT_EQ(actual, waveform(code)) << std::hex << "actual 0x" << code_of(actual) << "\nexpected 0x" << code; +} + +// Codes captured from the AR-715 remote in IRremoteESP8266 issue 1812 +TEST(RemkoAr715, EncodesCapturedFrames) { + Fixture f; + + f.command(climate::CLIMATE_MODE_COOL, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x830000005880FULL); + + // Power off keeps the rest of the state in the frame + f.command(climate::CLIMATE_MODE_OFF, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x8000000058802ULL); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007919AULL); + + f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007914FULL); +} + +// Dry and fan-only replace the fan nibbles; the fan speed is not sent +TEST(RemkoAr715, DryAndFanOnlyIgnoreTheFanSpeed) { + Fixture f; + + f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A469ULL); + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A36AULL); +} + +} // namespace esphome::remko_ar715::testing diff --git a/tests/components/remko_ar715/test.esp32-idf.yaml b/tests/components/remko_ar715/test.esp32-idf.yaml new file mode 100644 index 00000000000..128f6d444a7 --- /dev/null +++ b/tests/components/remko_ar715/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remko_ar715: !include common.yaml diff --git a/tests/components/remko_ar715/test.esp8266-ard.yaml b/tests/components/remko_ar715/test.esp8266-ard.yaml new file mode 100644 index 00000000000..bc4c8ddda13 --- /dev/null +++ b/tests/components/remko_ar715/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remko_ar715: !include common.yaml From 0c43d689d7852d4a75e471d932014ba351ecc92c Mon Sep 17 00:00:00 2001 From: pl4nkton Date: Tue, 13 Oct 2026 01:11:51 +0200 Subject: [PATCH 414/435] [sh1122] Add OLED display driver (#15295) Co-authored-by: Nicolas Reinecke Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/sh1122_base/__init__.py | 43 ++++++ .../components/sh1122_base/sh1122_base.cpp | 146 ++++++++++++++++++ esphome/components/sh1122_base/sh1122_base.h | 59 +++++++ esphome/components/sh1122_spi/__init__.py | 1 + esphome/components/sh1122_spi/display.py | 37 +++++ esphome/components/sh1122_spi/sh1122_spi.cpp | 42 +++++ esphome/components/sh1122_spi/sh1122_spi.h | 25 +++ tests/components/sh1122_spi/common.yaml | 19 +++ .../components/sh1122_spi/test.esp32-idf.yaml | 8 + .../sh1122_spi/test.esp8266-ard.yaml | 8 + .../sh1122_spi/test.rp2040-ard.yaml | 8 + 12 files changed, 398 insertions(+) create mode 100644 esphome/components/sh1122_base/__init__.py create mode 100644 esphome/components/sh1122_base/sh1122_base.cpp create mode 100644 esphome/components/sh1122_base/sh1122_base.h create mode 100644 esphome/components/sh1122_spi/__init__.py create mode 100644 esphome/components/sh1122_spi/display.py create mode 100644 esphome/components/sh1122_spi/sh1122_spi.cpp create mode 100644 esphome/components/sh1122_spi/sh1122_spi.h create mode 100644 tests/components/sh1122_spi/common.yaml create mode 100644 tests/components/sh1122_spi/test.esp32-idf.yaml create mode 100644 tests/components/sh1122_spi/test.esp8266-ard.yaml create mode 100644 tests/components/sh1122_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 5e88b7d7e78..b25611b3b66 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -522,6 +522,8 @@ esphome/components/sfa30/* @ghsensdev esphome/components/sfa40/* @NoQuarrel esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sh1122_base/* @pl4nkton +esphome/components/sh1122_spi/* @pl4nkton esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py new file mode 100644 index 00000000000..4f2b026a5ed --- /dev/null +++ b/esphome/components/sh1122_base/__init__.py @@ -0,0 +1,43 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@pl4nkton"] +DOMAIN = "sh1122_base" + +sh1122_base_ns = cg.esphome_ns.namespace("sh1122_base") +SH1122 = sh1122_base_ns.class_("SH1122", cg.PollingComponent, display.DisplayBuffer) +SH1122Model = sh1122_base_ns.enum("SH1122Model") + +MODELS = { + "SH1122_256X64": SH1122Model.SH1122_MODEL_256_64, +} + +SH1122_MODEL = cv.enum(MODELS, upper=True, space="_") + +SH1122_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.Optional(CONF_MODEL, default="SH1122_256X64"): SH1122_MODEL, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, + } +).extend(cv.polling_component_schema("1s")) + + +async def setup_sh1122(var: MockObj, config: ConfigType) -> None: + await display.register_display(var, config) + + cg.add(var.set_model(config[CONF_MODEL])) + if (reset_pin := config.get(CONF_RESET_PIN)) is not None: + reset = await cg.gpio_pin_expression(reset_pin) + cg.add(var.set_reset_pin(reset)) + cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/sh1122_base/sh1122_base.cpp b/esphome/components/sh1122_base/sh1122_base.cpp new file mode 100644 index 00000000000..72d5e44235f --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.cpp @@ -0,0 +1,146 @@ +#include "sh1122_base.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sh1122_base { + +ESPHOME_LOG_TAG(TAG, "sh1122"); + +static constexpr uint8_t SH1122_MAX_CONTRAST = 255; +static constexpr uint8_t SH1122_COLORMASK = 0x0f; +static constexpr uint8_t SH1122_COLORSHIFT = 4; +static constexpr uint8_t SH1122_PIXELSPERBYTE = 2; + +static constexpr uint8_t SH1122_SETLOWCOLUMNADDRESS = 0x00; +static constexpr uint8_t SH1122_SETHIGHCOLUMNADDRESS = 0x10; +static constexpr uint8_t SH1122_SETDISCHARGEVSLLEVEL = 0x30; +static constexpr uint8_t SH1122_SETDISPLAYSTARTLINE = 0x40; +static constexpr uint8_t SH1122_SETCONTRASTCURRENT = 0x81; +static constexpr uint8_t SH1122_SETSEGMENTREMAP = 0xA0; +static constexpr uint8_t SH1122_SETDISPLAYOFFON = 0xA4; +static constexpr uint8_t SH1122_SETNORMALDISPLAY = 0xA6; +static constexpr uint8_t SH1122_SETMULTIPLEXRATIO = 0xA8; +static constexpr uint8_t SH1122_DCDCSETTING = 0xAD; +static constexpr uint8_t SH1122_SETDISPLAYOFF = 0xAE; +static constexpr uint8_t SH1122_SETDISPLAYON = 0xAF; +static constexpr uint8_t SH1122_SETROWADDRESS = 0xB0; +static constexpr uint8_t SH1122_SETSCANDIRECTION = 0xC0; +static constexpr uint8_t SH1122_SETDISPLAYOFFSET = 0xD3; +static constexpr uint8_t SH1122_SETCLOCKDIVIDER = 0xD5; +static constexpr uint8_t SH1122_SETDISCHARGEPRECHARGEPERIOD = 0xD9; +static constexpr uint8_t SH1122_SETVCOMDESELECTLEVEL = 0xDB; +static constexpr uint8_t SH1122_SETVSEGMLEVEL = 0xDC; + +void SH1122::setup() { + this->init_internal_(this->get_buffer_length_()); + if (this->buffer_ == nullptr) { + this->mark_failed(); + return; + } + + this->turn_off(); + this->command2_(SH1122_SETCLOCKDIVIDER, 0x50); + this->command2_(SH1122_SETMULTIPLEXRATIO, 0x3F); + this->command2_(SH1122_SETDISPLAYOFFSET, 0x00); + this->command2_(SH1122_SETROWADDRESS, 0x00); + this->command_(SH1122_SETDISPLAYSTARTLINE | 32); // the panel's rows start half way down the 128 line RAM + this->command_(SH1122_SETDISCHARGEVSLLEVEL); + this->command2_(SH1122_DCDCSETTING, 0x81); + this->command_(SH1122_SETSEGMENTREMAP | 0x01); // column 0 on the right + this->command_(SH1122_SETSCANDIRECTION | 0x08); // rows scanned bottom up + this->command2_(SH1122_SETDISCHARGEPRECHARGEPERIOD, 0x28); + this->command2_(SH1122_SETVCOMDESELECTLEVEL, 0x35); + this->command2_(SH1122_SETVSEGMLEVEL, 0x35); + this->command_(SH1122_SETNORMALDISPLAY); + this->command_(SH1122_SETHIGHCOLUMNADDRESS); + this->command_(SH1122_SETLOWCOLUMNADDRESS); + this->command_(SH1122_SETDISPLAYOFFON); + this->set_brightness(this->brightness_); + this->display(); // the buffer starts cleared; push it so no power-on garbage shows + this->turn_on(); +} +void SH1122::display() { + // Return the RAM pointer to the top left corner in one transfer, then stream the frame + const uint8_t set_address[] = {SH1122_SETHIGHCOLUMNADDRESS, SH1122_SETLOWCOLUMNADDRESS, SH1122_SETROWADDRESS, 0x00}; + this->write_command(set_address, sizeof(set_address)); + this->write_display_data(); +} +void SH1122::update() { + this->do_update_(); + this->display(); +} +void SH1122::set_brightness(float brightness) { + this->brightness_ = clamp(brightness, 0.0F, 1.0F); + this->command2_(SH1122_SETCONTRASTCURRENT, int(SH1122_MAX_CONTRAST * (this->brightness_))); +} +void SH1122::turn_on() { + this->command_(SH1122_SETDISPLAYON); + this->is_on_ = true; +} +void SH1122::turn_off() { + this->command_(SH1122_SETDISPLAYOFF); + this->is_on_ = false; +} +int SH1122::get_height_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 64; + default: + return 0; + } +} +int SH1122::get_width_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 256; + default: + return 0; + } +} +size_t SH1122::get_buffer_length_() { + return size_t(this->get_width_internal()) * size_t(this->get_height_internal()) / SH1122_PIXELSPERBYTE; +} +void HOT SH1122::draw_absolute_pixel_internal(int x, int y, Color color) { + const int width = this->get_width_internal(); + if (x >= width || x < 0 || y >= this->get_height_internal() || y < 0) + return; + // Two pixels per byte, the left one in the high nibble + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color) & SH1122_COLORMASK; + const uint16_t pos = (x / SH1122_PIXELSPERBYTE) + (y * width / SH1122_PIXELSPERBYTE); + const uint8_t shift = (1u - (x % SH1122_PIXELSPERBYTE)) * SH1122_COLORSHIFT; + this->buffer_[pos] = (this->buffer_[pos] & (static_cast(~SH1122_COLORMASK) >> shift)) | (color4 << shift); +} +void SH1122::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color); + const uint8_t fill = (color4 & SH1122_COLORMASK) | ((color4 & SH1122_COLORMASK) << SH1122_COLORSHIFT); + memset(this->buffer_, fill, this->get_buffer_length_()); +} +void SH1122::init_reset_() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(1); + // Trigger Reset + this->reset_pin_->digital_write(false); + delay(10); + // Wake up + this->reset_pin_->digital_write(true); + } +} +const LogString *SH1122::model_str_() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return LOG_STR("SH1122 256x64"); + default: + return LOG_STR("Unknown"); + } +} + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_base/sh1122_base.h b/esphome/components/sh1122_base/sh1122_base.h new file mode 100644 index 00000000000..c3e4719d395 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/display/display_buffer.h" + +namespace esphome::sh1122_base { + +enum SH1122Model : uint8_t { + SH1122_MODEL_256_64 = 0, +}; + +class SH1122 : public display::DisplayBuffer { + public: + void setup() override; + + void display(); + + void update() override; + + void set_model(SH1122Model model) { this->model_ = model; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + void init_brightness(float brightness) { this->brightness_ = brightness; } + void set_brightness(float brightness); + bool is_on() { return this->is_on_; } + void turn_on(); + void turn_off(); + + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + void fill(Color color) override; + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_GRAYSCALE; } + + protected: + /// Sends a command byte and its arguments; the SH1122 takes command arguments with D/C low as well + virtual void write_command(const uint8_t *bytes, size_t len) = 0; + virtual void write_display_data() = 0; + void command_(uint8_t value) { this->write_command(&value, 1); } + void command2_(uint8_t value, uint8_t data) { + const uint8_t bytes[2] = {value, data}; + this->write_command(bytes, 2); + } + void init_reset_(); + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + + int get_height_internal() override; + int get_width_internal() override; + size_t get_buffer_length_(); + const LogString *model_str_(); + + GPIOPin *reset_pin_{nullptr}; + float brightness_{1.0}; + SH1122Model model_{SH1122_MODEL_256_64}; + bool is_on_{false}; +}; + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_spi/__init__.py b/esphome/components/sh1122_spi/__init__.py new file mode 100644 index 00000000000..4ed70222673 --- /dev/null +++ b/esphome/components/sh1122_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sh1122_spi" diff --git a/esphome/components/sh1122_spi/display.py b/esphome/components/sh1122_spi/display.py new file mode 100644 index 00000000000..36acb9fd591 --- /dev/null +++ b/esphome/components/sh1122_spi/display.py @@ -0,0 +1,37 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import sh1122_base, spi +import esphome.config_validation as cv +from esphome.const import CONF_DC_PIN, CONF_ID, CONF_LAMBDA, CONF_PAGES +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@pl4nkton"] + +AUTO_LOAD = ["sh1122_base"] +DEPENDENCIES = ["spi"] + +sh1122_spi = cg.esphome_ns.namespace("sh1122_spi") +SPISH1122 = sh1122_spi.class_("SPISH1122", sh1122_base.SH1122, spi.SPIDevice) + +CONFIG_SCHEMA = cv.All( + sh1122_base.SH1122_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SPISH1122), + cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema, + } + ).extend(spi.spi_device_schema(cs_pin_required=False)), + cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), +) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + DOMAIN, require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) + var = cg.new_Pvariable(config[CONF_ID], dc) + await sh1122_base.setup_sh1122(var, config) + await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/sh1122_spi/sh1122_spi.cpp b/esphome/components/sh1122_spi/sh1122_spi.cpp new file mode 100644 index 00000000000..2e925aea29d --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.cpp @@ -0,0 +1,42 @@ +#include "sh1122_spi.h" +#include "esphome/core/log.h" + +namespace esphome::sh1122_spi { + +ESPHOME_LOG_TAG(TAG, "sh1122_spi"); + +void SPISH1122::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + + this->init_reset_(); + delay(500); // NOLINT + SH1122::setup(); +} +void SPISH1122::dump_config() { + LOG_DISPLAY("", "SPI SH1122", this); + ESP_LOGCONFIG(TAG, + " Model: %s\n" + " Initial Brightness: %.2f", + LOG_STR_ARG(this->model_str_()), this->brightness_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} +// enable() and disable() drive the CS pin. The D/C setup time is a few tens of nanoseconds, so the +// GPIO write followed by the CS edge already satisfies it; no delay is needed. +void SPISH1122::write_command(const uint8_t *bytes, size_t len) { + this->dc_pin_->digital_write(false); + this->enable(); + this->write_array(bytes, len); + this->disable(); +} +void HOT SPISH1122::write_display_data() { + this->dc_pin_->digital_write(true); + this->enable(); + this->write_array(this->buffer_, this->get_buffer_length_()); + this->disable(); +} + +} // namespace esphome::sh1122_spi diff --git a/esphome/components/sh1122_spi/sh1122_spi.h b/esphome/components/sh1122_spi/sh1122_spi.h new file mode 100644 index 00000000000..f5da209fe27 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/sh1122_base/sh1122_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::sh1122_spi { + +class SPISH1122 final : public sh1122_base::SH1122, + public spi::SPIDevice { + public: + explicit SPISH1122(GPIOPin *dc_pin) : dc_pin_(dc_pin) {} + + void setup() override; + + void dump_config() override; + + protected: + void write_command(const uint8_t *bytes, size_t len) override; + void write_display_data() override; + + GPIOPin *dc_pin_; +}; + +} // namespace esphome::sh1122_spi diff --git a/tests/components/sh1122_spi/common.yaml b/tests/components/sh1122_spi/common.yaml new file mode 100644 index 00000000000..a48a1a5711c --- /dev/null +++ b/tests/components/sh1122_spi/common.yaml @@ -0,0 +1,19 @@ +display: + - platform: sh1122_spi + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${reset_pin} + brightness: 80% + pages: + - id: sh1122_spi_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: sh1122_spi_page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: sh1122_spi_page1 + to: sh1122_spi_page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/sh1122_spi/test.esp32-idf.yaml b/tests/components/sh1122_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..10efaac7f45 --- /dev/null +++ b/tests/components/sh1122_spi/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + reset_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.esp8266-ard.yaml b/tests/components/sh1122_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..bc1105479d6 --- /dev/null +++ b/tests/components/sh1122_spi/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + dc_pin: GPIO15 + reset_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.rp2040-ard.yaml b/tests/components/sh1122_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..91ec731ca95 --- /dev/null +++ b/tests/components/sh1122_spi/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO20 + dc_pin: GPIO21 + reset_pin: GPIO22 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + sh1122_spi: !include common.yaml From 132e9dac8b52ae13aeeab07b205e2e026fbf00f2 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 13 Oct 2026 01:13:11 +0200 Subject: [PATCH 415/435] [tmc2209] Add TMC2209 stepper driver (#20437) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/tmc2209/__init__.py | 10 + esphome/components/tmc2209/stepper.py | 14 + esphome/components/tmc2209/tmc2209.cpp | 13 + esphome/components/tmc2209/tmc2209.h | 15 + esphome/components/tmc22xx/__init__.py | 211 ++++++++++ esphome/components/tmc22xx/tmc22xx.cpp | 372 ++++++++++++++++++ esphome/components/tmc22xx/tmc22xx.h | 167 ++++++++ tests/components/tmc2209/common.yaml | 57 +++ tests/components/tmc2209/test.esp32-idf.yaml | 9 + .../components/tmc2209/test.esp8266-ard.yaml | 9 + tests/components/tmc2209/test.rp2040-ard.yaml | 9 + 12 files changed, 888 insertions(+) create mode 100644 esphome/components/tmc2209/__init__.py create mode 100644 esphome/components/tmc2209/stepper.py create mode 100644 esphome/components/tmc2209/tmc2209.cpp create mode 100644 esphome/components/tmc2209/tmc2209.h create mode 100644 esphome/components/tmc22xx/__init__.py create mode 100644 esphome/components/tmc22xx/tmc22xx.cpp create mode 100644 esphome/components/tmc22xx/tmc22xx.h create mode 100644 tests/components/tmc2209/common.yaml create mode 100644 tests/components/tmc2209/test.esp32-idf.yaml create mode 100644 tests/components/tmc2209/test.esp8266-ard.yaml create mode 100644 tests/components/tmc2209/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b25611b3b66..2c637b0d200 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -617,6 +617,8 @@ esphome/components/tm1621/* @Philippe12 esphome/components/tm1637/* @glmnet esphome/components/tm1638/* @skykingjwc esphome/components/tm1651/* @mrtoy-me +esphome/components/tmc2209/* @remcom +esphome/components/tmc22xx/* @remcom esphome/components/tmp102/* @timsavage esphome/components/tmp1075/* @sybrenstuvel esphome/components/tmp117/* @Azimath diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py new file mode 100644 index 00000000000..b3822f7582e --- /dev/null +++ b/esphome/components/tmc2209/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import tmc22xx + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc2209" + +tmc2209_ns = cg.esphome_ns.namespace("tmc2209") +TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) + +tmc22xx.register_actions("tmc2209", TMC2209Stepper) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py new file mode 100644 index 00000000000..5ef91dc591f --- /dev/null +++ b/esphome/components/tmc2209/stepper.py @@ -0,0 +1,14 @@ +from esphome.components import tmc22xx +from esphome.types import ConfigType + +from . import TMC2209Stepper + +AUTO_LOAD = ["tmc22xx"] +DEPENDENCIES = ["uart"] + +CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate + + +async def to_code(config: ConfigType) -> None: + await tmc22xx.new_tmc22xx(config) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp new file mode 100644 index 00000000000..040b362a8d5 --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -0,0 +1,13 @@ +#include "tmc2209.h" +#include "esphome/core/log.h" + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209"); + +void TMC2209Stepper::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209:"); + TMC22XXStepper::dump_config(); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h new file mode 100644 index 00000000000..2f880251e56 --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/tmc22xx/tmc22xx.h" + +namespace esphome::tmc2209 { + +class TMC2209Stepper : public tmc22xx::TMC22XXStepper { + public: + void dump_config() override; + + protected: + uint8_t expected_version() const override { return 0x21; } +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py new file mode 100644 index 00000000000..ad7221f0b21 --- /dev/null +++ b/esphome/components/tmc22xx/__init__.py @@ -0,0 +1,211 @@ +"""Shared code for Trinamic stepper drivers configured over the single wire UART interface.""" + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import stepper, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DIR_PIN, + CONF_ID, + CONF_STEP_PIN, + CONF_UART_ID, +) +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc22xx" +DEPENDENCIES = ["uart"] + +CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" +CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" +CONF_ENN_PIN = "enn_pin" +CONF_HOLD_CURRENT = "hold_current" +CONF_IHOLD = "ihold" +CONF_IHOLDDELAY = "iholddelay" +CONF_INDEX_PIN = "index_pin" +CONF_INTERPOLATION = "interpolation" +CONF_INVERSE_DIRECTION = "inverse_direction" +CONF_IRUN = "irun" +CONF_MICROSTEPS = "microsteps" +CONF_OTTRIM = "ottrim" +CONF_RSENSE = "rsense" +CONF_RUN_CURRENT = "run_current" +CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TPOWERDOWN = "tpowerdown" +CONF_TPWM_THRESHOLD = "tpwm_threshold" +CONF_VSENSE = "vsense" + +tmc22xx_ns = cg.esphome_ns.namespace("tmc22xx") +TMC22XXStepper = tmc22xx_ns.class_( + "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice +) +StandstillMode = tmc22xx_ns.enum("StandstillMode") + +STANDSTILL_MODES = { + "normal": StandstillMode.STANDSTILL_MODE_NORMAL, + "freewheeling": StandstillMode.STANDSTILL_MODE_FREEWHEELING, + "coil_short_ls": StandstillMode.STANDSTILL_MODE_COIL_SHORT_LS, + "coil_short_hs": StandstillMode.STANDSTILL_MODE_COIL_SHORT_HS, +} + +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: + """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" + return cv.All( + stepper.STEPPER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(cls), + cv.Optional(CONF_ADDRESS, default=0): cv.int_range(0, max_address), + cv.Optional(CONF_ENN_PIN): pins.gpio_output_pin_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_CLOCK_FREQUENCY, default="12MHz"): cv.All( + cv.frequency, cv.int_range(min=1) + ), + cv.Optional(CONF_RSENSE): cv.All( + cv.resistance, cv.float_range(min=0.0, min_included=False) + ), + cv.Optional(CONF_VSENSE): cv.boolean, + cv.Optional(CONF_OTTRIM): cv.int_range(0, 3), + cv.Optional(CONF_ANALOG_CURRENT_SCALE, default=False): cv.boolean, + cv.Optional(CONF_RUN_CURRENT): validate_current, + cv.Optional(CONF_HOLD_CURRENT): validate_current, + cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + cv.has_exactly_one_key(CONF_INDEX_PIN, CONF_STEP_PIN), + cv.has_none_or_all_keys(CONF_STEP_PIN, CONF_DIR_PIN), + _validate_analog_current_scale, + ) + + +def _validate_analog_current_scale(config: ConfigType) -> ConfigType: + if config[CONF_ANALOG_CURRENT_SCALE]: + for key in (CONF_RUN_CURRENT, CONF_HOLD_CURRENT): + if key in config: + raise cv.Invalid( + f"'{key}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', the " + f"current then depends on VREF; set '{CONF_IRUN}'/'{CONF_IHOLD}' " + "with the currents action instead", + path=[key], + ) + return config + + +def final_validate(config: ConfigType) -> ConfigType: + """Reject two drivers of the family with the same address on one UART bus.""" + for other in fv.full_config.get().get("stepper", []): + if other is config: + break + if ( + other.get(CONF_UART_ID) == config[CONF_UART_ID] + and other.get(CONF_ADDRESS) == config[CONF_ADDRESS] + ): + raise cv.Invalid( + f"Address {config[CONF_ADDRESS]} is already used by '{other[CONF_ID]}' " + "on the same UART bus" + ) + return config + + +async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + await stepper.register_stepper(var, config) + + cg.add(var.set_address(config[CONF_ADDRESS])) + cg.add(var.set_clock_frequency(config[CONF_CLOCK_FREQUENCY])) + cg.add(var.set_analog_current_scale(config[CONF_ANALOG_CURRENT_SCALE])) + cg.add(var.set_initial_microsteps(config[CONF_MICROSTEPS])) + for key, setter in ( + (CONF_ENN_PIN, var.set_enn_pin), + (CONF_STEP_PIN, var.set_step_pin), + (CONF_DIR_PIN, var.set_dir_pin), + (CONF_INDEX_PIN, var.set_index_pin), + ): + if (pin := config.get(key)) is not None: + cg.add(setter(await cg.gpio_pin_expression(pin))) + for key, setter in ( + (CONF_RSENSE, var.set_rsense), + (CONF_VSENSE, var.set_vsense), + (CONF_OTTRIM, var.set_ottrim), + (CONF_RUN_CURRENT, var.set_initial_run_current), + (CONF_HOLD_CURRENT, var.set_initial_hold_current), + ): + if (value := config.get(key)) is not None: + cg.add(setter(value)) + return var + + +def register_actions(domain: str, cls: MockObjClass) -> None: + """Register the actions shared by the family under `domain`, e.g. `tmc2209.enable`.""" + id_schema = {cv.GenerateID(): cv.use_id(cls)} + for name, enabled in (("enable", "true"), ("disable", "false")): + automation.register_apply_action( + f"{domain}.{name}", + automation.maybe_simple_id(id_schema), + automation.ApplyCall(f"set_enabled({enabled})"), + ) + + automation.register_apply_action( + f"{domain}.configure", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_INVERSE_DIRECTION): cv.templatable(cv.boolean), + cv.Optional(CONF_MICROSTEPS): cv.templatable(validate_microsteps), + cv.Optional(CONF_INTERPOLATION): cv.templatable(cv.boolean), + cv.Optional(CONF_ENABLE_SPREADCYCLE): cv.templatable(cv.boolean), + cv.Optional(CONF_TPWM_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField( + CONF_INVERSE_DIRECTION, "set_inverse_direction", cg.bool_ + ), + automation.ApplyField(CONF_MICROSTEPS, "set_microsteps", cg.uint16), + automation.ApplyField(CONF_INTERPOLATION, "set_interpolation", cg.bool_), + automation.ApplyField(CONF_ENABLE_SPREADCYCLE, "set_spreadcycle", cg.bool_), + automation.ApplyField(CONF_TPWM_THRESHOLD, "set_tpwm_threshold", cg.uint32), + ) + + automation.register_apply_action( + f"{domain}.currents", + cv.Schema( + { + **id_schema, + cv.Exclusive(CONF_RUN_CURRENT, "run"): cv.templatable(validate_current), + cv.Exclusive(CONF_IRUN, "run"): cv.templatable(cv.int_range(0, 31)), + cv.Exclusive(CONF_HOLD_CURRENT, "hold"): cv.templatable( + validate_current + ), + cv.Exclusive(CONF_IHOLD, "hold"): cv.templatable(cv.int_range(0, 31)), + cv.Optional(CONF_IHOLDDELAY): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.int_range(0, 255)), + cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( + cv.enum(STANDSTILL_MODES, lower=True) + ), + } + ), + automation.ApplyField(CONF_RUN_CURRENT, "set_run_current", cg.float_), + automation.ApplyField(CONF_IRUN, "set_irun", cg.uint8), + automation.ApplyField(CONF_HOLD_CURRENT, "set_hold_current", cg.float_), + automation.ApplyField(CONF_IHOLD, "set_ihold", cg.uint8), + automation.ApplyField(CONF_IHOLDDELAY, "set_iholddelay", cg.uint8), + automation.ApplyField(CONF_TPOWERDOWN, "set_tpowerdown", cg.uint8), + automation.ApplyField( + CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode + ), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp new file mode 100644 index 00000000000..1628572d406 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -0,0 +1,372 @@ +#include "tmc22xx.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::tmc22xx { + +ESPHOME_LOG_TAG(TAG, "tmc22xx"); + +static constexpr uint8_t SYNC_BYTE = 0x05; +static constexpr uint8_t MASTER_ADDRESS = 0xFF; +static constexpr uint8_t WRITE_BIT = 0x80; +static constexpr size_t READ_REQUEST_SIZE = 4; +static constexpr size_t DATAGRAM_SIZE = 8; +static constexpr uint8_t DEFAULT_TOFF = 3; +static constexpr float SENSE_RESISTOR_OFFSET = 0.02f; // Ohm, added to RSENSE in the datasheet current formula +static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resistance when using internal sensing +// INDEX pulses are counted once per loop, so near the target the speed is limited to cover the remaining +// distance in no less than this time. A slow loop pass then cannot run far past the target. +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; + +// 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) { + uint8_t crc = 0; + for (size_t i = 0; i < len; i++) { + const uint8_t reflected = reverse_bits(data[i]); + crc = crc8(&reflected, 1, crc, 0x07, true); + } + return crc; +} + +void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } + +void TMC22XXStepper::setup() { + if (this->enn_pin_ != nullptr) { + this->enn_pin_->setup(); + this->enn_pin_->digital_write(true); + } + + auto version = this->read_field(VERSION); + if (!version.has_value()) { + ESP_LOGE(TAG, "No response from driver at address %u", this->address_); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->version_ = *version; + if (this->version_ != this->expected_version()) { + 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->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); + + this->write_field(PDN_DISABLE, true); + this->write_field(MSTEP_REG_SELECT, true); + this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); + this->write_field(I_SCALE_ANALOG, this->analog_current_scale_); + if (this->vsense_.has_value()) + this->write_field(VSENSE, *this->vsense_); + if (this->ottrim_.has_value()) + this->write_field(OTTRIM, *this->ottrim_); + this->vsense_active_ = this->read_field(VSENSE).value_or(0); + + // TOFF=0 disables the outputs, keep the current value so it can be restored on enable + auto toff = this->read_field(TOFF); + if (toff.has_value() && *toff != 0) + 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); +} + +void TMC22XXStepper::dump_config() { + ESP_LOGCONFIG(TAG, + " Address: %u\n" + " IC version: 0x%02X", + this->address_, this->version_); + // Register reads on a failed driver would only wait for the UART timeout + if (!this->is_failed()) { + uint8_t irun = this->read_field(IRUN).value_or(0); + uint8_t ihold = this->read_field(IHOLD).value_or(0); + ESP_LOGCONFIG(TAG, " Microsteps: %u", this->get_microsteps()); + if (this->analog_current_scale_) { + ESP_LOGCONFIG(TAG, + " Run current scale: %u\n" + " Hold current scale: %u\n" + " Current scaled by VREF", + irun, ihold); + } else { + ESP_LOGCONFIG(TAG, + " Run current: %.2f A\n" + " Hold current: %.2f A\n" + " Max current: %.2f A", + this->scale_to_current(irun), this->scale_to_current(ihold), this->scale_to_current(31)); + } + } + LOG_PIN(" ENN Pin: ", this->enn_pin_); + LOG_PIN(" STEP Pin: ", this->step_pin_); + LOG_PIN(" DIR Pin: ", this->dir_pin_); + LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_STEPPER(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void TMC22XXStepper::loop() { + if (this->index_pin_ != nullptr) + this->add_index_pulses_(); + if (this->has_reached_target()) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + if (!this->enabled_) + this->set_enabled(true); + } + + if (this->index_pin_ != nullptr) { + this->loop_serial_(); + } else { + this->loop_step_dir_(); + } +} + +void TMC22XXStepper::add_index_pulses_() { + InterruptLock lock; + this->current_position += this->index_store_.pulses; + this->index_store_.pulses = 0; +} + +void TMC22XXStepper::loop_serial_() { + int32_t remaining = this->target_position - this->current_position; + int8_t direction = (remaining > 0) - (remaining < 0); + // Ran past the target, restart the ramp from standstill in the other direction + if (direction != 0 && direction == -this->direction_) + this->current_speed_ = 0.0f; + this->calculate_speed_(micros()); + this->current_speed_ = std::min(this->current_speed_, static_cast(std::abs(remaining)) / APPROACH_TIME_S); + + int32_t vactual = direction * this->speed_to_vactual_(this->current_speed_); + int32_t last = this->get_vactual_(); + if (vactual == last) + return; + if (vactual != 0 && (vactual > 0) == (last > 0) && std::abs(vactual - last) < std::abs(last) / VACTUAL_CHANGE_DIVISOR) + return; + // Pulses that arrive while VACTUAL changes direction are counted in the new direction + this->index_store_.direction = direction; + this->direction_ = direction; + this->write_field(VACTUAL, static_cast(vactual)); +} + +void TMC22XXStepper::loop_step_dir_() { + int32_t direction = this->should_step_(); + if (direction == 0) + return; + if (direction != this->direction_) { + this->dir_pin_->digital_write(direction > 0); + this->direction_ = direction; + delayMicroseconds(1); + } + this->step_state_ = !this->step_state_; + this->step_pin_->digital_write(this->step_state_); +} + +void TMC22XXStepper::on_shutdown() { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); +} + +void TMC22XXStepper::set_enabled(bool enabled) { + if (!enabled) { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); + if (this->index_pin_ != nullptr) { + // Count the pulses up to the stop, so the position is current before the target is set + this->add_index_pulses_(); + this->index_store_.direction = 0; + this->direction_ = 0; + } + // Stop at the current position, so the motor does not run off again when re-enabled + this->target_position = this->current_position; + } + if (this->enn_pin_ != nullptr) { + this->enn_pin_->digital_write(!enabled); + } else { + this->write_field(TOFF, enabled ? this->toff_ : 0); + } + this->enabled_ = enabled; +} + +void TMC22XXStepper::set_microsteps(uint16_t microsteps) { + for (uint8_t mres = 0; mres <= 8; mres++) { + if ((256u >> mres) == microsteps) { + this->write_field(MRES, mres); + return; + } + } + ESP_LOGW(TAG, "Invalid microsteps %u", microsteps); +} + +uint16_t TMC22XXStepper::get_microsteps() { + auto mres = this->read_field(MRES); + if (!mres.has_value() || *mres > 8) + return 0; + return 256u >> *mres; +} + +void TMC22XXStepper::set_run_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IRUN, *scale); +} + +void TMC22XXStepper::set_hold_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IHOLD, *scale); +} + +float TMC22XXStepper::full_scale_current_() { + return (this->vsense_active_ ? 0.180f : 0.325f) / + ((std::isnan(this->rsense_) ? INTERNAL_RSENSE_OHM : this->rsense_) + SENSE_RESISTOR_OFFSET); +} + +float TMC22XXStepper::scale_to_current(uint8_t scale) { + return (std::min(scale, 31) + 1) / 32.0f * this->full_scale_current_() / std::numbers::sqrt2_v; +} + +optional TMC22XXStepper::current_to_scale_(float current) { + if (this->analog_current_scale_) { + ESP_LOGW(TAG, "Current in A is not supported with analog current scale, use irun/ihold"); + return {}; + } + float scale = 32.0f * std::numbers::sqrt2_v * current / this->full_scale_current_() - 1.0f; + if (scale > 31.0f) { + ESP_LOGW(TAG, "%.2f A is above the %.2f A limit, using the limit", current, this->scale_to_current(31)); + return 31; + } + return static_cast(std::max(0.0f, std::round(scale))); +} + +int32_t TMC22XXStepper::speed_to_vactual_(float speed) const { + // VACTUAL is in microsteps per 2^24 clock cycles + return static_cast(std::lround(speed * 16777216.0f / this->clock_frequency_)); +} + +uint32_t *TMC22XXStepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_IHOLD_IRUN: + return &this->ihold_irun_; + case REG_TPOWERDOWN: + return &this->tpowerdown_; + case REG_TPWMTHRS: + return &this->tpwmthrs_; + case REG_VACTUAL: + return &this->vactual_reg_; + default: + return nullptr; + } +} + +bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { + uint8_t datagram[DATAGRAM_SIZE] = {SYNC_BYTE, + this->address_, + static_cast(reg | WRITE_BIT), + static_cast(value >> 24), + static_cast(value >> 16), + static_cast(value >> 8), + static_cast(value), + 0}; + datagram[7] = datagram_crc(datagram, 7); + + while (this->available()) + this->read(); + this->write_array(datagram, DATAGRAM_SIZE); + this->flush(); + // On the single wire bus every byte sent is also received, drop that echo + uint8_t echo[DATAGRAM_SIZE]; + if (!this->read_array(echo, DATAGRAM_SIZE) || memcmp(echo, datagram, DATAGRAM_SIZE) != 0) { + ESP_LOGV(TAG, "No echo writing register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->status_clear_warning(); + + if (uint32_t *shadow = this->shadow_register(reg)) + *shadow = value; + return true; +} + +optional TMC22XXStepper::read_register(uint8_t reg) { + if (uint32_t *shadow = this->shadow_register(reg)) + return *shadow; + + uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; + request[3] = datagram_crc(request, 3); + + while (this->available()) + this->read(); + this->write_array(request, READ_REQUEST_SIZE); + this->flush(); + uint8_t reply[DATAGRAM_SIZE]; + if (!this->read_array(reply, READ_REQUEST_SIZE) || !this->read_array(reply, DATAGRAM_SIZE)) { + ESP_LOGV(TAG, "No reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + if (reply[0] != SYNC_BYTE || reply[1] != MASTER_ADDRESS || reply[2] != reg || reply[7] != datagram_crc(reply, 7)) { + ESP_LOGV(TAG, "Invalid reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + this->status_clear_warning(); + return encode_uint32(reply[3], reply[4], reply[5], reply[6]); +} + +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); + if (!data.has_value()) + return false; + uint32_t mask = field.mask(); + return this->write_register(field.reg, (*data & ~mask) | ((value << field.shift) & mask)); +} + +optional TMC22XXStepper::read_field(const RegisterField &field) { + auto data = this->read_register(field.reg); + if (!data.has_value()) + return {}; + return extract_field(*data, field); +} + +uint32_t TMC22XXStepper::extract_field(uint32_t data, const RegisterField &field) { + uint32_t value = (data & field.mask()) >> field.shift; + if (field.is_signed && field.width < 32) { + uint32_t sign_bit = 1u << (field.width - 1); + value = (value ^ sign_bit) - sign_bit; + } + return value; +} + +} // namespace esphome::tmc22xx diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h new file mode 100644 index 00000000000..8b0c92d3520 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -0,0 +1,167 @@ +#pragma once + +#include + +#include "esphome/components/stepper/stepper.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tmc22xx { + +/// A bit field inside one register. +struct RegisterField { + uint8_t reg; + uint8_t shift; + uint8_t width; + bool is_signed; + + constexpr uint32_t mask() const { return ((width >= 32) ? 0xFFFFFFFFu : ((1u << width) - 1u)) << shift; } +}; + +// Registers shared by the TMC22xx family (TMC2202, TMC2208, TMC2209, TMC2224, TMC2225, TMC2226) +static constexpr uint8_t REG_GCONF = 0x00; +static constexpr uint8_t REG_GSTAT = 0x01; +static constexpr uint8_t REG_IOIN = 0x06; +static constexpr uint8_t REG_FACTORY_CONF = 0x07; +static constexpr uint8_t REG_IHOLD_IRUN = 0x10; +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_PWMCONF = 0x70; + +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}; +static constexpr RegisterField SHAFT{REG_GCONF, 3, 1, false}; +static constexpr RegisterField INDEX_OTPW{REG_GCONF, 4, 1, false}; +static constexpr RegisterField INDEX_STEP{REG_GCONF, 5, 1, false}; +static constexpr RegisterField PDN_DISABLE{REG_GCONF, 6, 1, false}; +static constexpr RegisterField MSTEP_REG_SELECT{REG_GCONF, 7, 1, false}; +static constexpr RegisterField MULTISTEP_FILT{REG_GCONF, 8, 1, false}; +static constexpr RegisterField VERSION{REG_IOIN, 24, 8, false}; +static constexpr RegisterField OTTRIM{REG_FACTORY_CONF, 8, 2, false}; +static constexpr RegisterField IHOLD{REG_IHOLD_IRUN, 0, 5, false}; +static constexpr RegisterField IRUN{REG_IHOLD_IRUN, 8, 5, false}; +static constexpr RegisterField IHOLDDELAY{REG_IHOLD_IRUN, 16, 4, false}; +static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; +static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; +static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; +static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +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 FREEWHEEL{REG_PWMCONF, 20, 2, false}; + +enum StandstillMode : uint8_t { + STANDSTILL_MODE_NORMAL = 0, + STANDSTILL_MODE_FREEWHEELING = 1, + STANDSTILL_MODE_COIL_SHORT_LS = 2, + STANDSTILL_MODE_COIL_SHORT_HS = 3, +}; + +/// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. +struct IndexPulseStore { + volatile int32_t pulses{0}; + volatile int8_t direction{0}; + static void gpio_intr(IndexPulseStore *arg); +}; + +/// Base class for Trinamic stepper drivers that are configured over the single wire UART interface. +class TMC22XXStepper : public stepper::Stepper, public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_address(uint8_t address) { this->address_ = address; } + void set_enn_pin(GPIOPin *pin) { this->enn_pin_ = pin; } + 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_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; } + void set_ottrim(uint8_t ottrim) { this->ottrim_ = ottrim; } + void set_analog_current_scale(bool enable) { this->analog_current_scale_ = enable; } + void set_initial_run_current(float current) { this->initial_run_current_ = current; } + void set_initial_hold_current(float current) { this->initial_hold_current_ = current; } + void set_initial_microsteps(uint16_t microsteps) { this->initial_microsteps_ = microsteps; } + + /// 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_; } + + void set_microsteps(uint16_t microsteps); + uint16_t get_microsteps(); + void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } + void set_spreadcycle(bool enable) { this->write_field(EN_SPREADCYCLE, enable); } + void set_inverse_direction(bool inverse) { this->write_field(SHAFT, inverse); } + void set_tpwm_threshold(uint32_t threshold) { this->write_field(TPWMTHRS, threshold); } + + /// Set the motor run or hold current in A RMS. + /// Not supported with analog current scale, use set_irun() and set_ihold() instead. + void set_run_current(float current); + void set_hold_current(float current); + void set_irun(uint8_t irun) { this->write_field(IRUN, irun); } + void set_ihold(uint8_t ihold) { this->write_field(IHOLD, ihold); } + void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } + void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } + void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + /// Convert a current scale (0-31) to A RMS, without analog current scale. + float scale_to_current(uint8_t scale); + + bool write_register(uint8_t reg, uint32_t value); + optional read_register(uint8_t reg); + bool write_field(const RegisterField &field, uint32_t value); + optional read_field(const RegisterField &field); + static uint32_t extract_field(uint32_t data, const RegisterField &field); + + protected: + /// IC version reported in IOIN by the supported chip. + 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); + optional current_to_scale_(float current); + float full_scale_current_(); + int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } + int32_t speed_to_vactual_(float speed) const; + void add_index_pulses_(); + void loop_serial_(); + void loop_step_dir_(); + + GPIOPin *enn_pin_{nullptr}; + GPIOPin *step_pin_{nullptr}; + GPIOPin *dir_pin_{nullptr}; + InternalGPIOPin *index_pin_{nullptr}; + 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}; + // 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}; + optional vsense_{}; + optional ottrim_{}; + uint8_t address_{0}; + uint8_t toff_{3}; + uint8_t version_{0}; + int8_t direction_{0}; + bool analog_current_scale_{false}; + bool enabled_{false}; + bool vsense_active_{false}; + bool step_state_{false}; + HighFrequencyLoopRequester high_freq_; +}; + +} // namespace esphome::tmc22xx diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml new file mode 100644 index 00000000000..ff10c79edcf --- /dev/null +++ b/tests/components/tmc2209/common.yaml @@ -0,0 +1,57 @@ +stepper: + - platform: tmc2209 + id: tmc2209_step_dir + uart_id: uart_bus + address: 0 + step_pin: ${step_pin} + dir_pin: ${dir_pin} + enn_pin: ${enn_pin} + rsense: 110 mOhm + vsense: false + ottrim: 1 + run_current: 800mA + hold_current: 400mA + microsteps: 16 + max_speed: 2000 steps/s + acceleration: 1000 steps/s^2 + deceleration: 1000 steps/s^2 + + - platform: tmc2209 + id: tmc2209_serial + uart_id: uart_bus + address: 1 + index_pin: ${index_pin} + clock_frequency: 12MHz + analog_current_scale: true + max_speed: 1000 steps/s + +esphome: + on_boot: + then: + - tmc2209.configure: + id: tmc2209_step_dir + inverse_direction: true + microsteps: 8 + interpolation: true + enable_spreadcycle: false + tpwm_threshold: 0 + - tmc2209.configure: + id: tmc2209_serial + microsteps: !lambda "return 32;" + - tmc2209.currents: + id: tmc2209_step_dir + run_current: 1A + hold_current: 0.5A + iholddelay: 4 + tpowerdown: 20 + standstill_mode: freewheeling + - tmc2209.currents: + id: tmc2209_serial + irun: 20 + ihold: !lambda "return 5;" + - tmc2209.enable: tmc2209_serial + - stepper.set_target: + id: tmc2209_serial + target: 1000 + - tmc2209.disable: + id: tmc2209_step_dir diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml new file mode 100644 index 00000000000..5634c186860 --- /dev/null +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3e4ea33f7a5 --- /dev/null +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml new file mode 100644 index 00000000000..722f8f201fc --- /dev/null +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO2 + dir_pin: GPIO3 + enn_pin: GPIO6 + index_pin: GPIO7 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + tmc2209: !include common.yaml From fa519cea645745d3e08de08d8233c4bfbf6627da Mon Sep 17 00:00:00 2001 From: Ilya Esin Date: Mon, 12 Oct 2026 23:14:31 +0000 Subject: [PATCH 416/435] [apds9930] Add ambient light and proximity sensor (#13125) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/apds9930/__init__.py | 2 + esphome/components/apds9930/apds9930.cpp | 140 ++++++++++++++++++ esphome/components/apds9930/apds9930.h | 30 ++++ esphome/components/apds9930/sensor.py | 70 +++++++++ tests/components/apds9930/common.yaml | 13 ++ tests/components/apds9930/test.esp32-idf.yaml | 3 + .../components/apds9930/test.esp8266-ard.yaml | 3 + .../components/apds9930/test.rp2040-ard.yaml | 3 + 9 files changed, 265 insertions(+) create mode 100644 esphome/components/apds9930/__init__.py create mode 100644 esphome/components/apds9930/apds9930.cpp create mode 100644 esphome/components/apds9930/apds9930.h create mode 100644 esphome/components/apds9930/sensor.py create mode 100644 tests/components/apds9930/common.yaml create mode 100644 tests/components/apds9930/test.esp32-idf.yaml create mode 100644 tests/components/apds9930/test.esp8266-ard.yaml create mode 100644 tests/components/apds9930/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2c637b0d200..99a12f839f3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,7 @@ esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apds9306/* @aodrenah +esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core esphome/components/aqi/* @freekode @jasstrong @ximex esphome/components/as5600/* @ammmze diff --git a/esphome/components/apds9930/__init__.py b/esphome/components/apds9930/__init__.py new file mode 100644 index 00000000000..0e6330bd88a --- /dev/null +++ b/esphome/components/apds9930/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@iyesin"] +DOMAIN = "apds9930" diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp new file mode 100644 index 00000000000..6debc0d9c7b --- /dev/null +++ b/esphome/components/apds9930/apds9930.cpp @@ -0,0 +1,140 @@ +#include "apds9930.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::apds9930 { + +ESPHOME_LOG_TAG(TAG, "apds9930"); + +// Register addresses +static constexpr uint8_t APDS9930_ENABLE = 0x00; +static constexpr uint8_t APDS9930_ATIME = 0x01; +static constexpr uint8_t APDS9930_PTIME = 0x02; +static constexpr uint8_t APDS9930_PPULSE = 0x0E; +static constexpr uint8_t APDS9930_CONTROL = 0x0F; +static constexpr uint8_t APDS9930_ID = 0x12; +static constexpr uint8_t APDS9930_STATUS = 0x13; + +// Command byte: repeated byte and auto-increment protocols +static constexpr uint8_t APDS9930_CMD = 0x80; +static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0; + +static constexpr uint8_t APDS9930_CHIP_ID = 0x39; + +// ENABLE register bits +static constexpr uint8_t APDS9930_PON = 0x01; +static constexpr uint8_t APDS9930_AEN = 0x02; +static constexpr uint8_t APDS9930_PEN = 0x04; + +// STATUS register bits +static constexpr uint8_t APDS9930_AVALID = 0x01; +static constexpr uint8_t APDS9930_PVALID = 0x02; + +// CONTROL register fields +static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6; +static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4; +static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2; +static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0; +static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode + +static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms +static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms +static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses + +// Lux calculation coefficients +static constexpr float APDS9930_DF = 52.0f; +static constexpr float APDS9930_GA = 0.49f; +static constexpr float APDS9930_ALS_B = 1.862f; +static constexpr float APDS9930_ALS_C = 0.746f; +static constexpr float APDS9930_ALS_D = 1.291f; +static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE); + +static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120}; +static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8}; +static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step + +// Lux per count at 1x gain and the fixed integration time +static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS; +// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels +static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE); + +bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); } + +void APDS9930Component::setup() { + uint8_t id; + if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) { + this->mark_failed(LOG_STR("Communication failed")); + return; + } + if (id != APDS9930_CHIP_ID) { + ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID); + this->mark_failed(LOG_STR("Wrong chip ID")); + return; + } + + const uint8_t control = + (this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT | + (this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT; + + uint8_t enable = APDS9930_PON; + if (this->illuminance_sensor_ != nullptr) + enable |= APDS9930_AEN; + if (this->proximity_sensor_ != nullptr) + enable |= APDS9930_PEN; + + if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) || + !this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) || + !this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) || + !this->write_reg_(APDS9930_ENABLE, enable)) { + this->mark_failed(LOG_STR("Configuration failed")); + } +} + +void APDS9930Component::dump_config() { + ESP_LOGCONFIG(TAG, + "APDS9930:\n" + " LED Drive: %.1f mA\n" + " Proximity Gain: %ux\n" + " Ambient Light Gain: %ux", + LED_CURRENT_MAX_MA / (1 << this->led_drive_), + progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]), + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Proximity", this->proximity_sensor_); +} + +void APDS9930Component::update() { + // STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H + uint8_t raw[7]; + if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) { + this->status_set_warning(LOG_STR("Reading data failed")); + return; + } + this->status_clear_warning(); + const uint8_t status = raw[0]; + + if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { + const uint16_t ch0 = encode_uint16(raw[2], raw[1]); + const uint16_t ch1 = encode_uint16(raw[4], raw[3]); + if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) { + // A saturated channel would give a lux value that is too low, not too high + ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain"); + this->illuminance_sensor_->publish_state(NAN); + } else { + const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f}); + this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X / + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + } + } + + if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) { + this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5])); + } +} + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/apds9930.h b/esphome/components/apds9930/apds9930.h new file mode 100644 index 00000000000..cc7f77d3d02 --- /dev/null +++ b/esphome/components/apds9930/apds9930.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::apds9930 { + +class APDS9930Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(illuminance) + SUB_SENSOR(proximity) + + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_led_drive(uint8_t level) { this->led_drive_ = level; } + void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; } + void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; } + + protected: + bool write_reg_(uint8_t reg, uint8_t value); + + uint8_t led_drive_{0}; + uint8_t proximity_gain_{0}; + uint8_t ambient_gain_{0}; +}; + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py new file mode 100644 index 00000000000..2b169271c68 --- /dev/null +++ b/esphome/components/apds9930/sensor.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + UNIT_LUX, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +CONF_AMBIENT_LIGHT_GAIN = "ambient_light_gain" +CONF_LED_DRIVE = "led_drive" +CONF_PROXIMITY = "proximity" +CONF_PROXIMITY_GAIN = "proximity_gain" + +DRIVE_LEVELS = {"100ma": 0, "50ma": 1, "25ma": 2, "12.5ma": 3} +PROXIMITY_LEVELS = {"1x": 0, "2x": 1, "4x": 2, "8x": 3} +AMBIENT_LEVELS = {"1x": 0, "8x": 1, "16x": 2, "120x": 3} + +apds9930_ns = cg.esphome_ns.namespace("apds9930") +APDS9930Component = apds9930_ns.class_( + "APDS9930Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APDS9930Component), + cv.Optional(CONF_LED_DRIVE, default="100ma"): cv.enum( + DRIVE_LEVELS, lower=True + ), + cv.Optional(CONF_PROXIMITY_GAIN, default="8x"): cv.enum( + PROXIMITY_LEVELS, lower=True + ), + cv.Optional(CONF_AMBIENT_LIGHT_GAIN, default="1x"): cv.enum( + AMBIENT_LEVELS, lower=True + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PROXIMITY): sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x39)) +) + + +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_led_drive(config[CONF_LED_DRIVE])) + cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) + cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_PROXIMITY, var.set_proximity_sensor) diff --git a/tests/components/apds9930/common.yaml b/tests/components/apds9930/common.yaml new file mode 100644 index 00000000000..2cb9547e5cf --- /dev/null +++ b/tests/components/apds9930/common.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: apds9930 + i2c_id: i2c_bus + id: apds9930_sensor + address: 0x39 + update_interval: 30s + led_drive: 25ma + proximity_gain: 4x + ambient_light_gain: 16x + illuminance: + name: APDS9930 Illuminance + proximity: + name: APDS9930 Proximity diff --git a/tests/components/apds9930/test.esp32-idf.yaml b/tests/components/apds9930/test.esp32-idf.yaml new file mode 100644 index 00000000000..8226f36aa74 --- /dev/null +++ b/tests/components/apds9930/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.esp8266-ard.yaml b/tests/components/apds9930/test.esp8266-ard.yaml new file mode 100644 index 00000000000..913a4adeeaa --- /dev/null +++ b/tests/components/apds9930/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.rp2040-ard.yaml b/tests/components/apds9930/test.rp2040-ard.yaml new file mode 100644 index 00000000000..be77d931dfc --- /dev/null +++ b/tests/components/apds9930/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + apds9930: !include common.yaml From b21354d6d9bd33803db17b0ffef48a141e1b19f4 Mon Sep 17 00:00:00 2001 From: Romain Luyten Date: Tue, 13 Oct 2026 01:22:34 +0200 Subject: [PATCH 417/435] [mistral_ir] Add climate component for Mistral MHFE1126A IR AC units (#17812) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/mistral_ir/__init__.py | 1 + esphome/components/mistral_ir/climate.py | 16 + esphome/components/mistral_ir/mistral_ir.cpp | 135 +++++++ esphome/components/mistral_ir/mistral_ir.h | 38 ++ tests/components/mistral_ir/__init__.py | 14 + tests/components/mistral_ir/benchmark.yaml | 4 + tests/components/mistral_ir/common.yaml | 4 + .../mistral_ir/mistral_ir_tests.cpp | 330 ++++++++++++++++++ .../components/mistral_ir/test.esp32-idf.yaml | 3 + .../mistral_ir/test.esp8266-ard.yaml | 3 + 11 files changed, 549 insertions(+) create mode 100644 esphome/components/mistral_ir/__init__.py create mode 100644 esphome/components/mistral_ir/climate.py create mode 100644 esphome/components/mistral_ir/mistral_ir.cpp create mode 100644 esphome/components/mistral_ir/mistral_ir.h create mode 100644 tests/components/mistral_ir/__init__.py create mode 100644 tests/components/mistral_ir/benchmark.yaml create mode 100644 tests/components/mistral_ir/common.yaml create mode 100644 tests/components/mistral_ir/mistral_ir_tests.cpp create mode 100644 tests/components/mistral_ir/test.esp32-idf.yaml create mode 100644 tests/components/mistral_ir/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 99a12f839f3..f7875eb1407 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -379,6 +379,7 @@ esphome/components/midea_ir/* @dudanov esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow +esphome/components/mistral_ir/* @lerhum esphome/components/mitsubishi/* @RubyBailey esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt diff --git a/esphome/components/mistral_ir/__init__.py b/esphome/components/mistral_ir/__init__.py new file mode 100644 index 00000000000..deadd224d54 --- /dev/null +++ b/esphome/components/mistral_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mistral_ir" diff --git a/esphome/components/mistral_ir/climate.py b/esphome/components/mistral_ir/climate.py new file mode 100644 index 00000000000..cc152ca85d3 --- /dev/null +++ b/esphome/components/mistral_ir/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@lerhum"] + +mistral_ir_ns = cg.esphome_ns.namespace("mistral_ir") +MistralIR = mistral_ir_ns.class_("MistralIR", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MistralIR) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/mistral_ir/mistral_ir.cpp b/esphome/components/mistral_ir/mistral_ir.cpp new file mode 100644 index 00000000000..2eafcb348af --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.cpp @@ -0,0 +1,135 @@ +#include "mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::mistral_ir { + +ESPHOME_LOG_TAG(TAG, "mistral_ir.climate"); + +// Fixed bytes of every frame; bytes 1, 3, 4, 5, 6 and 11 are filled in per state +static const uint8_t FRAME_TEMPLATE[MISTRAL_FRAME_SIZE] PROGMEM = {0x56, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE8, 0x00, 0x60, 0x00}; +static const uint8_t FIXED_BYTES[] PROGMEM = {0, 2, 7, 8, 9, 10}; +static const uint8_t CHECKSUM_BYTES[] PROGMEM = {1, 3, 4, 5, 6, 8}; + +constexpr uint8_t MISTRAL_POWER_OFF = 0x00; +constexpr uint8_t MISTRAL_POWER_ON = 0x20; +constexpr uint8_t MISTRAL_TEMP_OFFSET = 0x9F; +constexpr uint8_t MISTRAL_MODE_AUTO = 0x10; + +// The tables live in flash, so entries are copied out before use +struct ModeCode { + climate::ClimateMode mode; + uint8_t code; +}; +static const ModeCode MODES[] PROGMEM = { + {climate::CLIMATE_MODE_COOL, 0xC0}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x80}, + {climate::CLIMATE_MODE_HEAT, 0x60}, + {climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO}, +}; + +struct FanCode { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; +}; +static const FanCode FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0xA4, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x44, 0x40}, + {climate::CLIMATE_FAN_MEDIUM, 0x24, 0xC0}, + {climate::CLIMATE_FAN_HIGH, 0xA4, 0xA0}, +}; + +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +template static optional find_entry(const T (&table)[N], Pred pred) { + for (const T &slot : table) { + T entry = load_entry(slot); + if (pred(entry)) + return entry; + } + return {}; +} + +static uint8_t compute_checksum(const uint8_t *frame) { + uint8_t sum = 0; + for (const uint8_t &idx : CHECKSUM_BYTES) + sum += reverse_bits(frame[progmem_read_byte(&idx)]); + return reverse_bits(sum); +} + +static bool is_valid_frame(const uint8_t *frame) { + for (const uint8_t &slot : FIXED_BYTES) { + const uint8_t idx = progmem_read_byte(&slot); + if (frame[idx] != progmem_read_byte(&FRAME_TEMPLATE[idx])) + return false; + } + return frame[11] == compute_checksum(frame); +} + +void MistralIR::transmit_state() { + const auto mode = find_entry(MODES, [this](const ModeCode &m) { return m.mode == this->mode; }); + const climate::ClimateFanMode fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO); + const FanCode fan = + find_entry(FANS, [fan_mode](const FanCode &f) { return f.fan == fan_mode; }).value_or(load_entry(FANS[0])); + const uint8_t temp = lroundf(clamp(this->target_temperature, MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX)); + + uint8_t frame[MISTRAL_FRAME_SIZE]; + progmem_memcpy(frame, FRAME_TEMPLATE, sizeof(frame)); + frame[1] = fan.byte1; + frame[3] = this->mode == climate::CLIMATE_MODE_OFF ? MISTRAL_POWER_OFF : MISTRAL_POWER_ON; + frame[4] = mode.has_value() ? mode->code : MISTRAL_MODE_AUTO; + frame[5] = reverse_bits(static_cast(MISTRAL_TEMP_OFFSET - temp)); + frame[6] = fan.byte6; + frame[11] = compute_checksum(frame); + + auto transmit = this->transmitter_->transmit(); + remote_base::AEHAProtocol().encode(transmit.get_data(), MISTRAL_ADDRESS, frame, sizeof(frame)); + transmit.perform(); +} + +bool MistralIR::on_receive(remote_base::RemoteReceiveData data) { + auto aeha = remote_base::AEHAProtocol().decode(data); + if (!aeha.has_value() || aeha->address != MISTRAL_ADDRESS || aeha->data.size() != MISTRAL_FRAME_SIZE) + return false; + + const uint8_t *frame = aeha->data.data(); + if (!is_valid_frame(frame)) + return false; + + if (frame[3] == MISTRAL_POWER_OFF) { + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + if (frame[3] != MISTRAL_POWER_ON) + return false; + + // Decode everything before touching the state so an unknown code leaves it untouched + const auto mode = find_entry(MODES, [frame](const ModeCode &m) { return m.code == frame[4]; }); + const auto fan = find_entry(FANS, [frame](const FanCode &f) { return f.byte1 == frame[1] && f.byte6 == frame[6]; }); + if (!mode.has_value() || !fan.has_value()) + return false; + + this->mode = mode->mode; + this->target_temperature = + clamp(MISTRAL_TEMP_OFFSET - reverse_bits(frame[5]), MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX); + this->fan_mode = fan->fan; + + ESP_LOGV(TAG, "Received Mistral frame: mode=%d temp=%.0f fan=%d", static_cast(this->mode), + this->target_temperature, static_cast(fan->fan)); + this->publish_state(); + return true; +} + +} // namespace esphome::mistral_ir diff --git a/esphome/components/mistral_ir/mistral_ir.h b/esphome/components/mistral_ir/mistral_ir.h new file mode 100644 index 00000000000..172af1d6a25 --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.h @@ -0,0 +1,38 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::mistral_ir { + +// Temperature range, Celsius +constexpr uint8_t MISTRAL_TEMP_MIN = 16; +constexpr uint8_t MISTRAL_TEMP_MAX = 30; + +constexpr uint16_t MISTRAL_ADDRESS = 0x322C; +constexpr size_t MISTRAL_FRAME_SIZE = 12; + +/* Reverse-engineered protocol for a "Mistral" (Belgian brand, model MHFE1126A / MR112C) split AC unit + from the 2000-2010 era, likely a rebadged OEM design. It is carried over AEHA IR timing (address + 0x322C) but does not match any protocol already implemented in ESPHome, so it is decoded/encoded + directly here rather than through the generic AEHA helper. + + Frame layout (12 bytes): 56 xx 08 xx xx xx xx 00 E8 00 60 xx + byte[1] / byte[6] -- fan speed + byte[3] -- power: 0x20 on, 0x00 off + byte[4] -- mode + byte[5] -- target temperature (bit-reversed offset from 0x9F) + byte[11] -- checksum: bit-reverse the sum of bit-reversed bytes 1, 3, 4, 5, 6, 8 +*/ +class MistralIR : public climate_ir::ClimateIR { + public: + MistralIR() + : climate_ir::ClimateIR(MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; +}; + +} // namespace esphome::mistral_ir diff --git a/tests/components/mistral_ir/__init__.py b/tests/components/mistral_ir/__init__.py new file mode 100644 index 00000000000..c74d97e1d16 --- /dev/null +++ b/tests/components/mistral_ir/__init__.py @@ -0,0 +1,14 @@ +from esphome.components import remote_base +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # 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 platform's to_code never runs here; without this request remote_base drops aeha_protocol.cpp + remote_base.request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/mistral_ir/benchmark.yaml b/tests/components/mistral_ir/benchmark.yaml new file mode 100644 index 00000000000..ef3f9fb05d1 --- /dev/null +++ b/tests/components/mistral_ir/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The mistral_ir, climate_ir and remote_transmitter sources come in through +# the dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/mistral_ir/common.yaml b/tests/components/mistral_ir/common.yaml new file mode 100644 index 00000000000..43a2225bcba --- /dev/null +++ b/tests/components/mistral_ir/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: mistral_ir + name: Mistral IR + transmitter_id: xmitr diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp new file mode 100644 index 00000000000..37db86d10a1 --- /dev/null +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -0,0 +1,330 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/mistral_ir/mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +namespace esphome::mistral_ir::testing { + +using Frame = std::vector; + +static constexpr uint8_t POWER_OFF = 0x00; +static constexpr uint8_t POWER_ON = 0x20; +static constexpr uint8_t MODE_COOL = 0xC0; +static constexpr uint8_t MODE_DRY = 0x40; +static constexpr uint8_t MODE_FAN_ONLY = 0x80; +static constexpr uint8_t MODE_HEAT = 0x60; +static constexpr uint8_t MODE_AUTO = 0x10; +static constexpr uint8_t FAN_AUTO_BYTE1 = 0xA4; +static constexpr uint8_t FAN_AUTO_BYTE6 = 0x00; +static constexpr uint8_t FAN_LOW_BYTE1 = 0x44; +static constexpr uint8_t FAN_LOW_BYTE6 = 0x40; +static constexpr uint8_t FAN_MEDIUM_BYTE1 = 0x24; +static constexpr uint8_t FAN_MEDIUM_BYTE6 = 0xC0; +static constexpr uint8_t FAN_HIGH_BYTE1 = 0xA4; +static constexpr uint8_t FAN_HIGH_BYTE6 = 0xA0; +static constexpr uint16_t WRONG_ADDRESS = 0x322D; +static constexpr uint32_t TOLERANCE_PERCENT = 25; + +static constexpr climate::ClimateMode ALL_MODES[] = { + climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_DRY, climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, +}; +static constexpr climate::ClimateFanMode ALL_FANS[] = { + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, +}; + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +static uint8_t checksum(const Frame &frame) { + uint8_t sum = 0; + for (size_t idx : {1, 3, 4, 5, 6, 8}) + sum += reverse_bits(frame[idx]); + return reverse_bits(sum); +} + +/// A received frame with a valid checksum, which callers may overwrite +static Frame make_frame(uint8_t byte1, uint8_t power, uint8_t mode, uint8_t temperature, uint8_t byte6) { + Frame frame{0x56, byte1, 0x08, power, mode, reverse_bits(static_cast(0x9F - temperature)), + byte6, 0x00, 0xE8, 0x00, 0x60, 0x00}; + frame[11] = checksum(frame); + return frame; +} + +static remote_base::RawTimings encode(uint16_t address, const Frame &frame) { + remote_base::RemoteTransmitData data; + remote_base::AEHAProtocol().encode(&data, address, frame.data(), frame.size()); + return data.get_data(); +} + +struct Fixture { + CapturingTransmitter transmitter; + MistralIR climate; + + Fixture() { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_mistral_ir"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.perform(); + } + + /// The 12 data bytes of the last transmission, decoded by the AEHA protocol + Frame last_frame() const { + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return {}; + remote_base::RemoteReceiveData data(this->transmitter.frames.back(), TOLERANCE_PERCENT, + remote_base::TOLERANCE_MODE_PERCENTAGE); + const auto aeha = remote_base::AEHAProtocol().decode(data); + EXPECT_TRUE(aeha.has_value()); + if (!aeha.has_value()) + return {}; + EXPECT_EQ(aeha->address, MISTRAL_ADDRESS); + EXPECT_EQ(aeha->data.size(), MISTRAL_FRAME_SIZE); + return aeha->data; + } + + bool receive(const remote_base::RawTimings &raw) { + remote_base::RemoteReceiveData data(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(data); + } + + bool receive(const Frame &frame, uint16_t address = MISTRAL_ADDRESS) { return this->receive(encode(address, frame)); } + + void expect_state(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) const { + EXPECT_EQ(this->climate.mode, mode); + EXPECT_FLOAT_EQ(this->climate.target_temperature, temperature); + EXPECT_EQ(this->climate.fan_mode, fan); + } +}; + +TEST(MistralIR, EncodesEachMode) { + struct Case { + climate::ClimateMode mode; + uint8_t code; + uint8_t checksum; + }; + // 22 C: 0x9F - 22 = 0x89, reversed 0x91. Checksum sums the reversed bytes 1, 3, 4, 5, 6 and 8: + // fan AUTO 0xA4 -> 0x25, power on 0x20 -> 0x04, mode, temperature 0x89, byte 6 0x00, 0xE8 -> 0x17 + const Case cases[] = { + // COOL 0xC0 -> 0x03: 0x25 + 0x04 + 0x03 + 0x89 + 0x00 + 0x17 = 0xCC, reversed 0x33 + {climate::CLIMATE_MODE_COOL, MODE_COOL, 0x33}, + // DRY 0x40 -> 0x02: sum 0xCB, reversed 0xD3 + {climate::CLIMATE_MODE_DRY, MODE_DRY, 0xD3}, + // FAN_ONLY 0x80 -> 0x01: sum 0xCA, reversed 0x53 + {climate::CLIMATE_MODE_FAN_ONLY, MODE_FAN_ONLY, 0x53}, + // HEAT 0x60 -> 0x06: sum 0xCF, reversed 0xF3 + {climate::CLIMATE_MODE_HEAT, MODE_HEAT, 0xF3}, + // HEAT_COOL 0x10 -> 0x08: sum 0xD1, reversed 0x8B + {climate::CLIMATE_MODE_HEAT_COOL, MODE_AUTO, 0x8B}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.mode)); + f.command(c.mode, 22.0f, climate::CLIMATE_FAN_AUTO); + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_ON, c.code, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, c.checksum}; + EXPECT_EQ(f.last_frame(), expected); + } +} + +TEST(MistralIR, EncodesEachFanMode) { + struct Case { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; + uint8_t checksum; + }; + // COOL at 24 C: 0x9F - 24 = 0x87, reversed 0xE1. Fixed part of the checksum sum: + // power on 0x04 + COOL 0x03 + temperature 0x87 + 0xE8 -> 0x17 = 0xA5, plus reversed bytes 1 and 6 + const Case cases[] = { + // AUTO 0xA4 -> 0x25, 0x00 -> 0x00: 0xA5 + 0x25 + 0x00 = 0xCA, reversed 0x53 + {climate::CLIMATE_FAN_AUTO, FAN_AUTO_BYTE1, FAN_AUTO_BYTE6, 0x53}, + // LOW 0x44 -> 0x22, 0x40 -> 0x02: 0xA5 + 0x22 + 0x02 = 0xC9, reversed 0x93 + {climate::CLIMATE_FAN_LOW, FAN_LOW_BYTE1, FAN_LOW_BYTE6, 0x93}, + // MEDIUM 0x24 -> 0x24, 0xC0 -> 0x03: 0xA5 + 0x24 + 0x03 = 0xCC, reversed 0x33 + {climate::CLIMATE_FAN_MEDIUM, FAN_MEDIUM_BYTE1, FAN_MEDIUM_BYTE6, 0x33}, + // HIGH 0xA4 -> 0x25, 0xA0 -> 0x05: 0xA5 + 0x25 + 0x05 = 0xCF, reversed 0xF3 + {climate::CLIMATE_FAN_HIGH, FAN_HIGH_BYTE1, FAN_HIGH_BYTE6, 0xF3}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.fan)); + f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[1], c.byte1); + EXPECT_EQ(frame[4], MODE_COOL); + EXPECT_EQ(frame[5], 0xE1); + EXPECT_EQ(frame[6], c.byte6); + EXPECT_EQ(frame[11], c.checksum); + } +} + +TEST(MistralIR, EncodesOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_AUTO); + // OFF has no mode code, so byte 4 falls back to auto 0x10 -> 0x08 with power 0x00: + // 0x25 + 0x00 + 0x08 + 0x89 + 0x00 + 0x17 = 0xCD, reversed 0xB3 + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_OFF, MODE_AUTO, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, 0xB3}; + EXPECT_EQ(f.last_frame(), expected); +} + +TEST(MistralIR, EncodesTemperatureRoundedAndClamped) { + struct Case { + float requested; + uint8_t byte5; + }; + const Case cases[] = { + {16.0f, 0xF1}, // 0x9F - 16 = 0x8F, reversed 0xF1 + {22.0f, 0x91}, // 0x9F - 22 = 0x89, reversed 0x91 + {30.0f, 0x81}, // 0x9F - 30 = 0x81, reversed 0x81 + {15.6f, 0xF1}, // rounds to 16 + {30.4f, 0x81}, // rounds to 30 + {10.0f, 0xF1}, // clamps to 16 + {35.0f, 0x81}, // clamps to 30 + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(c.requested); + f.command(climate::CLIMATE_MODE_COOL, c.requested, climate::CLIMATE_FAN_AUTO); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[5], c.byte5); + EXPECT_EQ(frame[11], checksum(frame)); + } +} + +TEST(MistralIR, RoundTripsEveryModeAndFan) { + for (climate::ClimateMode mode : ALL_MODES) { + for (climate::ClimateFanMode fan : ALL_FANS) { + for (float temperature : {16.0f, 23.0f, 30.0f}) { + SCOPED_TRACE(static_cast(mode) * 1000 + static_cast(fan) * 100 + static_cast(temperature)); + Fixture sender; + sender.command(mode, temperature, fan); + EXPECT_EQ(sender.last_frame().size(), MISTRAL_FRAME_SIZE); + Fixture receiver; + EXPECT_TRUE(receiver.receive(sender.transmitter.frames.back())); + receiver.expect_state(mode, temperature, fan); + } + } + } +} + +TEST(MistralIR, ReceivesHeatFrame) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_HEAT, 25.0f, climate::CLIMATE_FAN_LOW); +} + +TEST(MistralIR, ReceivedPowerOffKeepsTemperatureAndFan) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_AUTO_BYTE1, POWER_OFF, MODE_AUTO, 28, FAN_AUTO_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsBadChecksum) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[11] ^= 0x01; + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsChangedFixedByte) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (size_t idx : {0, 2, 7, 8, 9, 10}) { + SCOPED_TRACE(idx); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[idx] ^= 0x01; + // Byte 8 is part of the checksum, so keep it valid to test the fixed-byte check alone + frame[11] = checksum(frame); + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsUnknownPowerModeAndFan) { + struct Case { + const char *name; + uint8_t byte1; + uint8_t power; + uint8_t mode; + uint8_t byte6; + }; + const Case cases[] = { + {"power 0x10", FAN_LOW_BYTE1, 0x10, MODE_HEAT, FAN_LOW_BYTE6}, + {"mode 0x20", FAN_LOW_BYTE1, POWER_ON, 0x20, FAN_LOW_BYTE6}, + {"fan 0x44/0x00", FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, FAN_AUTO_BYTE6}, + {"fan 0x00/0x40", 0x00, POWER_ON, MODE_HEAT, FAN_LOW_BYTE6}, + }; + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (const Case &c : cases) { + SCOPED_TRACE(c.name); + EXPECT_FALSE(f.receive(make_frame(c.byte1, c.power, c.mode, 25, c.byte6))); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsWrongAddress) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + EXPECT_FALSE(f.receive(frame, WRONG_ADDRESS)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsWrongFrameLength) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + Frame shorter(frame.begin(), frame.begin() + 11); + EXPECT_FALSE(f.receive(shorter)); + Frame longer = frame; + longer.push_back(0x00); + EXPECT_FALSE(f.receive(longer)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +} // namespace esphome::mistral_ir::testing diff --git a/tests/components/mistral_ir/test.esp32-idf.yaml b/tests/components/mistral_ir/test.esp32-idf.yaml new file mode 100644 index 00000000000..4f0186d9eb8 --- /dev/null +++ b/tests/components/mistral_ir/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + mistral_ir: !include common.yaml diff --git a/tests/components/mistral_ir/test.esp8266-ard.yaml b/tests/components/mistral_ir/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3dddb492665 --- /dev/null +++ b/tests/components/mistral_ir/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + mistral_ir: !include common.yaml From 6c86a8903f1718a00e804d214cba783603f27a02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:28:17 -1000 Subject: [PATCH 418/435] [core] Report incomplete YAML discovery and record remote package sources (#20312) --- esphome/bundle.py | 12 +++ esphome/components/packages/__init__.py | 40 +++++++- esphome/yaml_util.py | 96 ++++++++++++++----- .../component_tests/packages/test_packages.py | 45 +++++++++ tests/unit_tests/test_bundle.py | 9 +- tests/unit_tests/test_yaml_util.py | 62 ++++++++++-- 6 files changed, 226 insertions(+), 38 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 45d9a74b49a..f499e0de1bc 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -418,6 +418,18 @@ class ConfigBundleCreator: must ship every candidate so the remote build can pick any one. """ discovered = yaml_util.discover_user_yaml_files(self._config_path) + if discovered.load_errors: + _LOGGER.warning( + "Bundle may be incomplete; could not load all configuration files: %s", + "; ".join(discovered.load_errors), + ) + if discovered.unresolved: + _LOGGER.warning( + "Bundle may be incomplete; %d !include path(s) use " + "substitutions and cannot be captured: %s", + len(discovered.unresolved), + ", ".join(discovered.unresolved), + ) self._secrets_paths.update(discovered.secrets) # A !secret inside a file this re-parse does not reach (for example # a git-fetched package the builder re-fetches) still resolves diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 9498d4b4da7..577385b2174 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,6 @@ from collections import UserDict from collections.abc import Callable +from dataclasses import dataclass, field from functools import reduce import logging from pathlib import Path @@ -35,11 +36,45 @@ from esphome.const import ( CONF_VARS, __version__ as ESPHOME_VERSION, ) -from esphome.core import EsphomeError +from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) DOMAIN = "packages" + + +@dataclass(frozen=True) +class RemotePackageSource: + """A remote source a package was fetched from while processing the config.""" + + url: str + ref: str | None + + +@dataclass +class PackagesData: + """Per-run package state, keyed under DOMAIN in CORE.data.""" + + # Insertion-ordered and de-duplicated + remote_sources: dict[RemotePackageSource, None] = field(default_factory=dict) + + +def _get_data() -> PackagesData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PackagesData() + return CORE.data[DOMAIN] + + +def get_remote_package_sources() -> tuple[RemotePackageSource, ...]: + """Remote sources fetched for this config, in fetch order. + + store_yaml uses them to tell remote parts of the config from local files. + """ + if (data := CORE.data.get(DOMAIN)) is None: + return () + return tuple(data.remote_sources) + + # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -193,6 +228,9 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), ) + _get_data().remote_sources[ + RemotePackageSource(config[CONF_URL], config.get(CONF_REF)) + ] = None files: list[dict[str, Any]] = [] # ``repo_root`` is the directory containing ``.git`` and must be passed diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 5882c133f6a..f02819a3edb 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -12,7 +12,7 @@ import math import os from pathlib import Path import re -from typing import Any +from typing import Any, NamedTuple import uuid import yaml @@ -410,9 +410,11 @@ def _load_include_candidates( include: IncludeFile, *, warn_on_unresolved: bool, + warn_on_load_error: bool, seen: set[int], expanded_paths: set[Path], keepalive: list[Any], + result: ForceLoadResult, ) -> None: """Load every filesystem candidate for an unresolved ``IncludeFile``.""" from voluptuous import Invalid @@ -425,6 +427,7 @@ def _load_include_candidates( include.file, include.parent_file, ) + result.unresolved.append(str(include.file)) return _LOGGER.debug( "Expanding !include %s (referenced from %s) to %d candidate file(s)", @@ -439,27 +442,27 @@ def _load_include_candidates( try: loaded = include.with_file(candidate.as_posix()).load() except (EsphomeError, Invalid) as err: - # Unlike an unresolved pattern (expected during the discovery - # re-parse), a matched on-disk candidate that fails to load is a - # genuine user error; warn in every mode. The file itself is - # still tracked (the load listener fires before parsing), only - # its nested includes go undiscovered. - _LOGGER.warning( + # A real user error. The file stays tracked (the listener fires + # before parsing); only its nested includes are missed. + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load candidate %s for !include %s: %s", candidate, include.file, err, ) + result.errors.append(f"{candidate}: {err}") continue # The throwaway IncludeFile is this tree's only owner; keep the tree # alive so ids recorded in ``seen`` stay unique for the traversal. keepalive.append(loaded) - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=seen, _expanded_paths=expanded_paths, _keepalive=keepalive, + _result=result, ) @@ -473,14 +476,19 @@ def find_secret_references(text: str) -> set[str]: return {match.group(1) for match in _SECRET_REFERENCE_RE.finditer(text)} +class ForceLoadResult(NamedTuple): + """Templated includes that matched no file, and includes that failed to load. + + Either being non-empty means the walk was incomplete. + """ + + unresolved: list[str] + errors: list[str] + + def force_load_include_files( - obj: Any, - *, - warn_on_unresolved: bool = True, - _seen: set[int] | None = None, - _expanded_paths: set[Path] | None = None, - _keepalive: list[Any] | None = None, -) -> None: + obj: Any, *, warn_on_unresolved: bool = True, warn_on_load_error: bool = True +) -> ForceLoadResult: """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved @@ -495,8 +503,30 @@ def force_load_include_files( expression could select. By default a warning is logged when no candidate exists; pass ``warn_on_unresolved=False`` (used by discovery paths that run on a fresh re-parse where substitutions haven't been applied yet) to - demote it to a debug log. + demote it to a debug log. Callers that report ``errors`` themselves pass + ``warn_on_load_error=False`` so load failures are not logged twice. """ + result = ForceLoadResult([], []) + _force_load_include_files( + obj, + warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, + _result=result, + ) + return result + + +def _force_load_include_files( + obj: Any, + *, + warn_on_unresolved: bool = True, + warn_on_load_error: bool = True, + _seen: set[int] | None = None, + _expanded_paths: set[Path] | None = None, + _keepalive: list[Any] | None = None, + _result: ForceLoadResult, +) -> None: + """Walk for :func:`force_load_include_files`, adding to ``result``.""" from voluptuous import Invalid if _seen is None: @@ -519,51 +549,60 @@ def force_load_include_files( _load_include_candidates( obj, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, seen=_seen, expanded_paths=_expanded_paths, keepalive=_keepalive, + result=_result, ) return try: loaded = obj.load() except (EsphomeError, Invalid) as err: - _LOGGER.warning( + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load !include %s (referenced from %s): %s", obj.file, obj.parent_file, err, ) + _result.errors.append(f"{obj.file}: {err}") return - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, dict): if id(obj) in _seen: return _seen.add(id(obj)) for value in obj.values(): - force_load_include_files( + _force_load_include_files( value, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, (list, tuple)): if id(obj) in _seen: return _seen.add(id(obj)) for item in obj: - force_load_include_files( + _force_load_include_files( item, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) @@ -575,11 +614,14 @@ class DiscoveredYamlFiles: were re-parsing the user's config; ``secrets`` is the subset whose *un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so a ``secrets.yaml`` symlinked to a differently-named target is still - flagged as secrets). + flagged as secrets). ``files`` is incomplete when ``unresolved`` (templated + include paths) or ``load_errors`` is non-empty. """ files: list[Path] = field(default_factory=list) secrets: set[Path] = field(default_factory=set) + unresolved: list[str] = field(default_factory=list) + load_errors: list[str] = field(default_factory=list) def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: @@ -609,9 +651,13 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: try: try: data = load_yaml(config_path) - except EsphomeError: - return DiscoveredYamlFiles(list(loaded), secrets) - force_load_include_files(data, warn_on_unresolved=False) + except EsphomeError as err: + return DiscoveredYamlFiles( + list(loaded), secrets, load_errors=[f"{config_path}: {err}"] + ) + unresolved, load_errors = force_load_include_files( + data, warn_on_unresolved=False, warn_on_load_error=False + ) finally: _load_listeners.remove(_capture_secret) @@ -622,7 +668,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: if path not in seen: seen.add(path) unique.append(path) - return DiscoveredYamlFiles(unique, secrets) + return DiscoveredYamlFiles(unique, secrets, unresolved, load_errors) def _add_data_ref(fn): diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 418cab5ea15..fd7e9f12786 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -10,9 +10,11 @@ import pytest from esphome import bundle from esphome.components.packages import ( CONFIG_SCHEMA, + RemotePackageSource, _substitute_package_definition, _walk_packages, do_packages_pass, + get_remote_package_sources, merge_packages, resolve_packages, ) @@ -614,6 +616,49 @@ def test_package_remove_by_missing_id() -> None: assert actual == expected +def test_get_remote_package_sources_empty_without_remote_packages() -> None: + """A config without remote packages records no sources.""" + assert get_remote_package_sources() == () + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_get_remote_package_sources_records_each_fetch_once( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """Every fetched remote source is recorded exactly once.""" + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + mock_load_yaml.side_effect = lambda *args, **kwargs: OrderedDict() + + config = { + CONF_PACKAGES: { + "first": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: [TEST_YAML_FILENAME], + }, + "again": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: ["other.yaml"], + }, + "second": { + CONF_URL: "https://github.com/esphome/repo-b", + CONF_FILES: [TEST_YAML_FILENAME], + }, + } + } + packages_pass(config) + + # packages are processed last to first, so repo-b is fetched before repo-a + assert get_remote_package_sources() == ( + RemotePackageSource("https://github.com/esphome/repo-b", None), + RemotePackageSource("https://github.com/esphome/repo-a", "main"), + ) + + @patch("esphome.yaml_util.load_yaml") @patch("pathlib.Path.is_file") @patch("esphome.git.clone_or_update") diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 6a086767840..fe4bf197157 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -1363,7 +1363,8 @@ def test_discover_files_candidate_outside_config_dir_skipped( def test_discover_files_nested_include_load_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A nested !include pointing at a missing file is logged and skipped.""" + """A nested !include pointing at a missing file is reported in the bundle + summary, not a second time by yaml_util, and skipped.""" config_dir = _setup_config_dir(tmp_path) (config_dir / "test.yaml").write_text( "esphome:\n name: test\nwifi: !include missing.yaml\n" @@ -1374,10 +1375,12 @@ def test_discover_files_nested_include_load_failure( paths = [f.path for f in files] assert "test.yaml" in paths + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any( - "failed to load !include" in r.message.lower() and "missing.yaml" in r.message - for r in caplog.records + m.startswith("Bundle may be incomplete") and "missing.yaml" in m + for m in warnings ) + assert not any("failed to load !include" in m.lower() for m in warnings) def test_force_load_skips_duplicate_include_file() -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 24a431b0c05..c30b03467f1 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1280,22 +1280,52 @@ def test_force_load_include_files_unresolved_log_level( assert matching == [expect_level] +def test_force_load_include_files_returns_unresolved_paths( + patch_include_file: None, +) -> None: + """Includes with substitution-templated paths are reported back to the + caller; resolvable ones are not.""" + templated = _StubInclude("${var}.yaml", unresolved=True) + plain = _StubInclude("ok.yaml") + result = force_load_include_files({"a": templated, "b": plain}) + assert result.unresolved == [str(templated.file)] + assert result.errors == [] + assert plain.load_calls == 1 + + def test_force_load_include_files_warns_on_load_failure( patch_include_file: None, caplog: pytest.LogCaptureFixture, ) -> None: - """An `EsphomeError` raised by `load()` is caught and logged, not propagated.""" + """An `EsphomeError` raised by `load()` is caught, logged, and reported to + the caller — not propagated.""" stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) with caplog.at_level("WARNING", logger="esphome.yaml_util"): - force_load_include_files({"k": stub}) + result = force_load_include_files({"k": stub}) assert any( "Failed to load !include" in r.message and "missing.yaml" in r.message for r in caplog.records ) + assert result.errors == [f"{stub.file}: boom"] + assert result.unresolved == [] + + +def test_force_load_include_files_load_failure_quiet_when_caller_reports( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """With warn_on_load_error=False the failure is still returned but only + logged at debug, so a caller that reports it does not duplicate it.""" + stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + result = force_load_include_files({"k": stub}, warn_on_load_error=False) + assert result.errors == [f"{stub.file}: boom"] + failures = [r for r in caplog.records if "Failed to load !include" in r.message] + assert [r.levelname for r in failures] == ["DEBUG"] def test_discovered_yaml_files_holds_files_and_secrets() -> None: - """`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory.""" + """`DiscoveredYamlFiles` is a small data carrier.""" files = [Path("/tmp/a.yaml")] secrets = {Path("/tmp/a.yaml")} discovered = DiscoveredYamlFiles(files, secrets) @@ -1358,11 +1388,24 @@ def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None: assert target.resolve() in discovered.secrets -def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None: - """A YAML parse failure returns whatever was tracked so far without raising.""" +def test_discover_user_yaml_files_reports_parse_errors(tmp_path: Path) -> None: + """A YAML parse failure is surfaced in `.load_errors` (not raised), so + consumers can tell the file set is incomplete.""" entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n") discovered = discover_user_yaml_files(entry) assert isinstance(discovered, DiscoveredYamlFiles) + assert len(discovered.load_errors) == 1 + assert "entry.yaml" in discovered.load_errors[0] + + +def test_discover_user_yaml_files_reports_unresolved_includes( + tmp_path: Path, +) -> None: + """A substitution-templated `!include` path is surfaced in `.unresolved`.""" + entry = _write_entry_including(tmp_path, "${board}.yaml") + discovered = discover_user_yaml_files(entry) + assert len(discovered.unresolved) == 1 + assert "${board}.yaml" in discovered.unresolved[0] def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: @@ -1589,9 +1632,9 @@ def test_discover_user_yaml_files_many_candidates_keep_nested_includes( def test_discover_user_yaml_files_bad_candidate_still_tracked( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A matched candidate that fails to parse warns even during discovery, - stays tracked (the load listener fires before parsing), and doesn't block - other candidates.""" + """A matched candidate that fails to parse is reported in ``load_errors`` + (logged only at debug, the caller reports it), stays tracked (the load + listener fires before parsing), and doesn't block other candidates.""" _write(tmp_path, "keys/good.yaml", "api:\n") _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") with caplog.at_level("DEBUG", logger="esphome.yaml_util"): @@ -1604,7 +1647,8 @@ def test_discover_user_yaml_files_bad_candidate_still_tracked( matching = [ r.levelname for r in caplog.records if "Failed to load candidate" in r.message ] - assert matching == ["WARNING"] + assert matching == ["DEBUG"] + assert any("bad.yaml" in e for e in discovered.load_errors) def test_discover_user_yaml_files_tolerates_templated_top_level_include( From cce435a31ed5172660aa61343d957cbab545393c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:34:44 -1000 Subject: [PATCH 419/435] [gpio] Keep each interlock group in one shared flash table (#20238) --- esphome/components/gpio/switch/__init__.py | 18 ++++++---- .../components/gpio/switch/gpio_switch.cpp | 4 --- esphome/components/gpio/switch/gpio_switch.h | 7 ++-- .../component_tests/gpio/test_gpio_switch.py | 33 +++++++++++++++++++ .../gpio/test_gpio_switch_interlock.yaml | 23 +++++++++++++ 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/gpio/test_gpio_switch.py create mode 100644 tests/component_tests/gpio/test_gpio_switch_interlock.yaml diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 766cdc4afb3..481b8d01dad 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -36,11 +36,17 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - if CONF_INTERLOCK in config: + if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") - interlock = [] - for it in config[CONF_INTERLOCK]: - lock = await cg.get_variable(it) - interlock.append(lock) - cg.add(var.set_interlock(interlock)) + locks = [await cg.get_variable(it) for it in interlock] + # The group's table lists this switch too, so every member shares one table. + group = sorted({str(s): s for s in (var, *locks)}.values(), key=str) + if len(group) > 1: + table = cg.shared_progmem_array( + "gpio_interlock", + switch.Switch.operator("ptr"), + cg.ArrayInitializer(*group), + constexpr=False, + ) + cg.add(var.set_interlock(table, len(group))) cg.add(var.set_interlock_wait_time(config[CONF_INTERLOCK_WAIT_TIME])) diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 450a97eb190..68086d94660 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -67,8 +67,4 @@ void GPIOSwitch::write_state(bool state) { this->publish_state(state); } -#ifdef USE_GPIO_SWITCH_INTERLOCK -void GPIOSwitch::set_interlock(const std::initializer_list &interlock) { this->interlock_ = interlock; } -#endif - } // namespace esphome::gpio diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index e7323e6e937..64e4f95f16b 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -21,7 +21,10 @@ class GPIOSwitch final : public switch_::Switch, public Component { void setup() override; void dump_config() override; #ifdef USE_GPIO_SWITCH_INTERLOCK - void set_interlock(const std::initializer_list &interlock); + /// Codegen only: the table must outlive this switch; it may list this switch, which is skipped. + void set_interlock(Switch *const *interlock, size_t count) { + this->interlock_ = ConstVector(interlock, count); + } void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; } #endif @@ -30,7 +33,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK - FixedVector interlock_; + ConstVector interlock_; uint32_t interlock_wait_time_{0}; #endif }; diff --git a/tests/component_tests/gpio/test_gpio_switch.py b/tests/component_tests/gpio/test_gpio_switch.py new file mode 100644 index 00000000000..e62f11fd090 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch.py @@ -0,0 +1,33 @@ +"""Tests for the GPIO switch component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_gpio_switch_interlock_group_shares_one_table( + generate_main: Callable[[str | Path], str], +) -> None: + """Every member of an interlock group points at the same flash table.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + tables = re.findall(r"(gpio_interlock\w*)\[\] PROGMEM = \{([^}]*)\};", main_cpp) + assert tables == [("gpio_interlock", "sw_a, sw_b, sw_c")] + for sw in ("sw_a", "sw_b", "sw_c"): + assert f"{sw}->set_interlock(gpio_interlock, 3);" in main_cpp + + +def test_gpio_switch_interlock_with_only_itself_has_no_table( + generate_main: Callable[[str | Path], str], +) -> None: + """A switch interlocked only with itself gets no table and no set_interlock.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + assert "sw_self->set_interlock(" not in main_cpp + assert "sw_self->set_interlock_wait_time(0);" in main_cpp diff --git a/tests/component_tests/gpio/test_gpio_switch_interlock.yaml b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml new file mode 100644 index 00000000000..a3da64388f9 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: esp32dev + +switch: + - platform: gpio + pin: 12 + id: sw_a + interlock: [sw_b, sw_c] + - platform: gpio + pin: 13 + id: sw_b + interlock: [sw_a, sw_c] + - platform: gpio + pin: 14 + id: sw_c + interlock: [sw_a, sw_b] + - platform: gpio + pin: 15 + id: sw_self + interlock: [sw_self] From 72ca565a0e4e2a676748b202caef348332d88a14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:35:03 -1000 Subject: [PATCH 420/435] [core] Resume if and while from the end of their branch instead of an embedded continuation action (#20283) --- esphome/core/automation.h | 61 ++- esphome/core/base_automation.h | 100 ++--- tests/components/api/common-base.yaml | 8 +- .../core/test_action_branch_owner.cpp | 403 ++++++++++++++++++ .../fixtures/branch_resume_async.yaml | 28 ++ .../fixtures/continuation_actions.yaml | 14 +- tests/integration/test_branch_resume_async.py | 14 + .../integration/test_continuation_actions.py | 9 +- 8 files changed, 533 insertions(+), 104 deletions(-) create mode 100644 tests/components/core/test_action_branch_owner.cpp diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 35c2a17c247..bbb7f08fb8e 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -576,20 +576,22 @@ template class Action { /// the following actions in the chain. int num_running_total() { int total = this->num_running_; - if (this->next_ != nullptr) - total += this->next_->num_running_total(); + if (Action *next = this->chain_next_()) + total += next->num_running_total(); return total; } protected: friend ActionList; - template friend class ContinuationAction; + /// For a branch owner (if, while), play() runs when the branch finishes. virtual void play(const Ts &...x) = 0; void play_next_(const Ts &...x) { if (this->num_running_ > 0) { this->num_running_--; - if (this->next_ != nullptr) { + if (this->next_is_owner_()) { + this->owner_()->play(x...); + } else if (this->next_ != nullptr) { this->next_->play_complex(x...); } } @@ -603,15 +605,24 @@ template class Action { virtual void stop() {} void stop_next_() { - if (this->next_ != nullptr) { - this->next_->stop_complex(); - } + if (Action *next = this->chain_next_()) + next->stop_complex(); } bool is_running_next_() { - if (this->next_ == nullptr) - return false; - return this->next_->is_running(); + Action *next = this->chain_next_(); + return next != nullptr && next->is_running(); + } + + // A branch's last action points at its owner with the low bit set. Only Action objects are + // tagged, never function pointers, which use that bit on ARM Thumb (#9283). + static constexpr uintptr_t OWNER_TAG = 1; + bool next_is_owner_() const { return reinterpret_cast(this->next_) & OWNER_TAG; } + /// Only valid when next_is_owner_(). + Action *owner_() const { return reinterpret_cast(reinterpret_cast(this->next_) - OWNER_TAG); } + Action *chain_next_() const { return this->next_is_owner_() ? nullptr : this->next_; } + static Action *tag_owner(Action *owner) { + return reinterpret_cast(reinterpret_cast(owner) + OWNER_TAG); } Action *next_{nullptr}; @@ -621,25 +632,30 @@ template class Action { int num_running_{0}; }; +// The vtable pointer gives every instantiation this alignment. +static_assert(alignof(Action<>) >= 2, "the owner tag needs a free low bit in Action object pointers"); + template class ActionList { public: void add_action(Action *action) { // Walk to end of chain - action lists are short and only built during setup() - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; - *tail = action; + *this->tail_() = action; } void add_actions(const std::initializer_list *> &actions) { // Find tail once, then append all actions in a single pass - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; + Action **tail = this->tail_(); for (auto *action : actions) { *tail = action; tail = &action->next_; } } + /// Resume `owner` via play() when the list finishes; call once, after adding actions. + void set_owner(Action *owner) { + // Nothing to tag in an empty list + if (this->actions_ == nullptr) + return; + *this->tail_() = Action::tag_owner(owner); + } // Force-inline: part of the Trigger→Automation→ActionList forwarding // chain collapsed to reduce automation call stack depth. inline void play(const Ts &...x) ESPHOME_ALWAYS_INLINE { @@ -672,6 +688,17 @@ template class ActionList { template void play_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play(std::get(tuple)...); } + // Lists with an owner are complete; never append after set_owner() + Action **tail_() { + Action **tail = &this->actions_; + while (*tail != nullptr) { + // Not checked in release builds: only automation.py builds these lists, appending each action and + // then finishing the branch once, so appending after set_owner() would be a codegen bug. + ESPHOME_DEBUG_ASSERT(!(*tail)->next_is_owner_()); + tail = &(*tail)->next_; + } + return tail; + } Action *actions_{nullptr}; }; diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 09cde1261d0..c59e27f43ab 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -278,70 +278,33 @@ template class ApplyCondition final : public Condition< bool check(const Ts &...x) override { return Fn(x...); } }; -/// Simple continuation action that calls play_next_ on a parent action. -/// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class ContinuationAction : public Action { - public: - explicit ContinuationAction(Action *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->play_next_(x...); } - - protected: - Action *parent_; -}; - -// Forward declaration for WhileLoopContinuation -template class WhileAction; - -/// Loop continuation for WhileAction that checks condition and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class WhileLoopContinuation : public Action { - public: - explicit WhileLoopContinuation(WhileAction *parent) : parent_(parent) {} - - void play(const Ts &...x) override; - - protected: - WhileAction *parent_; -}; - -// Wraps a ContinuationAction when Enabled, empty otherwise. -// Lets IfAction elide the else continuation when HasElse is false. -template struct OptionalContinuation { - ContinuationAction action; - explicit OptionalContinuation(Action *parent) : action(parent) {} -}; -template struct OptionalContinuation { - explicit OptionalContinuation(Action * /*parent*/) {} -}; - template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} // Precondition: build each branch either with one add_then/add_else call, or with // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. - // Appending the inline continuation twice forms a self-loop in the next_ chain. + // set_owner() closes the list; appending after it is a codegen bug. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->then_continuation_); + this->then_.set_owner(this); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(&this->else_continuation_.action); + this->else_.set_owner(this); } // Codegen appends branch actions one at a time, then finishes each branch once; a list literal // would be a constant array in .rodata, which is RAM on ESP8266. void add_then_action(Action *action) { this->then_.add_action(action); } - void finish_then() { this->then_.add_action(&this->then_continuation_); } + void finish_then() { this->then_.set_owner(this); } void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } - void finish_else() requires(HasElse) { this->else_.add_action(&this->else_continuation_.action); } + void finish_else() requires(HasElse) { this->else_.set_owner(this); } - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; + // The condition may stop this automation (e.g. script.stop in a lambda) if (this->condition_->check(x...)) { if (!this->then_.empty() && this->num_running_ > 0) { this->then_.play(x...); @@ -356,8 +319,8 @@ template class IfAction : public Action { this->play_next_(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ - } + // Branch-end resume hook + void play(const Ts &...x) override { this->play_next_(x...); } void stop() override { this->then_.stop(); @@ -369,10 +332,8 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; - ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; - [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { @@ -383,14 +344,12 @@ template class WhileAction : public Action { // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->loop_continuation_); + this->then_.set_owner(this); } void add_then_action(Action *action) { this->then_.add_action(action); } - void finish_then() { this->then_.add_action(&this->loop_continuation_); } + void finish_then() { this->then_.set_owner(this); } - friend class WhileLoopContinuation; - - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; // Initial condition check if (!this->condition_->check(x...)) { @@ -400,12 +359,25 @@ template class WhileAction : public Action { return; } - if (this->num_running_ > 0) { - this->then_.play(x...); + // The condition may have stopped this automation + if (this->num_running_ == 0) + return; + // An empty body cannot resume the loop + if (this->then_.empty()) { + this->play_next_(x...); + return; } + this->then_.play(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ + // Branch-end resume hook: loop again or continue + void play(const Ts &...x) override { + // The condition may stop this automation + if (this->num_running_ > 0 && this->condition_->check(x...) && this->num_running_ > 0) { + this->then_.play(x...); + } else { + this->play_next_(x...); + } } void stop() override { this->then_.stop(); } @@ -413,25 +385,13 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; - WhileLoopContinuation loop_continuation_{this}; }; -// Implementation of WhileLoopContinuation::play -template void WhileLoopContinuation::play(const Ts &...x) { - if (this->parent_->num_running_ > 0 && this->parent_->condition_->check(x...)) { - // play again - this->parent_->then_.play(x...); - } else { - // condition false, play next - this->parent_->play_next_(x...); - } -} - // Forward declaration for RepeatLoopContinuation template class RepeatAction; /// Loop continuation for RepeatAction that increments iteration and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +/// Repeat keeps a continuation: its body takes an extra uint32_t, so it cannot be tagged. template class RepeatLoopContinuation : public Action { public: explicit RepeatLoopContinuation(RepeatAction *parent) : parent_(parent) {} @@ -447,7 +407,7 @@ template class RepeatAction : public Action { TEMPLATABLE_VALUE(uint32_t, count) // Precondition: use either add_then or add_then_action plus one finish_then, never both - // (see IfAction::add_then). + // (see IfAction::add_then); appending the continuation twice forms a self-loop. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 1513d537c21..7f6cb83f16c 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -181,7 +181,7 @@ api: room: !lambda 'return room;' ratio: !lambda 'return level / 100.0f;' - homeassistant.tag_scanned: !lambda 'return room;' - # Test ContinuationAction (IfAction with then/else branches) + # Test IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -199,7 +199,7 @@ api: format: "Condition false, value: %ld" args: ['(long) value'] - logger.log: "After if/else" - # Test nested IfAction (multiple ContinuationAction instances) + # Test nested IfAction - action: test_nested_if variables: outer: bool @@ -219,7 +219,7 @@ api: else: - logger.log: "Outer false" - logger.log: "After nested if" - # Test WhileLoopContinuation (WhileAction) + # Test WhileAction - action: test_while_action variables: max_count: int @@ -234,7 +234,7 @@ api: args: ['id(api_continuation_test_counter)'] - lambda: 'id(api_continuation_test_counter)++;' - logger.log: "After while loop" - # Test RepeatLoopContinuation (RepeatAction) + # Test RepeatAction - action: test_repeat_action variables: count: int diff --git a/tests/components/core/test_action_branch_owner.cpp b/tests/components/core/test_action_branch_owner.cpp new file mode 100644 index 00000000000..126f932686d --- /dev/null +++ b/tests/components/core/test_action_branch_owner.cpp @@ -0,0 +1,403 @@ +#include +#include + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::core::testing { + +using Log = std::vector; + +// Records its id when it runs, then continues the chain +class RecordAction : public Action<> { + public: + RecordAction(Log *log, int id) : log_(log), id_(id) {} + void play() override { this->log_->push_back(this->id_); } + Action<> *next() const { return this->next_; } + + protected: + Log *log_; + int id_; +}; + +// Holds the chain until finish() is called, like a delay +class DeferredAction : public Action<> { + public: + explicit DeferredAction(Log *log, int id) : log_(log), id_(id) {} + void play_complex() override { + this->num_running_++; + this->log_->push_back(this->id_); + this->pending_ = true; + } + void play() override {} + void stop() override { this->pending_ = false; } + void finish() { + if (this->pending_) { + this->pending_ = false; + this->play_next_(); + } + } + bool pending() const { return this->pending_; } + + protected: + Log *log_; + int id_; + bool pending_{false}; +}; + +class FlagCondition : public Condition<> { + public: + bool check() override { return this->value; } + bool value{true}; +}; + +// Stops the given list during check(), like script.stop in a lambda condition +class StoppingCondition : public Condition<> { + public: + bool check() override { + if (this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; +}; + +// Stops the given list on the Nth check and keeps returning true +class StopOnCheckCondition : public Condition<> { + public: + explicit StopOnCheckCondition(int stop_on) : stop_on_(stop_on) {} + bool check() override { + if (++this->checks_ == this->stop_on_ && this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; + + protected: + int stop_on_; + int checks_{0}; +}; + +// Turns its condition off after a number of checks +class CountdownCondition : public Condition<> { + public: + explicit CountdownCondition(int passes) : passes_(passes) {} + bool check() override { return this->passes_-- > 0; } + + protected: + int passes_; +}; + +// Reads an action's next_ through Action's own tag helpers +class TaggedOwner : public RecordAction { + public: + using RecordAction::RecordAction; + static bool is_tagged(const TaggedOwner *a) { return a->next_is_owner_(); } + static Action<> *owner_of(const TaggedOwner *a) { return a->owner_(); } +}; + +class InspectList : public ActionList<> { + public: + Action<> *head() const { return this->actions_; } +}; + +TEST(ActionBranchOwner, SetOwnerTagsOnlyTheLastAction) { + Log log; + TaggedOwner a(&log, 1), b(&log, 2), c(&log, 3); + TaggedOwner owner(&log, 99); + InspectList list; + list.add_actions({&a, &b}); + list.add_action(&c); // appending before set_owner keeps order + list.set_owner(&owner); + EXPECT_EQ(list.head(), &a); + EXPECT_EQ(a.next(), &b); + EXPECT_EQ(b.next(), &c); + EXPECT_FALSE(TaggedOwner::is_tagged(&a)); + EXPECT_FALSE(TaggedOwner::is_tagged(&b)); + EXPECT_TRUE(TaggedOwner::is_tagged(&c)); + EXPECT_EQ(TaggedOwner::owner_of(&c), &owner); +} + +TEST(ActionBranchOwner, EmptyListStaysEmpty) { + Log log; + TaggedOwner owner(&log, 99); + InspectList list; + list.set_owner(&owner); + EXPECT_EQ(list.head(), nullptr); + EXPECT_TRUE(list.empty()); +} + +// ESPHOME_DEBUG_ASSERT is only live because script/cpp_unit_test.py builds with -DESPHOME_DEBUG +TEST(ActionBranchOwnerDeathTest, AppendAfterSetOwnerAsserts) { + Log log; + RecordAction a(&log, 1), late(&log, 2); + TaggedOwner owner(&log, 99); + ActionList<> list; + list.add_action(&a); + list.set_owner(&owner); + EXPECT_DEATH(list.add_action(&late), ""); +} + +TEST(ActionBranchOwner, IfThenResumesAfterTheIfOnce) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then({&t1, &t2}); + if_action.add_else({&e1}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4})); + log.clear(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{3, 4})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// The per-item calls automation.py emits +TEST(ActionBranchOwner, PerItemIfAndWhileResumeAfterTheirBranch) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then_action(&t1); + if_action.add_then_action(&t2); + if_action.finish_then(); + if_action.add_else_action(&e1); + if_action.finish_else(); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4, 3, 4})); + + log.clear(); + CountdownCondition loop_cond(2); + WhileAction<> loop(&loop_cond); + DeferredAction wait(&log, 5); + RecordAction after2(&log, 6); + loop.add_then_action(&wait); + loop.finish_then(); + ActionList<> top2; + top2.add_actions({&loop, &after2}); + top2.play(); + wait.finish(); + wait.finish(); + EXPECT_EQ(log, (Log{5, 5, 6})); + EXPECT_FALSE(top2.is_running()); +} + +// then: [] generates finish_then() with no actions +TEST(ActionBranchOwner, PerItemEmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + if_action.finish_then(); + if_action.finish_else(); + WhileAction<> loop(&cond); + loop.finish_then(); + RecordAction after(&log, 1), after2(&log, 2); + ActionList<> top, top2; + top.add_actions({&if_action, &after}); + top2.add_actions({&loop, &after2}); + top.play(); + cond.value = false; + top.play(); + cond.value = true; + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, ConditionThatStopsTheRunStartsNoBranch) { + Log log; + StoppingCondition cond; + IfAction if_action(&cond); + WhileAction<> loop(&cond); + RecordAction t1(&log, 1), e1(&log, 2), body(&log, 3), after(&log, 4); + if_action.add_then({&t1}); + if_action.add_else({&e1}); + loop.add_then({&body}); + ActionList<> if_list, while_list; + if_list.add_actions({&if_action, &after}); + while_list.add_actions({&loop, &after}); + + cond.list = &if_list; + if_list.play(); + cond.list = &while_list; + while_list.play(); + EXPECT_TRUE(log.empty()); + EXPECT_FALSE(if_list.is_running()); + EXPECT_FALSE(while_list.is_running()); +} + +TEST(ActionBranchOwner, WhileConditionStoppingOnALaterPassEndsTheLoop) { + Log log; + StopOnCheckCondition cond(2); + WhileAction<> loop(&cond); + RecordAction body(&log, 1), after(&log, 2); + loop.add_then({&body}); + ActionList<> list; + list.add_actions({&loop, &after}); + cond.list = &list; + + list.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_FALSE(list.is_running()); +} + +TEST(ActionBranchOwner, IfResumesAfterDeferredLastAction) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_TRUE(top.is_running()); + // num_running() counts the top-level chain only: the if, not the action inside its branch + EXPECT_EQ(top.num_running(), 1); + wait.finish(); + EXPECT_EQ(log, (Log{1, 2})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// A deferred action that can hold two runs at once, completed in order +class QueueDeferredAction : public Action<> { + public: + void play_complex() override { + this->num_running_++; + this->pending_++; + } + void play() override {} + void finish_one() { + if (this->pending_ > 0) { + this->pending_--; + this->play_next_(); + } + } + int running() const { return this->num_running_; } + + protected: + int pending_{0}; +}; + +TEST(ActionBranchOwner, OverlappingRunsResumeOncePerRun) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + QueueDeferredAction wait; + RecordAction after(&log, 7); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + top.play(); + EXPECT_EQ(wait.running(), 2); // the tagged last action holds both runs + wait.finish_one(); + EXPECT_EQ(log, (Log{7})); + EXPECT_TRUE(top.is_running()); + wait.finish_one(); + EXPECT_EQ(log, (Log{7, 7})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(wait.running(), 0); +} + +TEST(ActionBranchOwner, EmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction after(&log, 1); + if_action.add_then({}); + if_action.add_else({}); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 1})); + + FlagCondition always; + WhileAction<> loop(&always); + loop.add_then({}); + ActionList<> top2; + RecordAction after2(&log, 2); + top2.add_actions({&loop, &after2}); + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, WhileRechecksEachPassAndExits) { + Log log; + CountdownCondition cond(3); + WhileAction<> loop(&cond); + RecordAction body(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&body}); + ActionList<> top; + top.add_actions({&loop, &after}); + top.play(); + EXPECT_EQ(log, (Log{1, 1, 1, 2})); + EXPECT_FALSE(top.is_running()); +} + +TEST(ActionBranchOwner, StopMidBranchClearsRunning) { + Log log; + FlagCondition cond; + WhileAction<> loop(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&wait}); + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_TRUE(top.is_running()); + top.stop(); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); + wait.finish(); // already stopped: nothing pending, nothing resumes + EXPECT_EQ(log, (Log{1})); + + // A fresh run works after the stop + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2})); +} + +TEST(ActionBranchOwner, NestedIfInsideWhileResumesEachOwner) { + Log log; + CountdownCondition loop_cond(2); + FlagCondition if_cond; + WhileAction<> loop(&loop_cond); + IfAction inner(&if_cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 9); + inner.add_then({&wait}); + loop.add_then({&inner}); // the if is the last action of the loop body + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + wait.finish(); // resumes the if, then the while, which loops again + EXPECT_EQ(log, (Log{1, 1})); + wait.finish(); // second pass ends; the while condition is now false + EXPECT_EQ(log, (Log{1, 1, 9})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +} // namespace esphome::core::testing diff --git a/tests/integration/fixtures/branch_resume_async.yaml b/tests/integration/fixtures/branch_resume_async.yaml index fb093238095..76654ddaa9e 100644 --- a/tests/integration/fixtures/branch_resume_async.yaml +++ b/tests/integration/fixtures/branch_resume_async.yaml @@ -72,6 +72,26 @@ script: format: "parallel-after-if-%d" args: ['run'] + - id: stop_in_later_while_check + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: |- + if (++id(counter) == 2) + id(stop_in_later_while_check).stop(); + return true; + then: + - lambda: 'ESP_LOGI("test", "later-check-body-%d", id(counter));' + - logger.log: "after-later-check-should-not-run" + - id: empty_while + then: + - while: + condition: + lambda: 'return true;' + then: [] + - logger.log: "after-empty-while" + - id: nested_if_last_in_while then: - lambda: 'id(counter) = 0;' @@ -206,6 +226,14 @@ api: ESP_LOGI("test", "stop-running=%d", id(stop_in_branch).is_running() ? 1 : 0); - delay: 600ms - logger.log: "stop-check-done" + - action: run_stop_in_later_while_check + then: + - script.execute: stop_in_later_while_check + - delay: 200ms + - logger.log: "later-check-done" + - action: run_empty_while + then: + - script.execute: empty_while - action: run_nested_if_last_in_while then: - script.execute: nested_if_last_in_while diff --git a/tests/integration/fixtures/continuation_actions.yaml b/tests/integration/fixtures/continuation_actions.yaml index bdfe149cb7b..d48569f0f4b 100644 --- a/tests/integration/fixtures/continuation_actions.yaml +++ b/tests/integration/fixtures/continuation_actions.yaml @@ -5,7 +5,7 @@ host: api: actions: - # Test 1: IfAction with ContinuationAction (then/else branches) + # Test 1: IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -27,7 +27,7 @@ api: args: ['value'] - logger.log: "if completed" - # Test 2: Nested IfAction (multiple ContinuationAction instances) + # Test 2: Nested IfAction - action: test_nested_if variables: outer: bool @@ -51,7 +51,7 @@ api: - logger.log: "nested-outer-false" - logger.log: "nested if completed" - # Test 3: WhileAction with WhileLoopContinuation + # Test 3: WhileAction - action: test_while_action variables: max_count: int @@ -74,7 +74,7 @@ api: value: !lambda 'return id(continuation_test_counter) + 1;' - logger.log: "while completed" - # Test 4: RepeatAction with RepeatLoopContinuation + # Test 4: RepeatAction - action: test_repeat_action variables: count: int @@ -90,7 +90,7 @@ api: args: ['iteration'] - logger.log: "repeat completed" - # Test 5: Combined continuations (if + while + repeat) + # Test 5: Combined if + while + repeat - action: test_combined variables: do_loop: bool @@ -123,7 +123,7 @@ api: - logger.log: "combined-skipped" - logger.log: "combined completed" - # Test 6: Rapid triggers to verify memory efficiency + # Test 6: Rapid triggers - action: test_rapid_if then: - logger.log: "=== Rapid if test start ===" @@ -153,7 +153,7 @@ globals: restore_value: false initial_value: '0' -# Sensor to test rapid automation triggers with if/else (ContinuationAction) +# Sensor to test rapid automation triggers with if/else sensor: - platform: template id: rapid_sensor diff --git a/tests/integration/test_branch_resume_async.py b/tests/integration/test_branch_resume_async.py index 78064cbe305..f6fb5dbd457 100644 --- a/tests/integration/test_branch_resume_async.py +++ b/tests/integration/test_branch_resume_async.py @@ -73,6 +73,20 @@ async def test_branch_resume_async( assert count("stopped-branch-should-not-run") == 0 assert count("stopped-after-if-should-not-run") == 0 + # A while condition that stops the script on a later pass must not start the body again + done = wait_for("later-check-done") + await client.execute_service(service["run_stop_in_later_while_check"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("later-check-body-1") == 1 + assert count("later-check-body-2") == 0 + assert count("after-later-check-should-not-run") == 0 + + # An empty loop body cannot resume the loop, so the while finishes at once + done = wait_for("after-empty-while") + await client.execute_service(service["run_empty_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("after-empty-while") == 1 + done = wait_for("after-nested-while") await client.execute_service(service["run_nested_if_last_in_while"], {}) await asyncio.wait_for(done, timeout=2.0) diff --git a/tests/integration/test_continuation_actions.py b/tests/integration/test_continuation_actions.py index e6020c711a1..1c6e8891a96 100644 --- a/tests/integration/test_continuation_actions.py +++ b/tests/integration/test_continuation_actions.py @@ -1,4 +1,4 @@ -"""Test continuation actions (ContinuationAction, WhileLoopContinuation, RepeatLoopContinuation).""" +"""Test that if, while and repeat resume the actions after them once their branch finishes.""" from __future__ import annotations @@ -17,10 +17,7 @@ async def test_continuation_actions( api_client_connected: APIClientConnectedFactory, ) -> None: """ - Test that continuation actions work correctly for if/while/repeat. - - These continuation classes replace LambdaAction with simple parent pointers, - saving 32-36 bytes per instance and eliminating std::function overhead. + Test that if/while/repeat resume the actions after them once their branch finishes. """ loop = asyncio.get_running_loop() @@ -218,7 +215,7 @@ async def test_continuation_actions( ) assert test_results["combined_complete"], "Combined did not complete" - # Test 6: Rapid triggers (tests memory efficiency of ContinuationAction) + # Test 6: Rapid triggers through an if/else test_service = next((s for s in services if s.name == "test_rapid_if"), None) assert test_service is not None, "test_rapid_if service not found" await client.execute_service(test_service, {}) From 99a4ae1f518454b7d95da31e14b6a8d2470b2615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:35:27 -1000 Subject: [PATCH 421/435] [ota] Add old_key so an upload can retry with the previous key (#19398) --- esphome/__main__.py | 17 +++ esphome/components/esphome/ota/__init__.py | 35 +++-- esphome/espota2.py | 96 +++++++++---- tests/component_tests/ota/test_esphome_ota.py | 75 +++++++++- tests/components/ota/old_key.yaml | 12 ++ .../ota/validate-old_key.esp8266-ard.yaml | 2 + tests/unit_tests/test_espota2.py | 17 +++ tests/unit_tests/test_espota2_noise.py | 95 ++++++++++++- tests/unit_tests/test_main.py | 129 +++++++++++------- 9 files changed, 384 insertions(+), 94 deletions(-) create mode 100644 tests/components/ota/old_key.yaml create mode 100644 tests/components/ota/validate-old_key.esp8266-ard.yaml diff --git a/esphome/__main__.py b/esphome/__main__.py index 0c164893df2..2b094e67a14 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1376,6 +1376,7 @@ def _upload_via_native_api( # Fail closed: an encryption block whose key did not resolve must never # fall back to a plaintext upload noise_psk = None + old_noise_psk = None plaintext_fallback = False allow_plaintext_upload = False if ota_key := getattr(args, "ota_key", None): @@ -1396,6 +1397,8 @@ def _upload_via_native_api( # Ensure the key is a string, as required by the underlying OTA implementation. # It arrives here as a SensitiveStr which aioesphomeapi rejects. noise_psk = str(noise_psk) + if old_key := encryption_conf.get(espota2.CONF_OLD_KEY): + old_noise_psk = str(old_key) elif api_key := static_encryption_key(config.get(CONF_API) or {}): # Remove before 2027.3.0: the api key is tried, falling back to plaintext noise_psk = str(api_key) @@ -1445,9 +1448,22 @@ def _upload_via_native_api( noise_psk, plaintext_fallback=plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, + old_noise_psk=old_noise_psk, ) +def _old_key_notice(config: ConfigType) -> None: + """After `run`, whose image was just built from this config; a plain + `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): + _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) + + def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: @@ -1892,6 +1908,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code, successful_device = upload_program(config, args, devices) if exit_code == 0: _LOGGER.info("Successfully uploaded program.") + _old_key_notice(config) else: _LOGGER.warning("Failed to upload to %s", devices) return exit_code diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index dc4fc5b47ed..7a0d03df529 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -5,6 +5,7 @@ from esphome.components.noise import ( ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, + validate_encryption_key, ) from esphome.components.ota import ( BASE_OTA_SCHEMA, @@ -32,7 +33,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv from esphome.types import ConfigType @@ -99,16 +100,18 @@ def ota_esphome_final_validate(config: ConfigType) -> None: # Encryption blocks conflict only when both pin a key; a bare # `encryption:` (a package/device split) is compatible with a # keyed one, and merge_config yields the keyed result - merged_key = ( - merged_ota_esphome_configs_by_port[conf_port] - .get(CONF_ENCRYPTION, {}) - .get(CONF_KEY) + merged_enc = merged_ota_esphome_configs_by_port[conf_port].get( + CONF_ENCRYPTION, {} ) - other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) - if merged_key and other_key and merged_key != other_key: - raise cv.Invalid( - f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent" - ) + other_enc = ota_conf.get(CONF_ENCRYPTION, {}) + for option in (CONF_KEY, CONF_OLD_KEY): + merged_key = merged_enc.get(option) + other_key = other_enc.get(option) + if merged_key and other_key and merged_key != other_key: + raise cv.Invalid( + f"Found multiple configurations but {CONF_ENCRYPTION} " + f"{option} is inconsistent" + ) ports_with_merged_configs.append(conf_port) merged_ota_esphome_configs_by_port[conf_port] = merge_config( @@ -206,7 +209,8 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - """Resolve the one encryption key per device into the ota block. An explicit ota key must match the api key, a bare block inherits it, - a runtime provisioned api key cannot be inherited. + a runtime provisioned api key cannot be inherited, and an old_key equal + to the resolved key is a leftover from a finished rotation. """ api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) if ota_key := encryption_conf.get(CONF_KEY): @@ -229,12 +233,19 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - ) else: encryption_conf[CONF_KEY] = api_key + if encryption_conf.get(CONF_OLD_KEY) == encryption_conf[CONF_KEY]: + raise cv.Invalid( + f"'{CONF_OLD_KEY}' is the same as '{CONF_KEY}'; remove '{CONF_OLD_KEY}' " + "once the device runs the new key" + ) # Uploader side options live only on the ota block; the api block keeps the -# shared schema +# shared schema. The firmware is built with `key` alone, `old_key` never +# reaches the device _ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( { + cv.Optional(CONF_OLD_KEY): cv.sensitive(validate_encryption_key), cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, } ) diff --git a/esphome/espota2.py b/esphome/espota2.py index dbdf3233660..f775afe7f66 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -215,14 +215,14 @@ class OTANetworkError(OTAError): """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" -# Remove before 2027.3.0 -class OTAEncryptionFallback(OTAError): - """The encrypted attempt failed and the caller may retry in plaintext.""" +class OTAKeyRejected(OTAError): + """The Noise handshake failed on the key; another key may succeed.""" -# Uploader side option under `ota: encryption:`; the ota component imports the -# name so the upload path never loads the component module +# 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" 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, " @@ -249,20 +249,43 @@ PLAINTEXT_FALLBACK_NOTICE = ( ) -# Remove before 2027.3.0 -class _EncryptionAttempt: - """The key an upload tries and whether it may fall back to plaintext; - a rejected handshake falls back at once, a transport fault only on repeat.""" +OLD_KEY_REMOVE_NOTICE = ( + "Once the device has booted this firmware it runs the current key; remove " + f"'{CONF_OLD_KEY}' from 'ota: encryption:'" +) - def __init__(self, noise_psk: str | None, plaintext_fallback: bool) -> None: + +class _EncryptionAttempt: + """The key an upload tries, the previous key to retry with once, and + (until 2027.3.0) whether it may fall back to plaintext.""" + + def __init__( + self, noise_psk: str | None, plaintext_fallback: bool, old_noise_psk: str | None + ) -> None: self.noise_psk = noise_psk + self.old_noise_psk = old_noise_psk + self.tried_old_key = False self.plaintext_fallback = plaintext_fallback self.handshake_faults = 0 + def retry_with_old_key(self) -> bool: + """Swap in the previous key once; the device may not run the new one yet.""" + if not self.old_noise_psk: + return False + _LOGGER.warning( + "Device rejected the OTA encryption key; retrying with '%s'", CONF_OLD_KEY + ) + self.noise_psk = self.old_noise_psk + self.old_noise_psk = None + self.tried_old_key = True + return True + + # Remove before 2027.3.0 def handshake_fault_falls_back(self) -> bool: self.handshake_faults += 1 return self.plaintext_fallback and self.handshake_faults >= 2 + # Remove before 2027.3.0 def downgrade(self, reason: str) -> None: _LOGGER.warning( "%s. Retrying in plaintext; a device that requires encryption " @@ -459,18 +482,20 @@ class NoiseSocketWrapper: if payload[0] != NOISE_HANDSHAKE_OK: reason = payload[1:].decode("utf-8", "replace") if reason == NOISE_MAC_FAILURE_REASON: - raise OTAError( + raise OTAKeyRejected( "Device rejected the handshake; is the OTA encryption key correct?" ) raise OTAError(f"Device rejected the noise handshake: {reason}") try: self._handshake.read_message(payload[1:]) - except (ValueError, self._invalid_tag) as err: - # InvalidTag is a wrong key; ValueError covers a device sending an - # invalid curve point, which cryptography rejects during the DH - raise OTAError( + except self._invalid_tag as err: + raise OTAKeyRejected( "Noise handshake failed; is the OTA encryption key correct?" ) from err + except ValueError as err: + # An invalid curve point, rejected by cryptography during the DH; + # the device is broken, not the key + raise OTAError(f"Noise handshake failed: {err}") from err self._encrypt, self._decrypt = self._handshake.get_ciphers() def sendall(self, data: bytes) -> None: @@ -652,11 +677,6 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - except OTAError as err: - # Remove before 2027.3.0 - if plaintext_fallback: - raise OTAEncryptionFallback(str(err)) from err - raise _LOGGER.info("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) @@ -874,6 +894,7 @@ def run_ota_impl_( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -912,11 +933,12 @@ def run_ota_impl_( total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" reached_device = False + reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 - encryption = _EncryptionAttempt(noise_psk, plaintext_fallback) + encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] - if reached_device or attempt >= len(res): + if not reconnect and (reached_device or attempt >= len(res)): _LOGGER.info( "Retrying in %.0f seconds (attempt %d of %d)...", UPLOAD_RETRY_DELAY, @@ -925,6 +947,7 @@ def run_ota_impl_( ) time.sleep(UPLOAD_RETRY_DELAY) reached_device = False + reconnect = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -951,11 +974,22 @@ def run_ota_impl_( encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, ) - except OTAEncryptionFallback as err: - # Same address and attempt budget: not a network retry + except OTAKeyRejected as err: + # The device sent a reject and closed on its own, so reconnect + # at once, on the same address and attempt budget last_error = str(err) - encryption.downgrade(last_error) - continue + reconnect = True + if encryption.retry_with_old_key(): + continue + # Remove before 2027.3.0 + if encryption.plaintext_fallback: + encryption.downgrade(last_error) + continue + if encryption.tried_old_key: + raise OTAKeyRejected( + f"The device rejected both 'key' and '{CONF_OLD_KEY}'" + ) from err + raise except OTAHandshakeNetworkError as err: last_error = str(err) if encryption.handshake_fault_falls_back(): @@ -976,7 +1010,13 @@ def run_ota_impl_( _LOGGER.error(str(err)) return 1, None - # Successfully uploaded to sa[0] + if encryption.tried_old_key: + # Kept next to the result: a device that should already run the + # current key but answered to the previous one is worth a look + _LOGGER.warning( + "The device accepted '%s'; it was still running the previous key", + CONF_OLD_KEY, + ) return 0, sa[0] _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) @@ -992,6 +1032,7 @@ def run_ota( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -1003,6 +1044,7 @@ def run_ota( noise_psk, plaintext_fallback, allow_plaintext_upload, + old_noise_psk, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 77db1499231..39920ff4363 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -30,7 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv @@ -215,6 +215,77 @@ def test_encryption_schema_allow_plaintext_upload() -> None: encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) +def test_encryption_old_key_schema() -> None: + """old_key is validated like key and is an ota only option.""" + conf = _encryption_schema({CONF_KEY: API_KEY, CONF_OLD_KEY: OTHER_KEY}) + assert conf[CONF_OLD_KEY] == OTHER_KEY + with pytest.raises(cv.Invalid, match="base64"): + _encryption_schema({CONF_OLD_KEY: "short"}) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_OLD_KEY: OTHER_KEY}) + + +def test_encryption_old_key_keeps_inherited_key() -> None: + """A bare block with old_key inherits the api key as the current one.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}) + ], + } + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_OLD_KEY] == OTHER_KEY + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_mismatch_between_merged_configs_rejected() -> None: + """Same-port blocks that pin different old keys raise like differing keys.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}), + _make_ota_config( + port=3232, + **{ + CONF_ENCRYPTION: { + CONF_OLD_KEY: "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=" + } + }, + ), + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="old_key is inconsistent"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_equal_to_key_rejected() -> None: + """An old_key that matches key is a leftover from a finished rotation.""" + full_conf = { + CONF_API: {}, + CONF_OTA: [ + _make_ota_config( + port=3232, + **{CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_OLD_KEY: API_KEY}}, + ) + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="'old_key' is the same as 'key'"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -225,7 +296,7 @@ def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: } token = fv.full_config.set(full_conf) try: - with pytest.raises(cv.Invalid, match="encryption is inconsistent"): + with pytest.raises(cv.Invalid, match="encryption key is inconsistent"): ota_esphome_final_validate({}) finally: fv.full_config.reset(token) diff --git a/tests/components/ota/old_key.yaml b/tests/components/ota/old_key.yaml new file mode 100644 index 00000000000..8074ca06f6f --- /dev/null +++ b/tests/components/ota/old_key.yaml @@ -0,0 +1,12 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + encryption: + old_key: "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" diff --git a/tests/components/ota/validate-old_key.esp8266-ard.yaml b/tests/components/ota/validate-old_key.esp8266-ard.yaml new file mode 100644 index 00000000000..f58187f4556 --- /dev/null +++ b/tests/components/ota/validate-old_key.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include old_key.yaml diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f4c5935059c..6fa6c616b67 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -799,6 +799,23 @@ def test_run_ota_impl_connection_failed( mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_old_key_reconnect_never_waits( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """The reconnect with old_key skips the retry delay even once the + address list has been cycled by an earlier connect failure.""" + mock_socket.connect.side_effect = [OSError("timed out"), None, None] + mock_perform_ota.side_effect = [espota2.OTAKeyRejected("rejected"), None] + + result_code, _ = espota2.run_ota_impl_( + "test.local", 3232, None, str(firmware_file), noise_psk="a", old_noise_psk="b" + ) + + assert result_code == 0 + assert mock_sleep.call_count == 1 # only the connect failure waited + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_connect_retry_succeeds( mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 60f6b28e076..ca50641309b 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -70,10 +70,12 @@ class FakeEncryptedDevice(threading.Thread): prologue_features_override: int | None = None, connections: int = 1, drop_handshakes: int = 0, + reject_reason: str | None = None, ) -> None: super().__init__(daemon=True) self.connections = connections self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times + self.reject_reason = reject_reason # refuse every handshake with this reason self.psk = psk self.version = version self.offer_noise = offer_noise @@ -160,6 +162,9 @@ class FakeEncryptedDevice(threading.Thread): if self.drop_handshakes > 0: self.drop_handshakes -= 1 return # a transport fault: the socket closes with no reply + if self.reject_reason is not None: + _send_frame(sock, b"\x01" + self.reject_reason.encode()) + return try: proto.read_message(msg1[1:]) except InvalidTag: @@ -244,8 +249,9 @@ def _run_ota( tmp_path: Path, noise_psk: str, plaintext_fallback: bool = True, + old_noise_psk: str | None = None, ) -> int: - """Drive the retry loop, which is where the plaintext fallback reconnects.""" + """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" path.write_bytes(firmware) device.start() @@ -256,10 +262,62 @@ def _run_ota( path, noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + old_noise_psk=old_noise_psk, ) return rc +THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() + + +@pytest.mark.parametrize( + ("device_psk", "old_noise_psk", "expected_rc", "retried"), + [ + # The device still runs the previous key: one reconnect with old_key + (OTHER_PSK, OTHER_PSK, 0, True), + # The device already runs the new key: old_key is never presented + (PSK, OTHER_PSK, 0, False), + # Neither key matches: the retry is spent, then it fails closed + (THIRD_PSK, OTHER_PSK, 1, True), + # No old_key configured: a rejected key is a device error + (OTHER_PSK, None, 1, False), + ], + ids=["old_key_accepted", "key_accepted", "both_rejected", "no_old_key"], +) +def test_old_key_retry( + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + device_psk: str, + old_noise_psk: str | None, + expected_rc: int, + retried: bool, +) -> None: + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice(psk=device_psk, connections=2 if retried else 1) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + firmware, + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=old_noise_psk, + ) + device.join_and_check() + assert rc == expected_rc + assert (device.received == firmware) is (expected_rc == 0) + assert ( + any("retrying with 'old_key'" in r.message for r in caplog.records) is retried + ) + assert any("accepted 'old_key'" in r.message for r in caplog.records) is ( + retried and expected_rc == 0 + ) + assert not any("plaintext" in r.message for r in caplog.records) + if expected_rc == 1 and old_noise_psk is not None: + assert any("rejected both" in r.message for r in caplog.records) + + def test_encrypted_upload_success() -> None: """A full encrypted v2 upload spanning several 8192-byte blocks.""" pytest.importorskip("aioesphomeapi.noise") @@ -495,21 +553,21 @@ def test_handshake_reject_with_other_reason() -> None: def test_handshake_garbage_second_message() -> None: - """A valid-looking point with a garbage MAC fails cleanly.""" + """A valid-looking point with a garbage MAC is a key failure.""" wrapper = _wrapper(_frame(b"\x00" + bytes(range(48)))) with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" + espota2.OTAKeyRejected, match="handshake failed; is the OTA encryption key" ): wrapper.do_handshake() def test_handshake_invalid_curve_point() -> None: - """An all-zero x25519 point is rejected as a clean error, not a crash.""" + """An all-zero x25519 point is a clean error, not a crash, and not a + key failure: it must not spend the old_key retry.""" wrapper = _wrapper(_frame(b"\x00" + bytes(48))) - with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" - ): + with pytest.raises(espota2.OTAError, match="handshake failed: ") as info: wrapper.do_handshake() + assert not isinstance(info.value, espota2.OTAKeyRejected) def test_recv_closed_at_frame_boundary_returns_empty() -> None: @@ -585,6 +643,29 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: wrapper._decrypt.decrypt.assert_called_once() +@pytest.mark.parametrize("plaintext_fallback", [False, True]) +def test_non_key_reject_reason_is_a_device_error( + caplog: pytest.LogCaptureFixture, tmp_path: Path, plaintext_fallback: bool +) -> None: + """Only a key failure spends the old_key retry or, until 2027.3.0, the + plaintext fallback; another reject reason fails at once.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(reject_reason="Busy") + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=plaintext_fallback, + old_noise_psk=OTHER_PSK, + ) + device.join_and_check() + assert rc == 1 + assert not any("retrying with 'old_key'" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3b10c9cb3c2..801eda4496a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1558,6 +1558,14 @@ class MockArgs: env_ota_key: str | None = None +# The keyword half of every run_ota call; a test overrides the one it is about +RUN_OTA_DEFAULT_KWARGS = { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + "old_noise_psk": None, +} + + def test_upload_program_serial_esp32( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -2120,32 +2128,39 @@ def test_upload_program_ota_success( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) +@pytest.mark.parametrize( + "old_key", + [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="], + ids=["key", "old_key"], +) def test_upload_program_ota_encryption_key( mock_run_ota: Mock, mock_get_port_type: Mock, tmp_path: Path, + old_key: str | None, + caplog: pytest.LogCaptureFixture, ) -> None: - """The resolved encryption key is passed through to run_ota.""" + """The resolved encryption key, and the previous one when set, are passed + through to run_ota.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) mock_get_port_type.return_value = "NETWORK" mock_run_ota.return_value = (0, "192.168.1.100") key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + encryption = {CONF_KEY: key} + if old_key is not None: + encryption["old_key"] = old_key config = { CONF_OTA: [ - { - CONF_PLATFORM: CONF_ESPHOME, - CONF_PORT: 3232, - CONF_ENCRYPTION: {CONF_KEY: key}, - } + {CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: encryption} ] } - exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) + with caplog.at_level(logging.WARNING): + exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) assert exit_code == 0 assert host == "192.168.1.100" @@ -2159,9 +2174,10 @@ def test_upload_program_ota_encryption_key( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"old_noise_psk": old_key}, ) + # The notice belongs to run, whose image was just built from this config + assert not any("remove 'old_key'" in r.message for r in caplog.records) def test_upload_program_bare_encryption_block_never_falls_back( @@ -2191,10 +2207,7 @@ def test_upload_program_bare_encryption_block_never_falls_back( with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: upload_program(config, MockArgs(), ["192.168.1.100"]) assert run_ota.call_args.args[5] == key - assert run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_allow_plaintext_upload( @@ -2223,9 +2236,8 @@ def test_upload_program_ota_allow_plaintext_upload( assert exit_code == 0 assert mock_run_ota.call_args.args[2] == "pw" assert mock_run_ota.call_args.args[5] == key - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": True, + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS | { + "allow_plaintext_upload": True } @@ -2235,6 +2247,7 @@ def test_upload_program_ota_allow_plaintext_upload( { CONF_ENCRYPTION: { CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "old_key": "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=", "allow_plaintext_upload": True, } }, @@ -2265,10 +2278,7 @@ def test_upload_program_ota_prompted_key_is_presented( assert exit_code == 0 assert mock_run_ota.call_args.args[5] == old_key - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_serial_ignores_prompted_key( @@ -2320,6 +2330,46 @@ def test_command_run_prompts_for_ota_key_before_compile() -> None: assert calls == ["prompt", "compile"] +@pytest.mark.parametrize( + "old_key", [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="] +) +def test_command_run_says_when_old_key_can_go( + caplog: pytest.LogCaptureFixture, old_key: str | None +) -> None: + """Only run has just built the image from this config, so only run can + say the device will run `key` alone once it boots.""" + encryption = {CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + if old_key is not None: + encryption["old_key"] = old_key + setup_core( + config={ + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_ENCRYPTION: encryption, + } + ] + }, + platform=PLATFORM_ESP32, + ) + args = MockArgs() + args.device = ["dev.local"] + args.no_logs = True + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]), + patch("esphome.__main__.get_serial_ports", return_value=[]), + patch("esphome.__main__.upload_program", return_value=(0, "dev.local")), + caplog.at_level(logging.WARNING), + ): + assert command_run(args, CORE.config) == 0 + assert any("remove 'old_key'" in r.message for r in caplog.records) is ( + old_key is not None + ) + + def test_upload_program_platform_hook_ignores_prompted_key( mock_get_port_type: Mock, tmp_path: Path, @@ -2636,8 +2686,7 @@ def test_upload_program_ota_api_key_opportunistic( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=True, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"plaintext_fallback": True}, ) @@ -2666,10 +2715,7 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2728,8 +2774,7 @@ def test_upload_program_ota_with_file_arg( Path("custom.bin"), OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2785,8 +2830,7 @@ def test_upload_program_ota_partition_table_with_file_arg( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2849,8 +2893,7 @@ def test_upload_program_ota_partition_table_mqttip( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3039,8 +3082,7 @@ def test_upload_program_ota_bootloader_with_file_arg( bootloader_file, OTA_TYPE_UPDATE_BOOTLOADER, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3551,8 +3593,7 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3608,8 +3649,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -5161,8 +5201,7 @@ def test_upload_program_ota_static_ip_with_mqttip( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -5213,8 +5252,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -5398,8 +5436,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) From 318e4b79c110917303e49aade9d6fa13601ace63 Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Mon, 12 Oct 2026 17:38:34 -0600 Subject: [PATCH 422/435] [apc1] Add support for the ScioSense APC1 air quality sensor (UART) (#19274) Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/apc1/__init__.py | 3 + esphome/components/apc1/apc1.cpp | 352 ++++++++++++++++++++ esphome/components/apc1/apc1.h | 99 ++++++ esphome/components/apc1/sensor.py | 222 ++++++++++++ tests/components/apc1/common.yaml | 62 ++++ tests/components/apc1/test.esp32-idf.yaml | 7 + tests/components/apc1/test.esp8266-ard.yaml | 7 + tests/components/apc1/test.rp2040-ard.yaml | 7 + 9 files changed, 760 insertions(+) create mode 100644 esphome/components/apc1/__init__.py create mode 100644 esphome/components/apc1/apc1.cpp create mode 100644 esphome/components/apc1/apc1.h create mode 100644 esphome/components/apc1/sensor.py create mode 100644 tests/components/apc1/common.yaml create mode 100644 tests/components/apc1/test.esp32-idf.yaml create mode 100644 tests/components/apc1/test.esp8266-ard.yaml create mode 100644 tests/components/apc1/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f7875eb1407..fab41f5b1fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -39,6 +39,7 @@ esphome/components/am43/sensor/* @buxtronix esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix +esphome/components/apc1/* @ademuri esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core diff --git a/esphome/components/apc1/__init__.py b/esphome/components/apc1/__init__.py new file mode 100644 index 00000000000..355ecc23058 --- /dev/null +++ b/esphome/components/apc1/__init__.py @@ -0,0 +1,3 @@ +"""Support for ScioSense APC1 air quality sensor.""" + +DOMAIN = "apc1" diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp new file mode 100644 index 00000000000..beca9d85815 --- /dev/null +++ b/esphome/components/apc1/apc1.cpp @@ -0,0 +1,352 @@ +#include "apc1.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::apc1 { + +ESPHOME_LOG_TAG(TAG, "apc1"); + +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58; +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61; + +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19; + +// Give a freshly powered sensor time to boot before the first retry +static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000; +// Re-send the init commands at most this often while no frames arrive +static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000; +// Active mode streams a frame every second; this long without one means the sensor stopped +static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000; +// Drop a partial frame when the bytes stop mid-way +static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200; + +// Frames and commands end with the 16-bit sum of every byte before it +static uint16_t apc1_checksum(const uint8_t *data, size_t len) { + uint16_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum += data[i]; + } + return sum; +} + +void APC1Component::setup() { + if (this->set_pin_ != nullptr) { + this->set_pin_->setup(); + this->set_pin_->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + // Attempt initial communication immediately for warm restarts + this->initialize_device_(); + + // Schedule delayed initialization to catch sensors during cold power-on bootup + this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); }); +} + +void APC1Component::initialize_device_() { + this->last_init_attempt_ = App.get_loop_component_start_time(); + if (this->active_mode_ && !this->idle_) { + this->set_active_mode(); + } + this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000); +} + +void APC1Component::dump_config() { + ESP_LOGCONFIG(TAG, "APC1:"); + LOG_PIN(" Set Pin: ", this->set_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_); + LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); + LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); + LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_); + LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_); + LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_); + LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_); + LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_); + LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_); + LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_); + LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_); + LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_); + LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); + LOG_SENSOR(" ", "eCO2", this->eco2_sensor_); + LOG_SENSOR(" ", "AQI", this->aqi_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_); + LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_); + LOG_SENSOR(" ", "RS0", this->rs0_sensor_); + LOG_SENSOR(" ", "RS2", this->rs2_sensor_); + LOG_SENSOR(" ", "RS3", this->rs3_sensor_); + LOG_SENSOR(" ", "Error Code", this->error_code_sensor_); +} + +void APC1Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + // If in active mode and not idle, retry periodically if no valid data received + if (this->active_mode_ && !this->idle_) { + if (this->last_valid_frame_ == 0) { + if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands..."); + this->initialize_device_(); + } + } else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS && + now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_); + this->initialize_device_(); + } + } + + if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) { + ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer"); + this->rx_index_ = 0; + } + + while (this->available()) { + this->last_transmission_ = now; + uint8_t byte; + this->read_byte(&byte); + + if (this->rx_index_ == 0) { + if (byte == APC1_HEADER_1) { + this->rx_buffer_[this->rx_index_++] = byte; + } + continue; + } + + if (this->rx_index_ == 1) { + if (byte == APC1_HEADER_2) { + this->rx_buffer_[this->rx_index_++] = byte; + } else if (byte != APC1_HEADER_1) { + this->rx_index_ = 0; + } + continue; + } + + this->rx_buffer_[this->rx_index_++] = byte; + if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) { + continue; + } + + // The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a + // corrupt length from wrapping the total and walking past the buffer + const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]); + if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) { + ESP_LOGW(TAG, "Invalid frame length: %u", payload_len); + this->rx_index_ = 0; + continue; + } + const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE; + if (this->rx_index_ == total_len) { + this->parse_frame_(total_len); + this->rx_index_ = 0; + } + } +} + +void APC1Component::parse_frame_(uint16_t total_len) { + uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]); + uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2); + if (expected_checksum != calculated_checksum) { + ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum, + expected_checksum); + return; + } + + if (total_len == APC1_FRAME_SIZE_MEASUREMENT) { + this->parse_measurement_frame_(); + } else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) { + this->parse_device_info_frame_(); + } else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) { + ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]); + } else { + ESP_LOGD(TAG, "Received frame of unknown length %u", total_len); + } +} + +// One message per error bit, bit 0 first +PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault", + "Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault"); + +void APC1Component::parse_measurement_frame_() { + const uint32_t now = App.get_loop_component_start_time(); + this->last_valid_frame_ = now; + + uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE]; + if (error_code != this->last_error_code_) { + if (error_code != 0) { + for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) { + if (error_code & (1 << bit)) { + ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX))); + } + } + this->status_set_warning(LOG_STR("Sensor reported hardware error")); + } else { + ESP_LOGI(TAG, "APC1: Hardware error cleared"); + this->status_clear_warning(); + } + this->last_error_code_ = error_code; + if (this->error_code_sensor_ != nullptr) { + this->error_code_sensor_->publish_state(error_code); + } + } + + uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI]; + if (aqi == 0) { + if (!this->gas_warming_up_) { + this->gas_warming_up_ = true; + ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)"); + } + } else if (this->gas_warming_up_) { + this->gas_warming_up_ = false; + ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi); + } + + // Measurement payload data (bytes 4-57): big-endian sensor readings. + // The values below feed the debug summary, so they are always decoded; every + // other reading is only decoded when its sensor is configured. + const auto u16 = [this](uint8_t offset) { + return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]); + }; + const auto u32 = [this](uint8_t offset) { + return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2], + this->rx_buffer_[offset + 3]); + }; + uint16_t pm_1_0 = u16(10); + uint16_t pm_2_5 = u16(12); + uint16_t pm_10_0 = u16(14); + uint16_t tvoc = u16(28); + uint16_t eco2 = u16(30); + float temp_comp = static_cast(u16(34)) / 10.0f; + float hum_comp = u16(36) / 10.0f; + + ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0, + pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp); + + if (this->pm_1_0_std_sensor_ != nullptr) + this->pm_1_0_std_sensor_->publish_state(u16(4)); + if (this->pm_2_5_std_sensor_ != nullptr) + this->pm_2_5_std_sensor_->publish_state(u16(6)); + if (this->pm_10_0_std_sensor_ != nullptr) + this->pm_10_0_std_sensor_->publish_state(u16(8)); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0); + + if (this->pm_0_3um_sensor_ != nullptr) + this->pm_0_3um_sensor_->publish_state(u16(16)); + if (this->pm_0_5um_sensor_ != nullptr) + this->pm_0_5um_sensor_->publish_state(u16(18)); + if (this->pm_1_0um_sensor_ != nullptr) + this->pm_1_0um_sensor_->publish_state(u16(20)); + if (this->pm_2_5um_sensor_ != nullptr) + this->pm_2_5um_sensor_->publish_state(u16(22)); + if (this->pm_5_0um_sensor_ != nullptr) + this->pm_5_0um_sensor_->publish_state(u16(24)); + if (this->pm_10_0um_sensor_ != nullptr) + this->pm_10_0um_sensor_->publish_state(u16(26)); + + if (this->tvoc_sensor_ != nullptr) + this->tvoc_sensor_->publish_state(tvoc); + if (this->eco2_sensor_ != nullptr) + this->eco2_sensor_->publish_state(eco2); + if (this->aqi_sensor_ != nullptr) + this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN); + + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temp_comp); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(hum_comp); + + if (this->raw_temperature_sensor_ != nullptr) { + this->raw_temperature_sensor_->publish_state(static_cast(u16(38)) / 10.0f); + } + if (this->raw_humidity_sensor_ != nullptr) + this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f); + + if (this->rs0_sensor_ != nullptr) { + this->rs0_sensor_->publish_state(u32(42)); + } + if (this->rs2_sensor_ != nullptr) { + this->rs2_sensor_->publish_state(u32(50)); + } + if (this->rs3_sensor_ != nullptr) { + this->rs3_sensor_->publish_state(u32(54)); + } +} + +void APC1Component::parse_device_info_frame_() { + const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL; + uint16_t fw_version = + encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]); + ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)", + reinterpret_cast(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE), + encode_uint32(serial[0], serial[1], serial[2], serial[3]), + encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version); +} + +void APC1Component::arm_watchdog_() { + this->last_valid_frame_ = App.get_loop_component_start_time(); + this->last_init_attempt_ = this->last_valid_frame_; +} + +void APC1Component::set_active_mode() { + if (!this->active_mode_) { + this->arm_watchdog_(); + } + this->active_mode_ = true; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE); +} + +void APC1Component::set_passive_mode() { + this->active_mode_ = false; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE); +} + +void APC1Component::request_measurement() { + this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000); +} + +void APC1Component::set_idle_mode() { + this->idle_ = true; + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE); +} + +void APC1Component::set_measurement_mode() { + this->idle_ = false; + this->arm_watchdog_(); + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD); +} + +void APC1Component::send_command_(APC1Command cmd, uint16_t data) { + std::array buf{ + APC1_HEADER_1, + APC1_HEADER_2, + static_cast(cmd), + static_cast((data >> 8) & 0xFF), + static_cast(data & 0xFF), + 0, + 0, + }; + uint16_t checksum = apc1_checksum(buf.data(), 5); + buf[5] = static_cast((checksum >> 8) & 0xFF); + buf[6] = static_cast(checksum & 0xFF); + this->write_array(buf); +} + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/apc1.h b/esphome/components/apc1/apc1.h new file mode 100644 index 00000000000..850738ead6a --- /dev/null +++ b/esphome/components/apc1/apc1.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::apc1 { + +static constexpr uint8_t APC1_HEADER_1 = 0x42; +static constexpr uint8_t APC1_HEADER_2 = 0x4D; +static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes +static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64; +static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23; +static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8; +static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7; + +enum class APC1Command : uint8_t { + APC1_COMMAND_MEASUREMENT_MODE = 0xE1, + APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2, + APC1_COMMAND_OPERATION_MODE = 0xE4, + APC1_COMMAND_READ_SENSOR_VERSION = 0xE9, +}; + +static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000; +static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001; + +static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000; +static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001; + +class APC1Component final : public uart::UARTDevice, public Component { + SUB_SENSOR(pm_1_0) + SUB_SENSOR(pm_2_5) + SUB_SENSOR(pm_10_0) + SUB_SENSOR(pm_1_0_std) + SUB_SENSOR(pm_2_5_std) + SUB_SENSOR(pm_10_0_std) + + SUB_SENSOR(pm_0_3um) + SUB_SENSOR(pm_0_5um) + SUB_SENSOR(pm_1_0um) + SUB_SENSOR(pm_2_5um) + SUB_SENSOR(pm_5_0um) + SUB_SENSOR(pm_10_0um) + + SUB_SENSOR(tvoc) + SUB_SENSOR(eco2) + SUB_SENSOR(aqi) + + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(raw_temperature) + SUB_SENSOR(raw_humidity) + + SUB_SENSOR(rs0) + SUB_SENSOR(rs2) + SUB_SENSOR(rs3) + + SUB_SENSOR(error_code) + + public: + void setup() override; + void dump_config() override; + void loop() override; + + void set_active_mode(); + void set_passive_mode(); + void request_measurement(); + void set_idle_mode(); + void set_measurement_mode(); + + void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + + protected: + void send_command_(APC1Command cmd, uint16_t data); + void initialize_device_(); + /// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot + void arm_watchdog_(); + void parse_frame_(uint16_t total_len); + void parse_measurement_frame_(); + void parse_device_info_frame_(); + + GPIOPin *set_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint32_t last_transmission_{0}; + uint32_t last_valid_frame_{0}; + uint32_t last_init_attempt_{0}; + std::array rx_buffer_{}; + uint8_t rx_index_{0}; + uint8_t last_error_code_{0xFF}; + bool active_mode_{true}; + bool idle_{false}; + bool gas_warming_up_{false}; +}; + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py new file mode 100644 index 00000000000..b64a49903f2 --- /dev/null +++ b/esphome/components/apc1/sensor.py @@ -0,0 +1,222 @@ +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import sensor, uart +from esphome.components.const import CONF_AQI +import esphome.config_validation as cv +from esphome.const import ( + CONF_ECO2, + CONF_HUMIDITY, + CONF_ID, + CONF_PM_0_3UM, + CONF_PM_0_5UM, + CONF_PM_1_0, + CONF_PM_1_0_STD, + CONF_PM_1_0UM, + CONF_PM_2_5, + CONF_PM_2_5_STD, + CONF_PM_2_5UM, + CONF_PM_5_0UM, + CONF_PM_10_0, + CONF_PM_10_0_STD, + CONF_PM_10_0UM, + CONF_RESET_PIN, + CONF_TEMPERATURE, + CONF_TVOC, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_CHEMICAL_WEAPON, + ICON_MOLECULE_CO2, + ICON_OMEGA, + ICON_RADIATOR, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_COUNT_DECILITRE, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_OHM, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@ademuri"] +DEPENDENCIES = ["uart"] + +CONF_SET_PIN = "set_pin" +CONF_RAW_TEMPERATURE = "raw_temperature" +CONF_RAW_HUMIDITY = "raw_humidity" +CONF_RS0 = "rs0" +CONF_RS2 = "rs2" +CONF_RS3 = "rs3" +CONF_ERROR_CODE = "error_code" + +apc1_ns = cg.esphome_ns.namespace("apc1") +APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component) + + +def _pm_mass_schema(device_class: str) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=device_class, + state_class=STATE_CLASS_MEASUREMENT, + ) + + +_PM_COUNT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNT_DECILITRE, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) +_TEMPERATURE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) +_HUMIDITY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, +) +_RESISTANCE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_OHM, + icon=ICON_OMEGA, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APC1Component), + cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_TVOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ECO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_AQI): sensor.sensor_schema( + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA, + cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema( + icon="mdi:alert-circle-outline", + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> None: + schema = uart.final_validate_device_schema( + "apc1", baud_rate=9600, require_rx=True, require_tx=True + ) + schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor) + await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor) + await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor) + await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor) + await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor) + await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor) + await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor) + await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor) + await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_ECO2, var.set_eco2_sensor) + await sensors(CONF_AQI, var.set_aqi_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor) + await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor) + await sensors(CONF_RS0, var.set_rs0_sensor) + await sensors(CONF_RS2, var.set_rs2_sensor) + await sensors(CONF_RS3, var.set_rs3_sensor) + await sensors(CONF_ERROR_CODE, var.set_error_code_sensor) + + if (set_pin_config := config.get(CONF_SET_PIN)) is not None: + set_pin = await cg.gpio_pin_expression(set_pin_config) + cg.add(var.set_set_pin(set_pin)) + + if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None: + reset_pin = await cg.gpio_pin_expression(reset_pin_config) + cg.add(var.set_reset_pin(reset_pin)) + + +APC1_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(): cv.use_id(APC1Component), + } +) + + +for _name, _call in ( + ("apc1.set_active_mode", "set_active_mode()"), + ("apc1.set_passive_mode", "set_passive_mode()"), + ("apc1.request_measurement", "request_measurement()"), + ("apc1.set_idle_mode", "set_idle_mode()"), + ("apc1.set_measurement_mode", "set_measurement_mode()"), +): + automation.register_apply_action( + _name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/apc1/common.yaml b/tests/components/apc1/common.yaml new file mode 100644 index 00000000000..496f587e42a --- /dev/null +++ b/tests/components/apc1/common.yaml @@ -0,0 +1,62 @@ +sensor: + - platform: apc1 + id: apc1_sensor + set_pin: ${set_pin} + reset_pin: ${reset_pin} + pm_1_0: + name: "APC1 PM1.0" + pm_2_5: + name: "APC1 PM2.5" + filters: + - throttle_average: 10s + pm_10_0: + name: "APC1 PM10" + pm_1_0_std: + name: "APC1 PM1.0 Std" + pm_2_5_std: + name: "APC1 PM2.5 Std" + pm_10_0_std: + name: "APC1 PM10 Std" + pm_0_3um: + name: "APC1 Particles >0.3um" + pm_0_5um: + name: "APC1 Particles >0.5um" + pm_1_0um: + name: "APC1 Particles >1.0um" + pm_2_5um: + name: "APC1 Particles >2.5um" + pm_5_0um: + name: "APC1 Particles >5.0um" + pm_10_0um: + name: "APC1 Particles >10um" + tvoc: + name: "APC1 TVOC" + eco2: + name: "APC1 eCO2" + aqi: + name: "APC1 AQI" + temperature: + name: "APC1 Temperature" + humidity: + name: "APC1 Humidity" + raw_temperature: + name: "APC1 Raw Temperature" + raw_humidity: + name: "APC1 Raw Humidity" + rs0: + name: "APC1 RS0" + rs2: + name: "APC1 MOX RS2" + rs3: + name: "APC1 MOX RS3" + error_code: + name: "APC1 Error Code" + +interval: + - interval: 60s + then: + - apc1.set_active_mode: apc1_sensor + - apc1.set_passive_mode: apc1_sensor + - apc1.request_measurement: apc1_sensor + - apc1.set_idle_mode: apc1_sensor + - apc1.set_measurement_mode: apc1_sensor diff --git a/tests/components/apc1/test.esp32-idf.yaml b/tests/components/apc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..a8fdc5956dc --- /dev/null +++ b/tests/components/apc1/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO32 + reset_pin: GPIO14 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.esp8266-ard.yaml b/tests/components/apc1/test.esp8266-ard.yaml new file mode 100644 index 00000000000..746829bea79 --- /dev/null +++ b/tests/components/apc1/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO4 + reset_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.rp2040-ard.yaml b/tests/components/apc1/test.rp2040-ard.yaml new file mode 100644 index 00000000000..e1dc80743f5 --- /dev/null +++ b/tests/components/apc1/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO2 + reset_pin: GPIO3 + +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + apc1: !include common.yaml From 331d12af2f228ea6be18736c7f9f9604458e52e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:39:10 -1000 Subject: [PATCH 423/435] [binary_sensor] Share multi click timing tables in flash and skip the default cooldown setter (#20071) --- esphome/components/binary_sensor/__init__.py | 23 ++++++---- .../components/binary_sensor/automation.cpp | 27 ++++++------ esphome/components/binary_sensor/automation.h | 42 +++++++------------ .../binary_sensor/test_multi_click.py | 29 +++++++++++++ .../binary_sensor/test_multi_click.yaml | 33 +++++++++++++++ 5 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 tests/component_tests/binary_sensor/test_multi_click.py create mode 100644 tests/component_tests/binary_sensor/test_multi_click.yaml diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 109a22c7bca..1384f8b0086 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -74,6 +74,10 @@ from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] + +DEFAULT_INVALID_COOLDOWN_MS = ( + 1000 # Keep in sync with invalid_cooldown_ in automation.h +) DEVICE_CLASSES = [ DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY_CHARGING, @@ -131,10 +135,9 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() ) -MultiClickTriggerBase = binary_sensor_ns.class_( - "MultiClickTriggerBase", automation.Trigger.template(), cg.Component +MultiClickTrigger = binary_sensor_ns.class_( + "MultiClickTrigger", automation.Trigger.template(), cg.Component ) -MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") @@ -492,7 +495,8 @@ _BINARY_SENSOR_SCHEMA = ( cv.Length(min=1, max=255), ), cv.Optional( - CONF_INVALID_COOLDOWN, default="1s" + CONF_INVALID_COOLDOWN, + default=f"{DEFAULT_INVALID_COOLDOWN_MS}ms", ): cv.positive_time_period_milliseconds, } ), @@ -586,11 +590,14 @@ async def _build_binary_sensor_automations(var, config): ) for tim in conf[CONF_TIMING] ] - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings + table = cg.shared_progmem_array( + "multi_click_timing", MultiClickTriggerEvent, cg.ArrayInitializer(*timings) ) - if CONF_INVALID_COOLDOWN in conf: - cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN])) + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, table, len(timings)) + if ( + cooldown := conf[CONF_INVALID_COOLDOWN] + ).total_milliseconds != DEFAULT_INVALID_COOLDOWN_MS: + cg.add(trigger.set_invalid_cooldown(cooldown)) await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index d4b4906c30d..46f7d9488c4 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -18,7 +18,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; -void MultiClickTriggerBase::on_state_(bool state) { +void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -30,7 +30,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - if (!this->at_index_.has_value()) { + if (this->at_index_ == 0) { // Start matching MultiClickTriggerEvent evt = this->timing_[0]; if (evt.state == state) { @@ -55,8 +55,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - // at_index_ has a value here (the !has_value() branch above returns). - size_t at_index = *this->at_index_; + uint8_t at_index = this->at_index_; if (at_index == this->timing_count_) { this->trigger_(); return; @@ -65,15 +64,15 @@ void MultiClickTriggerBase::on_state_(bool state) { MultiClickTriggerEvent evt = this->timing_[at_index]; if (evt.max_length != 4294967294UL) { - ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT + ESP_LOGV(TAG, "A i=%u min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); } else if (at_index + 1 != this->timing_count_) { - ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "B i=%u min=%" PRIu32, at_index, evt.min_length); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { - ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "C i=%u min=%" PRIu32, at_index, evt.min_length); this->is_valid_ = false; this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); @@ -81,19 +80,19 @@ void MultiClickTriggerBase::on_state_(bool state) { this->at_index_ = at_index + 1; } -void MultiClickTriggerBase::schedule_cooldown_() { +void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again."); this->is_in_cooldown_ = false; }); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } -void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { +void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -105,7 +104,7 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS")); @@ -113,14 +112,14 @@ void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { this->schedule_cooldown_(); }); } -void MultiClickTriggerBase::cancel() { +void MultiClickTrigger::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void MultiClickTriggerBase::trigger_() { +void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 49457200bc6..7426cc5614c 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include "esphome/core/component.h" @@ -13,10 +13,14 @@ namespace esphome::binary_sensor { struct MultiClickTriggerEvent { - bool state; + uint32_t state; // Not bool: all-word fields allow aligned loads from PROGMEM on ESP8266 uint32_t min_length; uint32_t max_length; }; +// Read straight from PROGMEM; a byte-sized field would fault on ESP8266. +static_assert(std::is_same_v && + sizeof(MultiClickTriggerEvent) == 3 * sizeof(uint32_t), + "MultiClickTriggerEvent fields must all be uint32_t"); bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); @@ -70,10 +74,11 @@ class DoubleClickTrigger final : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header). -class MultiClickTriggerBase : public Trigger<>, public Component { +class MultiClickTrigger final : public Trigger<>, public Component { public: - explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {} + /// `timing` is a codegen PROGMEM table shared by triggers with the same timing. + MultiClickTrigger(BinarySensor *parent, const MultiClickTriggerEvent *timing, uint8_t timing_count) + : parent_(parent), timing_(timing), timing_count_(timing_count) {} void setup() override { this->last_state_ = this->parent_->get_state_default(false); @@ -85,8 +90,8 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; } void cancel(); - MultiClickTriggerBase(const MultiClickTriggerBase &) = delete; - MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete; + MultiClickTrigger(const MultiClickTrigger &) = delete; + MultiClickTrigger &operator=(const MultiClickTrigger &) = delete; protected: void on_state_(bool state); @@ -96,28 +101,13 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void trigger_(); BinarySensor *parent_; - const MultiClickTriggerEvent *timing_{nullptr}; - uint32_t invalid_cooldown_{1000}; - optional at_index_{}; - uint8_t timing_count_{0}; + const MultiClickTriggerEvent *timing_; + uint32_t invalid_cooldown_{1000}; // Must match DEFAULT_INVALID_COOLDOWN_MS in __init__.py + uint8_t timing_count_; + uint8_t at_index_{0}; // 0: not matching, otherwise the index of the next timing event bool last_state_{false}; bool is_in_cooldown_{false}; bool is_valid_{false}; }; -/// Template wrapper that provides inline std::array storage for timing events. -/// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger final : public MultiClickTriggerBase { - public: - MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) - : MultiClickTriggerBase(parent) { - init_array_from(this->timing_storage_, timing); - this->timing_ = this->timing_storage_.data(); - this->timing_count_ = N; - } - - protected: - std::array timing_storage_{}; -}; - } // namespace esphome::binary_sensor diff --git a/tests/component_tests/binary_sensor/test_multi_click.py b/tests/component_tests/binary_sensor/test_multi_click.py new file mode 100644 index 00000000000..6f2556735f9 --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.py @@ -0,0 +1,29 @@ +"""Tests for the multi click trigger codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_multi_click_timing_tables(generate_main: Callable[[str | Path], str]) -> None: + """Identical timings share one PROGMEM table; the default cooldown is not set.""" + main_cpp = generate_main( + "tests/component_tests/binary_sensor/test_multi_click.yaml" + ) + + tables = re.findall( + r"static constexpr binary_sensor::MultiClickTriggerEvent (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(tables) == 2 + single, long_press = tables + triggers = re.findall( + r"binary_sensor::MultiClickTrigger\((\w+), (\w+), (\d+)\);", main_cpp + ) + assert triggers == [ + ("button_a", single, "2"), + ("button_a", long_press, "1"), + ("button_b", single, "2"), + ] + assert main_cpp.count("set_invalid_cooldown(") == 1 + assert "set_invalid_cooldown(500);" in main_cpp diff --git a/tests/component_tests/binary_sensor/test_multi_click.yaml b/tests/component_tests/binary_sensor/test_multi_click.yaml new file mode 100644 index 00000000000..c18737ea3c4 --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.yaml @@ -0,0 +1,33 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +binary_sensor: + - platform: gpio + id: button_a + pin: GPIO32 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: a single + - timing: + - ON for at least 2s + invalid_cooldown: 500ms + then: + - logger.log: a long + - platform: gpio + id: button_b + pin: GPIO33 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: b single From b66cde0270b37d0716ae7442f0aa3dc6a4540224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:39:38 -1000 Subject: [PATCH 424/435] [sensor] Share calibrate_linear and calibrate_polynomial tables in flash (#20074) --- esphome/components/sensor/__init__.py | 15 ++++-- esphome/components/sensor/filter.cpp | 18 ++++--- esphome/components/sensor/filter.h | 34 ++++--------- .../sensor/test_calibrate.yaml | 51 +++++++++++++++++++ tests/component_tests/sensor/test_sensor.py | 27 ++++++++++ 5 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/sensor/test_calibrate.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 1440e07f763..8f345e3e668 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -806,6 +806,9 @@ def validate_calibrate_linear(config): return config +LINEAR_SEGMENT = cg.std_ns.class_("array").template(cg.float_, 3) + + @FILTER_REGISTRY.register( "calibrate_linear", CalibrateLinearFilter, @@ -832,9 +835,11 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + # The last segment always has a NaN boundary, so the filter needs no count. + table = cg.shared_progmem_array( + "sensor_calibrate_linear", LINEAR_SEGMENT, linear_functions ) + return cg.new_Pvariable(filter_id, table) CONF_DEGREE = "degree" @@ -872,7 +877,11 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) + # The first entry holds the coefficient count, so the filter needs no count field. + table = cg.shared_progmem_array( + "sensor_calibrate_polynomial", cg.float_, [float(len(res)), *res] + ) + return cg.new_Pvariable(filter_id, table) def validate_clamp(config): diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 732db5b0b4f..ed48ddebdbc 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -415,19 +415,21 @@ void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { }); } -optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { - for (size_t i = 0; i < count; i++) { - if (!std::isfinite(functions[i][2]) || value < functions[i][2]) - return (value * functions[i][0]) + functions[i][1]; +// The calibration tables hold only floats, so aligned 32-bit loads from PROGMEM are safe on ESP8266. +optional CalibrateLinearFilter::new_value(float value) { + // Always returns: codegen ends the table with a segment whose boundary is NaN. + for (const auto *function = this->functions_;; function++) { + if (!std::isfinite((*function)[2]) || value < (*function)[2]) + return (value * (*function)[0]) + (*function)[1]; } - return NAN; } -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { +optional CalibratePolynomialFilter::new_value(float value) { float res = 0.0f; float x = 1.0f; - for (size_t i = 0; i < count; i++) { - res += x * coefficients[i]; + const auto count = static_cast(this->coefficients_[0]); + for (size_t i = 1; i <= count; i++) { + res += x * this->coefficients_[i]; x *= value; } return res; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 01949f45df2..9b5ef493081 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -579,38 +579,24 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -/// Non-template helper for linear calibration (implementation in filter.cpp) -optional calibrate_linear_compute(const std::array *functions, size_t count, float value); - -/// N is set by code generation to match the exact number of calibration segments. -template class CalibrateLinearFilter : public Filter { +/// Piecewise linear calibration; the segments are a PROGMEM table shared by filters with the same data. +class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { - init_array_from(this->linear_functions_, linear_functions); - } - optional new_value(float value) override { - return calibrate_linear_compute(this->linear_functions_.data(), N, value); - } + explicit CalibrateLinearFilter(const std::array *functions) : functions_(functions) {} + optional new_value(float value) override; protected: - std::array, N> linear_functions_{}; + const std::array *functions_; // Last segment has a NaN boundary, which ends the table }; -/// Non-template helper for polynomial calibration (implementation in filter.cpp) -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); - -/// N is set by code generation to match the exact number of polynomial coefficients. -template class CalibratePolynomialFilter : public Filter { +/// Polynomial calibration; the coefficients are a PROGMEM table shared by filters with the same data. +class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients) { - init_array_from(this->coefficients_, coefficients); - } - optional new_value(float value) override { - return calibrate_polynomial_compute(this->coefficients_.data(), N, value); - } + explicit CalibratePolynomialFilter(const float *coefficients) : coefficients_(coefficients) {} + optional new_value(float value) override; protected: - std::array coefficients_{}; + const float *coefficients_; // [count, c0, c1, ...] }; class ClampFilter : public Filter { diff --git a/tests/component_tests/sensor/test_calibrate.yaml b/tests/component_tests/sensor/test_calibrate.yaml new file mode 100644 index 00000000000..41a87c87795 --- /dev/null +++ b/tests/component_tests/sensor/test_calibrate.yaml @@ -0,0 +1,51 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +sensor: + - platform: template + id: exact_a + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: exact_b + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: least_squares + lambda: return 1.0; + filters: + - calibrate_linear: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + - platform: template + id: polynomial + lambda: return 1.0; + filters: + - calibrate_polynomial: + degree: 1 + datapoints: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + # User ids equal to the old derived table name and to the helper's base name. + - platform: template + id: sensor_calibratelinearfilter_id_data + lambda: return 1.0; + - platform: template + id: sensor_calibrate_linear + lambda: return 1.0; diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 9d18fa36b8f..07981c18976 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -1,5 +1,8 @@ """Tests for the sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import extract_packed_value @@ -15,3 +18,27 @@ def test_sensor_device_class_set(generate_main): # Then: device_class: voltage means packed value must be non-zero packed = extract_packed_value(main_cpp, "s_1") assert packed != 0 + + +def test_calibration_tables_are_shared_progmem( + generate_main: Callable[[str], str], +) -> None: + """Calibration data is a PROGMEM table, shared by filters with identical data.""" + main_cpp = generate_main("tests/component_tests/sensor/test_calibrate.yaml") + + linear = re.findall( + r"static constexpr std::array (\w+)\[\] PROGMEM", main_cpp + ) + assert len(linear) == 2 # the two exact filters share one table + exact, least_squares = linear + assert main_cpp.count(f"sensor::CalibrateLinearFilter({exact});") == 2 + assert f"sensor::CalibrateLinearFilter({least_squares});" in main_cpp + + poly = re.search( + r"static constexpr float (\w+)\[\] PROGMEM = \{2\.0f, 1\.0f, 2\.0f\};", main_cpp + ) + assert poly is not None + assert f"sensor::CalibratePolynomialFilter({poly.group(1)});" in main_cpp + # Table names never reuse a user id. + assert exact != "sensor_calibrate_linear" + assert "sensor_calibratelinearfilter_id_data" not in (exact, least_squares) From 3ad83ad02bf868c696f4b37441e289885018d0c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:40:38 -1000 Subject: [PATCH 425/435] [text_sensor] Share substitute and map filter tables in flash (#20076) --- esphome/components/text_sensor/__init__.py | 36 ++++++++---------- esphome/components/text_sensor/filter.cpp | 24 ++++++------ esphome/components/text_sensor/filter.h | 34 ++++++----------- .../text_sensor/test_text_sensor.py | 38 +++++++++++++++++++ ...est_text_sensor_filter_table_id_clash.yaml | 18 +++++++++ .../test_text_sensor_filter_tables.yaml | 25 ++++++++++++ 6 files changed, 121 insertions(+), 54 deletions(-) create mode 100644 tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml create mode 100644 tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 959ce77c7a9..2ff0421f392 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -59,6 +59,7 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter) PrependFilter = text_sensor_ns.class_("PrependFilter", Filter) SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter) MapFilter = text_sensor_ns.class_("MapFilter", Filter) +Substitution = text_sensor_ns.struct("Substitution") @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -102,34 +103,29 @@ def validate_mapping(value): )(value) -@FILTER_REGISTRY.register( - "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) -) -async def substitute_filter_to_code(config, filter_id): - substitutions = [ +def _substitution_table(config: list[ConfigType]) -> MockObj: + """Shared PROGMEM Substitution table, ended by an empty pair so filters store no count.""" + pairs = [ cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), + Substitution, ("from", conf[CONF_FROM]), ("to", conf[CONF_TO]) ) for conf in config ] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(substitutions)), substitutions + end = cg.RawExpression("nullptr") + pairs.append(cg.StructInitializer(Substitution, ("from", end), ("to", end))) + return cg.shared_progmem_array( + "text_sensor_substitutions", Substitution, cg.ArrayInitializer(*pairs) ) +@FILTER_REGISTRY.register( + "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) +) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) -async def map_filter_to_code(config, filter_id): - mappings = [ - cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), - ) - for conf in config - ] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings) +async def substitution_filter_to_code( + config: list[ConfigType], filter_id: ID +) -> MockObj: + return cg.new_Pvariable(filter_id, _substitution_table(config)) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index 9d18fb9b1a6..54457d82386 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -73,25 +73,25 @@ bool PrependFilter::new_value(std::string &value) { return true; } -// Substitute — non-template helper -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - const size_t from_len = strlen(substitutions[i].from); - const size_t to_len = strlen(substitutions[i].to); +// Substitute +bool SubstituteFilter::new_value(std::string &value) { + for (const Substitution *s = this->substitutions_; s->from != nullptr; s++) { + const size_t from_len = strlen(s->from); + const size_t to_len = strlen(s->to); std::size_t pos = 0; - while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) { - value.replace(pos, from_len, substitutions[i].to, to_len); + while ((pos = value.find(s->from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, s->to, to_len); pos += to_len; } } return true; } -// Map — non-template helper -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - if (value == mappings[i].from) { - value.assign(mappings[i].to); +// Map +bool MapFilter::new_value(std::string &value) { + for (const Substitution *m = this->mappings_; m->from != nullptr; m++) { + if (value == m->from) { + value.assign(m->to); return true; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 6db76dcb640..7898d466861 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,8 +3,6 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER -#include - #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -123,25 +121,18 @@ struct Substitution { const char *to; }; -/// Non-template helper (implementation in filter.cpp) -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value); - /// A simple filter that replaces a substring with another substring. -/// N is set by code generation to match the exact number of substitutions configured in YAML. -template class SubstituteFilter : public Filter { +/// `substitutions` is a codegen table in flash, shared by filters with the same list and +/// ended by an entry whose `from` is nullptr, so no count is stored. +class SubstituteFilter : public Filter { public: - explicit SubstituteFilter(const std::initializer_list &substitutions) { - init_array_from(this->substitutions_, substitutions); - } - bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); } + explicit SubstituteFilter(const Substitution *substitutions) : substitutions_(substitutions) {} + bool new_value(std::string &value) override; protected: - std::array substitutions_{}; + const Substitution *substitutions_; }; -/// Non-template helper (implementation in filter.cpp) -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value); - /** A filter that maps values from one set to another * * Uses linear search instead of std::map for typical small datasets (2-20 mappings). @@ -166,17 +157,16 @@ bool map_filter_apply(const Substitution *mappings, size_t count, std::string &v * * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 * - * N is set by code generation to match the exact number of mappings configured in YAML. + * `mappings` is a codegen table in flash, shared by filters with the same list and ended by + * an entry whose `from` is nullptr, so no count is stored. */ -template class MapFilter : public Filter { +class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &mappings) { - init_array_from(this->mappings_, mappings); - } - bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); } + explicit MapFilter(const Substitution *mappings) : mappings_(mappings) {} + bool new_value(std::string &value) override; protected: - std::array mappings_{}; + const Substitution *mappings_; }; } // namespace esphome::text_sensor diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index eb25af3095f..9eda24f7b94 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -1,5 +1,8 @@ """Tests for the text sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value @@ -62,3 +65,38 @@ def test_text_sensor_device_class_set(generate_main): assert packed_ts_2 != 0 packed_ts_3 = extract_packed_value(main_cpp, "ts_3") assert packed_ts_3 != 0 + + +def test_substitution_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Map and substitute pairs live in PROGMEM tables; equal lists share one.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + map_table, sub_table = tables + assert main_cpp.count(f"text_sensor::MapFilter({map_table})") == 2 + assert f"text_sensor::SubstituteFilter({sub_table})" in main_cpp + # Each table ends with an empty pair so the filters store no count. + assert main_cpp.count(".from = nullptr") == 2 + + +def test_substitution_table_names_avoid_user_ids( + generate_main: Callable[[str], str], +) -> None: + """A user id equal to a table name cannot collide with the generated table.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + assert "text_sensor_substitutions" not in tables + assert "text_sensor_mapfilter_id_table" not in tables diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml new file mode 100644 index 00000000000..d0526cab197 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: text_sensor_substitutions + filters: + - map: + - "ON -> Open" + - platform: template + id: text_sensor_mapfilter_id_table + filters: + - map: + - "OFF -> Closed" diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml new file mode 100644 index 00000000000..ac422c33099 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: ts_map_a + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_map_b + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_sub + filters: + - substitute: + - "foo -> bar" From 80fa6efd1e2fd117b2f5ae601fc60fe56b3661ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:44:49 -1000 Subject: [PATCH 426/435] [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 427/435] [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 428/435] [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 429/435] [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 430/435] [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 431/435] [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 432/435] [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 433/435] [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 434/435] [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 435/435] [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