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 01/25] [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 02/25] [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 03/25] [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 04/25] [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 05/25] [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 06/25] [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 07/25] [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 08/25] [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 09/25] [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 10/25] [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 11/25] [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 12/25] [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 13/25] [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 14/25] [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 15/25] [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 16/25] [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 17/25] 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 18/25] 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 19/25] [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 20/25] [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 21/25] [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 22/25] [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 23/25] [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 24/25] [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 25/25] [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([])