From 044c4cb9bb9ce5622650cdadd89cb8b06dde0f05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 16:46:12 +0100 Subject: [PATCH 01/94] [core] Migrate trivial action and condition builders to the shared helpers (#19322) --- .../alarm_control_panel/__init__.py | 31 ++----- esphome/components/binary_sensor/__init__.py | 5 +- esphome/components/bl0906/sensor.py | 13 +-- esphome/components/ble_client/__init__.py | 15 +--- esphome/components/bm8563/time.py | 22 +---- esphome/components/cc1101/__init__.py | 38 ++++++--- esphome/components/cm1106/sensor.py | 13 +-- esphome/components/cs5460a/sensor.py | 12 +-- esphome/components/dfplayer/__init__.py | 6 +- .../components/dfrobot_sen0395/__init__.py | 12 +-- .../components/display_menu_base/__init__.py | 80 +++++++++---------- esphome/components/ds1307/time.py | 24 +----- esphome/components/duty_time/sensor.py | 57 ++++--------- esphome/components/esp32_ble/__init__.py | 48 +++++------ esphome/components/ethernet/__init__.py | 35 +++----- esphome/components/ezo_pmp/__init__.py | 32 ++------ .../components/fingerprint_grow/__init__.py | 12 +-- .../components/grove_tb6612fng/__init__.py | 14 +--- esphome/components/haier/climate.py | 49 ++++++------ esphome/components/hbridge/fan/__init__.py | 13 +-- esphome/components/hdc302x/sensor.py | 11 +-- esphome/components/integration/sensor.py | 11 +-- esphome/components/key_collector/__init__.py | 23 +----- esphome/components/max17043/sensor.py | 17 ++-- esphome/components/mcp4461/output/__init__.py | 36 ++++----- esphome/components/mhz19/sensor.py | 19 ++--- .../components/mitsubishi_cn105/__init__.py | 11 +-- esphome/components/nau7802/sensor.py | 21 ++--- esphome/components/pcf85063/time.py | 24 +----- esphome/components/pcf8563/time.py | 24 +----- esphome/components/pmwcs3/sensor.py | 14 +--- esphome/components/pn532/__init__.py | 15 +--- esphome/components/pn7150/__init__.py | 65 ++++++++------- esphome/components/pn7160/__init__.py | 65 ++++++++------- esphome/components/pzemac/sensor.py | 12 +-- esphome/components/pzemdc/sensor.py | 12 +-- esphome/components/rx8130/time.py | 24 +----- esphome/components/scd4x/sensor.py | 11 +-- esphome/components/sen5x/sensor.py | 12 +-- esphome/components/senseair/sensor.py | 28 +++---- esphome/components/sim800l/__init__.py | 10 +-- esphome/components/sound_level/sensor.py | 27 +++---- esphome/components/sprinkler/__init__.py | 49 +++++++----- esphome/components/sps30/sensor.py | 21 ++--- esphome/components/sx126x/__init__.py | 23 +++--- esphome/components/sx127x/__init__.py | 27 +++---- esphome/components/tinyusb/__init__.py | 12 +-- esphome/components/tm1651/__init__.py | 27 ++----- esphome/components/ufire_ec/sensor.py | 10 +-- esphome/components/ufire_ise/sensor.py | 10 +-- esphome/components/update/__init__.py | 26 +----- esphome/components/valve/__init__.py | 40 +++++----- .../components/voice_assistant/__init__.py | 33 ++------ esphome/components/wifi/__init__.py | 71 ++++++++-------- esphome/components/zigbee/__init__.py | 13 +-- 55 files changed, 455 insertions(+), 930 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 001ecd88616..b0fea438017 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -208,65 +208,50 @@ for _name, _arm in ( ) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.pending", PendingAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -async def alarm_action_pending_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.triggered", TriggeredAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -async def alarm_action_trigger_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.chime", ChimeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -async def alarm_action_chime_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.ready", ReadyAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -@automation.register_condition( + + +automation.register_simple_condition( "alarm_control_panel.ready", AlarmControlPanelCondition, ALARM_CONTROL_PANEL_CONDITION_SCHEMA, ) -async def alarm_action_ready_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition( +automation.register_simple_condition( "alarm_control_panel.is_armed", AlarmControlPanelCondition, ALARM_CONTROL_PANEL_CONDITION_SCHEMA, ) -async def alarm_control_panel_is_armed_to_code( - config, condition_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index da0276c037d..696d7345ddc 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -655,7 +655,7 @@ async def to_code(config): cg.add_global(binary_sensor_ns.using) -@automation.register_action( +automation.register_simple_action( "binary_sensor.invalidate_state", BinarySensorInvalidateAction, cv.maybe_simple_value( @@ -666,9 +666,6 @@ async def to_code(config): ), synchronous=True, ) -async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) # automation.cpp only implements the click/double_click/multi_click triggers diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 1a0c2287ab7..e24cd316f4e 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,8 +32,6 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType # Import ICONS not included in esphome's const.py, from the local components const.py @@ -138,7 +136,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -@automation.register_action( +automation.register_parented_action( "bl0906.reset_energy", ResetEnergyAction, maybe_simple_id( @@ -148,15 +146,6 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ), synchronous=True, ) -async def reset_energy_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 1ef7967fa83..810f4b66e3d 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -207,26 +207,20 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_simple_action( "ble_client.disconnect", BLEDisconnectAction, BLE_CONNECT_ACTION_SCHEMA, synchronous=False, ) -async def ble_disconnect_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) -@automation.register_action( +automation.register_simple_action( "ble_client.connect", BLEConnectAction, BLE_CONNECT_ACTION_SCHEMA, synchronous=False, ) -async def ble_connect_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -328,15 +322,12 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "ble_client.remove_bond", BLERemoveBondAction, BLE_REMOVE_BOND_ACTION_SCHEMA, synchronous=True, ) -async def remove_bond_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index 5ef162bb7c4..3a15d0eb1c0 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_parented_action( "bm8563.write_time", WriteAction, automation.maybe_simple_id( @@ -38,15 +38,6 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def bm8563_write_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var @automation.register_action( @@ -73,7 +64,7 @@ async def bm8563_start_timer_to_code( return var -@automation.register_action( +automation.register_parented_action( "bm8563.read_time", ReadAction, automation.maybe_simple_id( @@ -83,15 +74,6 @@ async def bm8563_start_timer_to_code( ), synchronous=True, ) -async def bm8563_read_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index dbafae4ad0f..d851d7386df 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -364,22 +364,36 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA, synchronous=True +automation.register_parented_action( + "cc1101.begin_tx", + BeginTxAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "cc1101.begin_rx", + BeginRxAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "cc1101.reset", + ResetAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "cc1101.set_idle", + SetIdleAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -async def cc1101_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var def validate_raw_data(value): diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index a36f0b00594..cbb0509ce50 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -13,8 +13,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -72,18 +70,9 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "cm1106.calibrate_zero", CM1106CalibrateZeroAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def cm1106_calibration_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - """Service code generation entry point.""" - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 5f14457101f..946037fc34d 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -17,8 +17,6 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@balrog-kun"] @@ -131,7 +129,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_power_sensor(sens)) -@automation.register_action( +automation.register_simple_action( "cs5460a.restart", CS5460ARestartAction, maybe_simple_id( @@ -141,11 +139,3 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -async def restart_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index d2120deaa61..0516b27ca21 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -199,7 +199,7 @@ automation.register_apply_action( ) -@automation.register_condition( +automation.register_parented_condition( "dfplayer.is_playing", DFPlayerIsPlayingCondition, cv.Schema( @@ -208,7 +208,3 @@ automation.register_apply_action( } ), ) -async def dfplayer_is_playing_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 51562f923cb..81d57026cc9 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -49,7 +49,7 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) -@automation.register_action( +automation.register_parented_action( "dfrobot_sen0395.reset", DfrobotSen0395ResetAction, maybe_simple_id( @@ -59,16 +59,6 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -async def dfrobot_sen0395_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - return var def range_segment_list(input: Any) -> list: diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 2120abe5f79..a5204cfe2fc 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -294,74 +294,71 @@ MENU_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "display_menu.up", UpAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.up", + UpAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_up_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.down", DownAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.down", + DownAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_down_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.left", LeftAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.left", + LeftAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_left_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.right", RightAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.right", + RightAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_right_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.enter", EnterAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.enter", + EnterAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_enter_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.show", ShowAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.show", + ShowAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_show_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.hide", HideAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.hide", + HideAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_hide_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "display_menu.show_main", ShowMainAction, MENU_ACTION_SCHEMA, synchronous=True, ) -async def menu_show_main_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition( +automation.register_simple_condition( "display_menu.is_active", IsActiveCondition, automation.maybe_simple_id( @@ -370,9 +367,6 @@ async def menu_show_main_to_code(config, action_id, template_arg, args): } ), ) -async def display_menu_is_active_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) async def menu_item_to_code(menu, config, parent): diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index a3ae3eb5af8..278494c81fc 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] @@ -22,7 +20,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x68)) -@automation.register_action( +automation.register_parented_action( "ds1307.write_time", WriteAction, cv.Schema( @@ -32,18 +30,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def ds1307_write_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_parented_action( "ds1307.read_time", ReadAction, automation.maybe_simple_id( @@ -53,15 +42,6 @@ async def ds1307_write_time_to_code( ), synchronous=True, ) -async def ds1307_read_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 6d878a80a5e..af7e14abb81 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -1,10 +1,5 @@ -from esphome.automation import ( - Action, - Condition, - maybe_simple_id, - register_action, - register_condition, -) +from esphome import automation +from esphome.automation import Action, Condition, maybe_simple_id, register_condition import esphome.codegen as cg from esphome.components import binary_sensor, sensor import esphome.config_validation as cv @@ -93,46 +88,28 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( ) -@register_action( - "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True +automation.register_parented_action( + "sensor.duty_time.start", + StartAction, + DUTY_TIME_ID_SCHEMA, + synchronous=True, ) -async def sensor_runtime_start_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@register_action( - "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True +automation.register_parented_action( + "sensor.duty_time.stop", + StopAction, + DUTY_TIME_ID_SCHEMA, + synchronous=True, ) -async def sensor_runtime_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@register_action( - "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True +automation.register_parented_action( + "sensor.duty_time.reset", + ResetAction, + DUTY_TIME_ID_SCHEMA, + synchronous=True, ) -async def sensor_runtime_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var @register_condition( diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 7e97111686b..77ec17f3891 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -32,8 +32,7 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, ID, TimePeriod -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, TimePeriod import esphome.final_validate as fv from esphome.types import ConfigType @@ -606,38 +605,27 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_UUID") -@automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -async def ble_enabled_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_action( - "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "ble.enabled", + BLEEnabledCondition, + cv.Schema({}), ) -async def ble_enable_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True +automation.register_bare_action( + "ble.enable", + BLEEnableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "ble.disable", + BLEDisableAction, + cv.Schema({}), + synchronous=True, ) -async def ble_disable_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(action_id, template_arg) # ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3e7d345805c..920f2e5e4bf 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -53,12 +53,10 @@ from esphome.const import ( ) from esphome.core import ( CORE, - ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -924,24 +922,15 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -async def _new_pvariable_to_code( - config: ConfigType, - id_: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(id_, template_arg) - - -for _name, _cls in ( - ("ethernet.connected", EthernetConnectedCondition), - ("ethernet.enabled", EthernetEnabledCondition), -): - automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) -for _name, _cls in ( - ("ethernet.enable", EthernetEnableAction), - ("ethernet.disable", EthernetDisableAction), -): - automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( - _new_pvariable_to_code - ) +automation.register_bare_condition( + "ethernet.connected", EthernetConnectedCondition, cv.Schema({}) +) +automation.register_bare_condition( + "ethernet.enabled", EthernetEnabledCondition, cv.Schema({}) +) +automation.register_bare_action( + "ethernet.enable", EthernetEnableAction, cv.Schema({}), synchronous=True +) +automation.register_bare_action( + "ethernet.disable", EthernetDisableAction, cv.Schema({}), synchronous=True +) diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 0793495e1ac..3477de88a4e 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -80,72 +80,52 @@ EzoPMPArbitraryCommandAction = ezo_pmp_ns.class_( ) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.find", EzoPMPFindAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_find_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.dose_continuously", EzoPMPDoseContinuouslyAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_dose_continuously_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.clear_total_volume_dosed", EzoPMPClearTotalVolumeDispensedAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_clear_total_volume_dosed_to_code( - config, action_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.clear_calibration", EzoPMPClearCalibrationAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_clear_calibration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.pause_dosing", EzoPMPPauseDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_pause_dosing_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.stop_dosing", EzoPMPStopDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_stop_dosing_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) # Actions that require Multiple Args diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 8d935a3c9ed..85fe3994c52 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -206,7 +206,7 @@ async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args) return var -@automation.register_action( +automation.register_parented_action( "fingerprint_grow.cancel_enroll", CancelEnrollmentAction, cv.Schema( @@ -216,10 +216,6 @@ async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args) ), synchronous=True, ) -async def fingerprint_grow_cancel_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var @automation.register_action( @@ -243,7 +239,7 @@ async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args) return var -@automation.register_action( +automation.register_parented_action( "fingerprint_grow.delete_all", DeleteAllAction, cv.Schema( @@ -253,10 +249,6 @@ async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args) ), synchronous=True, ) -async def fingerprint_grow_delete_all_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index ae64c049f53..e7937b1f298 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -126,7 +126,7 @@ async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_parented_action( "grove_tb6612fng.standby", GROVETB6612FNGMotorStandbyAction, cv.Schema( @@ -136,14 +136,9 @@ async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - return var -@automation.register_action( +automation.register_parented_action( "grove_tb6612fng.no_standby", GROVETB6612FNGMotorNoStandbyAction, cv.Schema( @@ -153,11 +148,6 @@ async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args) ), synchronous=True, ) -async def grove_tb6612fng_no_standby_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - return var @automation.register_action( diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 70ae36f5281..5a87ec1557a 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -284,56 +284,53 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "climate.haier.display_on", DisplayOnAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.display_off", DisplayOffAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -async def display_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "climate.haier.beeper_on", BeeperOnAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.beeper_off", BeeperOffAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -async def beeper_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) # Start self cleaning or steri-cleaning action action -@automation.register_action( +automation.register_simple_action( "climate.haier.start_self_cleaning", StartSelfCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.start_steri_cleaning", StartSteriCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -async def start_cleaning_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) # Set vertical airflow direction action @@ -384,44 +381,44 @@ async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, return var -@automation.register_action( +automation.register_simple_action( "climate.haier.health_on", HealthOnAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.health_off", HealthOffAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -async def health_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "climate.haier.power_on", PowerOnAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.power_off", PowerOffAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.power_toggle", PowerToggleAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -async def power_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config) -> None: diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 2cf1693b472..cd47d100be4 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -7,14 +7,11 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DECAY_MODE, CONF_ENABLE_PIN, - CONF_ID, CONF_PIN_A, CONF_PIN_B, CONF_PRESET_MODES, CONF_SPEED_COUNT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import hbridge_ns @@ -51,20 +48,12 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_simple_action( "fan.hbridge.brake", BrakeAction, maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), synchronous=True, ) -async def fan_hbridge_brake_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 6d91c3df7c2..31671c5b1e5 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -139,18 +139,9 @@ async def hdc302x_heater_on_to_code( return var -@automation.register_action( +automation.register_parented_action( "hdc302x.heater_off", HeaterOffAction, HDC302X_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_off_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 82e8ba8df8e..672af46e3e3 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -106,7 +106,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_restore(config[CONF_RESTORE])) -@automation.register_action( +automation.register_parented_action( "sensor.integration.reset", ResetAction, automation.maybe_simple_id( @@ -116,15 +116,6 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -async def sensor_integration_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var @automation.register_action( diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index bf47b6df888..c95bdc46b00 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TRIGGER_ID, ) -from esphome.core import ID from esphome.cpp_generator import MockObj, literal from esphome.types import ConfigType, TemplateArgsType @@ -135,7 +134,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_enabled(config[CONF_ENABLE_ON_BOOT])) -@automation.register_action( +automation.register_parented_action( "key_collector.enable", EnableAction, automation.maybe_simple_id( @@ -145,18 +144,9 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -async def enable_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_parented_action( "key_collector.disable", DisableAction, automation.maybe_simple_id( @@ -166,12 +156,3 @@ async def enable_to_code( ), synchronous=True, ) -async def disable_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 67fb8aa5b7b..7de1424a96d 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -14,8 +14,6 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -74,14 +72,9 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "max17043.sleep_mode", + SleepAction, + MAX17043_ACTION_SCHEMA, + synchronous=True, ) -async def max17043_sleep_mode_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index db1a1e6a29f..d2b4b321742 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -144,36 +144,28 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "mcp4461.wiper.increase", WiperIncreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "mcp4461.wiper.increase", + WiperIncreaseAction, + WIPER_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True -) -async def mcp4461_wiper_step_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - wiper = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, wiper) -@automation.register_action( +automation.register_simple_action( + "mcp4461.wiper.decrease", + WiperDecreaseAction, + WIPER_ACTION_SCHEMA, + synchronous=True, +) + + +automation.register_simple_action( "mcp4461.wiper.store_nonvolatile", WiperStoreNonvolatileAction, WIPER_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_store_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - wiper = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, wiper) @automation.register_action( diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 5852686608c..4434eddb08d 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -122,33 +122,28 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "mhz19.calibrate_zero", MHZ19CalibrateZeroAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "mhz19.abc_enable", MHZ19ABCEnableAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "mhz19.abc_disable", MHZ19ABCDisableAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_no_args_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var RANGE_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 9502773616e..8f1c6ef648b 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -162,21 +162,12 @@ async def remote_temperature_action_to_code( return var -@automation.register_action( +automation.register_parented_action( f"{DOMAIN}.clear_remote_temperature", ClearRemoteTemperatureAction, CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def clear_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var VANE_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 415ae09dafa..3112df99d03 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -4,8 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, ICON_SCALE, STATE_CLASS_MEASUREMENT -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@cujomalainey"] @@ -116,30 +114,25 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "nau7802.calibrate_internal_offset", NAU7802CalbrateInternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "nau7802.calibrate_external_offset", NAU7802CalbrateExternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "nau7802.calibrate_gain", NAU7802CalbrateGainAction, NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -async def nau7802_calibrate_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 771461905e0..cc7a373b42d 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@brogon"] @@ -24,7 +22,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -@automation.register_action( +automation.register_parented_action( "pcf85063.write_time", WriteAction, cv.Schema( @@ -34,18 +32,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf85063_write_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_parented_action( "pcf85063.read_time", ReadAction, automation.maybe_simple_id( @@ -55,15 +44,6 @@ async def pcf85063_write_time_to_code( ), synchronous=True, ) -async def pcf85063_read_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 8a0b871be9b..141949bf35d 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@KoenBreeman"] @@ -27,7 +25,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -@automation.register_action( +automation.register_parented_action( "pcf8563.write_time", WriteAction, automation.maybe_simple_id( @@ -37,18 +35,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf8563_write_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_parented_action( "pcf8563.read_time", ReadAction, automation.maybe_simple_id( @@ -58,15 +47,6 @@ async def pcf8563_write_time_to_code( ), synchronous=True, ) -async def pcf8563_read_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index ae22b3e0d6c..552cfafa04d 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -105,26 +105,20 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_simple_action( "pmwcs3.air_calibration", PMWCS3AirCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "pmwcs3.water_calibration", PMWCS3WaterCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, synchronous=True, ) -async def pmwcs3_calibration_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 62589323121..d396050511f 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -3,14 +3,12 @@ import esphome.codegen as cg from esphome.components import nfc import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_ON_FINISHED_WRITE, CONF_ON_TAG, CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] @@ -79,7 +77,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_condition( +automation.register_parented_condition( "pn532.is_writing", PN532IsWritingCondition, cv.Schema( @@ -88,12 +86,3 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: } ), ) -async def pn532_is_writing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 4638992abf3..da5f4824646 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -127,54 +127,68 @@ async def pn7150_set_message_to_code( return var -@automation.register_action( +automation.register_parented_action( "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.emulation_on", + EmulationOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_off", + PollingOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_on", + PollingOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_simple_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var _CALLBACK_AUTOMATIONS = ( @@ -220,7 +234,7 @@ async def setup_pn7150(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_condition( +automation.register_parented_condition( "pn7150.is_writing", PN7150IsWritingCondition, cv.Schema( @@ -229,12 +243,3 @@ async def setup_pn7150(var: MockObj, config: ConfigType) -> None: } ), ) -async def pn7150_is_writing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index 7f9f9172a15..5ce0dd12861 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -131,54 +131,68 @@ async def pn7160_set_message_to_code( return var -@automation.register_action( +automation.register_parented_action( "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.emulation_on", + EmulationOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_off", + PollingOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_on", + PollingOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_simple_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var _CALLBACK_AUTOMATIONS = ( @@ -232,7 +246,7 @@ async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_condition( +automation.register_parented_condition( "pn7160.is_writing", PN7160IsWritingCondition, cv.Schema( @@ -241,12 +255,3 @@ async def setup_pn7160(var: MockObj, config: ConfigType) -> None: } ), ) -async def pn7160_is_writing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index f093262e18b..1670204468c 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,8 +26,6 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -85,7 +83,7 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_simple_action( "pzemac.reset_energy", ResetEnergyAction, maybe_simple_id( @@ -95,14 +93,6 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config: ConfigType) -> None: diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b9f7246b721..97e9d09d058 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,8 +20,6 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -67,7 +65,7 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_simple_action( "pzemdc.reset_energy", ResetEnergyAction, maybe_simple_id( @@ -77,14 +75,6 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config: ConfigType) -> None: diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 40d10e9f6b4..0e67593fb32 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@beormund"] @@ -22,7 +20,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x32)) -@automation.register_action( +automation.register_parented_action( "rx8130.write_time", WriteAction, cv.Schema( @@ -32,18 +30,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def rx8130_write_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_parented_action( "rx8130.read_time", ReadAction, automation.maybe_simple_id( @@ -53,15 +42,6 @@ async def rx8130_write_time_to_code( ), synchronous=True, ) -async def rx8130_read_time_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index af3ff3a7af7..fcae9e670ed 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -166,18 +166,9 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "scd4x.factory_reset", FactoryResetAction, SCD4X_RESET_ACTION_SCHEMA, synchronous=True, ) -async def scd4x_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index e86c8bf899e..e7f61d8b2de 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -43,8 +43,6 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -282,17 +280,9 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "sen5x.start_fan_autoclean", StartFanAction, SEN5X_ACTION_SCHEMA, synchronous=True, ) -async def sen54_fan_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 82368a60d02..ac92efa3a40 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -11,8 +11,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -82,41 +80,41 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "senseair.background_calibration", SenseAirBackgroundCalibrationAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.background_calibration_result", SenseAirBackgroundCalibrationResultAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.abc_enable", SenseAirABCEnableAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.abc_disable", SenseAirABCDisableAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.abc_get_period", SenseAirABCGetPeriodAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def senseair_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index ae7ee6fa59f..7f42edb8631 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -125,15 +125,12 @@ async def sim800l_dial_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "sim800l.connect", Sim800LConnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), synchronous=True, ) -async def sim800l_connect_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) SIM800L_SEND_USSD_SCHEMA = cv.Schema( @@ -158,12 +155,9 @@ async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "sim800l.disconnect", Sim800LDisconnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), synchronous=True, ) -async def sim800l_disconnect_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index d2175340415..fe00ae41ba7 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -11,8 +11,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DECIBEL, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -92,18 +90,17 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "sound_level.start", StartAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +automation.register_parented_action( + "sound_level.start", + StartAction, + SOUND_LEVEL_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "sound_level.stop", + StopAction, + SOUND_LEVEL_ACTION_SCHEMA, + synchronous=True, ) -async def sound_level_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 21863e26217..f14ea0cdeb9 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -446,26 +446,20 @@ automation.register_apply_action( ) -@automation.register_action( +automation.register_simple_action( "sprinkler.start_from_queue", StartFromQueueAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -async def sprinkler_start_from_queue_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "sprinkler.start_full_cycle", StartFullCycleAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -485,45 +479,60 @@ async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, return var -@automation.register_action( +automation.register_simple_action( "sprinkler.clear_queued_valves", ClearQueuedValvesAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.next_valve", NextValveAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.previous_valve", PreviousValveAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "sprinkler.pause", PauseAction, SPRINKLER_ACTION_SCHEMA, synchronous=True + + +automation.register_simple_action( + "sprinkler.pause", + PauseAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "sprinkler.resume", ResumeAction, SPRINKLER_ACTION_SCHEMA, synchronous=True + + +automation.register_simple_action( + "sprinkler.resume", + ResumeAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.resume_or_start_full_cycle", ResumeOrStartAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.shutdown", ShutdownAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -async def sprinkler_simple_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) async def to_code(config): diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 681166cd3c6..a310993d82c 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -26,8 +26,6 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_MICROMETER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -182,30 +180,25 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "sps30.start_fan_autoclean", StartFanAction, SPS30_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sps30.start_measurement", StartMeasurementAction, SPS30_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sps30.stop_measurement", StopMeasurementAction, SPS30_ACTION_SCHEMA, synchronous=True, ) -async def sps30_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 29e3ad5359f..f8f51296a16 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -302,39 +302,36 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "sx126x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx126x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx126x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx126x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def no_args_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 34f2d4122f6..5701f2acb7c 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -286,45 +286,44 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "sx127x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_sleep", SetModeSleepAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def no_args_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 7ad88d3018d..422a9942380 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -12,8 +12,6 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -136,16 +134,8 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) -@automation.register_condition( +automation.register_simple_condition( "tinyusb.is_mounted", IsMountedCondition, cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), ) -async def tinyusb_is_mounted_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index c0cc6f1d2cb..b07576e443a 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -141,32 +141,17 @@ async def tm1651_set_level_percent_to_code( return var -@automation.register_action( +automation.register_parented_action( "tm1651.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True, ) -async def output_turn_off_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( - "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True +automation.register_parented_action( + "tm1651.turn_on", + TurnOnAction, + BINARY_OUTPUT_ACTION_SCHEMA, + synchronous=True, ) -async def output_turn_on_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 9d989ad4e67..406de3eff35 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -124,17 +124,9 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_simple_action( "ufire_ec.reset", UFireECResetAction, UFIRE_EC_RESET_SCHEMA, synchronous=True, ) -async def ufire_ec_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index c7e3b6f28d1..aab30ff50e4 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -131,17 +131,9 @@ async def ufire_ise_calibrate_probe_high_to_code( UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent)}) -@automation.register_action( +automation.register_simple_action( "ufire_ise.reset", UFireISEResetAction, UFIRE_ISE_RESET_SCHEMA, synchronous=True, ) -async def ufire_ise_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 818358c48a1..87707159893 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,14 +14,14 @@ from esphome.const import ( DEVICE_CLASS_FIRMWARE, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -144,7 +144,7 @@ automation.register_apply_action( ) -@automation.register_action( +automation.register_parented_action( "update.check", CheckAction, automation.maybe_simple_id( @@ -154,18 +154,9 @@ automation.register_apply_action( ), synchronous=True, ) -async def update_check_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_condition( +automation.register_parented_condition( "update.is_available", IsAvailableCondition, automation.maybe_simple_id( @@ -174,12 +165,3 @@ async def update_check_action_to_code( } ), ) -async def update_is_available_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 692759f3e42..9ec21ef6aba 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -182,36 +182,36 @@ VALVE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "valve.open", OpenAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.open", + OpenAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_open_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "valve.close", CloseAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.close", + CloseAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_close_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "valve.stop", StopAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.stop", + StopAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_stop_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.toggle", + ToggleAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d66ef43fdc5..2f193ebb4e5 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import register_condition import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -14,8 +13,6 @@ from esphome.const import ( CONF_ON_START, CONF_SPEAKER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio", "ring_buffer", "socket"] @@ -424,29 +421,15 @@ automation.register_apply_action( ) -@register_condition( - "voice_assistant.is_running", IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA +automation.register_parented_condition( + "voice_assistant.is_running", + IsRunningCondition, + VOICE_ASSISTANT_ACTION_SCHEMA, ) -async def voice_assistant_is_running_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@register_condition( - "voice_assistant.connected", ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA +automation.register_parented_condition( + "voice_assistant.connected", + ConnectedCondition, + VOICE_ASSISTANT_ACTION_SCHEMA, ) -async def voice_assistant_connected_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index c22d49e6654..c9616a6c290 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -67,14 +67,13 @@ from esphome.const import ( ) from esphome.core import ( CORE, - ID, CoroPriority, EsphomeError, HexInt, coroutine_with_priority, ) import esphome.final_validate as fv -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType from . import wpa2_eap @@ -830,45 +829,49 @@ async def to_code(config): CORE.add_job(final_step) -@automation.register_condition("wifi.connected", WiFiConnectedCondition, cv.Schema({})) -async def wifi_connected_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.enabled", WiFiEnabledCondition, cv.Schema({})) -async def wifi_enabled_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.ap_active", WiFiAPActiveCondition, cv.Schema({})) -async def wifi_ap_active_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_action( - "wifi.enable", WiFiEnableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.connected", + WiFiConnectedCondition, + cv.Schema({}), ) -async def wifi_enable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.disable", WiFiDisableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.enabled", + WiFiEnabledCondition, + cv.Schema({}), ) -async def wifi_disable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.ap_active", + WiFiAPActiveCondition, + cv.Schema({}), +) + + +automation.register_bare_action( + "wifi.enable", + WiFiEnableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "wifi.disable", + WiFiDisableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "wifi.roam", + WiFiRoamAction, + cv.Schema({}), + synchronous=True, ) -async def wifi_roam_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> cg.MockObj: - return cg.new_Pvariable(action_id, template_arg) KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 47913b34d70..8e9824d42e7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any -from esphome import automation, core +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import only_on_variant from esphome.components.esp32.const import ( @@ -298,18 +298,9 @@ FactoryResetAction = zigbee_ns.class_( ) -@automation.register_action( +automation.register_parented_action( "zigbee.factory_reset", FactoryResetAction, ZIGBEE_ACTION_SCHEMA, synchronous=True, ) -async def reset_zigbee_to_code( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> cg.Pvariable: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var From bb84409261282e81fd65e8e41757ffa7e36fc4f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 16:58:56 +0100 Subject: [PATCH 02/94] [sprinkler] Use register_apply_action for the seven keyless controller actions (#19617) --- esphome/components/sprinkler/__init__.py | 149 ++++++++-------------- esphome/components/sprinkler/automation.h | 70 ---------- 2 files changed, 55 insertions(+), 164 deletions(-) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index f14ea0cdeb9..00144adacd7 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -66,19 +66,10 @@ SprinklerControllerSwitch = sprinkler_ns.class_( "SprinklerControllerSwitch", switch.Switch, cg.Component ) -ClearQueuedValvesAction = sprinkler_ns.class_( - "ClearQueuedValvesAction", automation.Action -) -StartFromQueueAction = sprinkler_ns.class_("StartFromQueueAction", automation.Action) -StartFullCycleAction = sprinkler_ns.class_("StartFullCycleAction", automation.Action) StartSingleValveAction = sprinkler_ns.class_( "StartSingleValveAction", automation.Action ) ShutdownAction = sprinkler_ns.class_("ShutdownAction", automation.Action) -NextValveAction = sprinkler_ns.class_("NextValveAction", automation.Action) -PreviousValveAction = sprinkler_ns.class_("PreviousValveAction", automation.Action) -PauseAction = sprinkler_ns.class_("PauseAction", automation.Action) -ResumeAction = sprinkler_ns.class_("ResumeAction", automation.Action) ResumeOrStartAction = sprinkler_ns.class_("ResumeOrStartAction", automation.Action) @@ -415,51 +406,61 @@ CONFIG_SCHEMA = cv.All( _VALVE_AND_DURATION = ((CONF_VALVE_NUMBER, cg.size_t), (CONF_RUN_DURATION, cg.uint32)) -automation.register_apply_action( - "sprinkler.set_divider", - SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, - automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), -) - -automation.register_apply_action( - "sprinkler.set_multiplier", - SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, - automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), -) - -automation.register_apply_action( - "sprinkler.queue_valve", - SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, - automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), -) - -automation.register_apply_action( - "sprinkler.set_repeat", - SPRINKLER_ACTION_REPEAT_SCHEMA, - automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), -) - -automation.register_apply_action( - "sprinkler.set_valve_run_duration", - SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, - automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), -) - - -automation.register_simple_action( - "sprinkler.start_from_queue", - StartFromQueueAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.start_full_cycle", - StartFullCycleAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) +for _name, _schema, _apply in ( + ( + "sprinkler.set_divider", + SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, + automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), + ), + ( + "sprinkler.set_multiplier", + SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, + automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), + ), + ( + "sprinkler.queue_valve", + SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, + automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), + ), + ( + "sprinkler.set_repeat", + SPRINKLER_ACTION_REPEAT_SCHEMA, + automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), + ), + ( + "sprinkler.set_valve_run_duration", + SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, + automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), + ), + ( + "sprinkler.start_from_queue", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("start_from_queue()"), + ), + ( + "sprinkler.start_full_cycle", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("start_full_cycle()"), + ), + ( + "sprinkler.clear_queued_valves", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("clear_queued_valves()"), + ), + ( + "sprinkler.next_valve", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("next_valve()"), + ), + ( + "sprinkler.previous_valve", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("previous_valve()"), + ), + ("sprinkler.pause", SPRINKLER_ACTION_SCHEMA, automation.ApplyCall("pause()")), + ("sprinkler.resume", SPRINKLER_ACTION_SCHEMA, automation.ApplyCall("resume()")), +): + automation.register_apply_action(_name, _schema, _apply) @automation.register_action( @@ -479,46 +480,6 @@ async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, return var -automation.register_simple_action( - "sprinkler.clear_queued_valves", - ClearQueuedValvesAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.next_valve", - NextValveAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.previous_valve", - PreviousValveAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.pause", - PauseAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.resume", - ResumeAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - automation.register_simple_action( "sprinkler.resume_or_start_full_cycle", ResumeOrStartAction, diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index 14d9bd20ec1..1de0e1ee11e 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,36 +6,6 @@ namespace esphome::sprinkler { -template class ClearQueuedValvesAction final : public Action { - public: - explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->clear_queued_valves(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFromQueueAction final : public Action { - public: - explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_from_queue(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFullCycleAction final : public Action { - public: - explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_full_cycle(); } - - protected: - Sprinkler *sprinkler_; -}; - template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -64,46 +34,6 @@ template class ShutdownAction final : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction final : public Action { - public: - explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->next_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PreviousValveAction final : public Action { - public: - explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->previous_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PauseAction final : public Action { - public: - explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->pause(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class ResumeAction final : public Action { - public: - explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->resume(); } - - protected: - Sprinkler *sprinkler_; -}; - template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} From 1dcf0a0f9d119025c57d3831e9c128f477637356 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:00:33 +0100 Subject: [PATCH 03/94] [valve] Use register_apply_action for valve.open, valve.close, valve.stop and valve.toggle (#19618) --- esphome/components/valve/__init__.py | 47 ++++------------ esphome/components/valve/automation.h | 58 -------------------- tests/components/valve/common.yaml | 18 ++++++ tests/components/valve/test.esp32-idf.yaml | 2 + tests/components/valve/test.esp8266-ard.yaml | 2 + 5 files changed, 32 insertions(+), 95 deletions(-) create mode 100644 tests/components/valve/common.yaml create mode 100644 tests/components/valve/test.esp32-idf.yaml create mode 100644 tests/components/valve/test.esp8266-ard.yaml diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 9ec21ef6aba..7df58c7a2aa 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -62,13 +62,7 @@ VALVE_OPERATIONS = { validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) # Actions -OpenAction = valve_ns.class_("OpenAction", automation.Action) -CloseAction = valve_ns.class_("CloseAction", automation.Action) -StopAction = valve_ns.class_("StopAction", automation.Action) -ToggleAction = valve_ns.class_("ToggleAction", automation.Action) ValvePublishAction = valve_ns.class_("ValvePublishAction", automation.Action) -ValveIsOpenCondition = valve_ns.class_("ValveIsOpenCondition", Condition) -ValveIsClosedCondition = valve_ns.class_("ValveIsClosedCondition", Condition) # Triggers ValveOpenTrigger = valve_ns.class_("ValveOpenTrigger", automation.Trigger.template()) @@ -182,36 +176,15 @@ VALVE_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "valve.open", - OpenAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "valve.close", - CloseAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "valve.stop", - StopAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "valve.toggle", - ToggleAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) +for _name, _command in ( + ("valve.open", "set_command_open()"), + ("valve.close", "set_command_close()"), + ("valve.stop", "set_command_stop()"), + ("valve.toggle", "set_command_toggle()"), +): + automation.register_apply_action( + _name, VALVE_ACTION_SCHEMA, automation.ApplyCall(_command), call="make_call" + ) VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 9fdb7665b3c..598bbc1678e 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -6,64 +6,6 @@ namespace esphome::valve { -template class OpenAction final : public Action { - public: - explicit OpenAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_open().perform(); } - - protected: - Valve *valve_; -}; - -template class CloseAction final : public Action { - public: - explicit CloseAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_close().perform(); } - - protected: - Valve *valve_; -}; - -template class StopAction final : public Action { - public: - explicit StopAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_stop().perform(); } - - protected: - Valve *valve_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_toggle().perform(); } - - protected: - Valve *valve_; -}; - -template class ValveIsOpenCondition final : public Condition { - public: - ValveIsOpenCondition(Valve *valve) : valve_(valve) {} - bool check(const Ts &...x) override { return this->valve_->is_fully_open(); } - - protected: - Valve *valve_; -}; - -template class ValveIsClosedCondition final : public Condition { - public: - ValveIsClosedCondition(Valve *valve) : valve_(valve) {} - bool check(const Ts &...x) override { return this->valve_->is_fully_closed(); } - - protected: - Valve *valve_; -}; - class ValveOpenTrigger final : public Trigger<> { public: ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { diff --git a/tests/components/valve/common.yaml b/tests/components/valve/common.yaml new file mode 100644 index 00000000000..bf01f77bc9e --- /dev/null +++ b/tests/components/valve/common.yaml @@ -0,0 +1,18 @@ +valve: + - platform: template + name: Test Valve + id: test_valve + optimistic: true + has_position: true + +button: + - platform: template + name: Valve Commands + on_press: + - valve.open: test_valve + - valve.close: test_valve + - valve.stop: test_valve + - valve.toggle: test_valve + - valve.control: + id: test_valve + position: 50% diff --git a/tests/components/valve/test.esp32-idf.yaml b/tests/components/valve/test.esp32-idf.yaml new file mode 100644 index 00000000000..3c2acee7efa --- /dev/null +++ b/tests/components/valve/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + valve: !include common.yaml diff --git a/tests/components/valve/test.esp8266-ard.yaml b/tests/components/valve/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3c2acee7efa --- /dev/null +++ b/tests/components/valve/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + valve: !include common.yaml From 54d05180b7e07c6b5ab283b4bc87d13502869ef1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:30:10 +0100 Subject: [PATCH 04/94] [duty_time] Use one import style for the automation module (#19622) --- esphome/components/duty_time/sensor.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index af7e14abb81..4afcf0a0ce6 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import Action, Condition, maybe_simple_id, register_condition import esphome.codegen as cg from esphome.components import binary_sensor, sensor import esphome.config_validation as cv @@ -24,13 +23,13 @@ duty_time_sensor_ns = cg.esphome_ns.namespace("duty_time_sensor") DutyTimeSensor = duty_time_sensor_ns.class_( "DutyTimeSensor", sensor.Sensor, cg.PollingComponent ) -BaseAction = duty_time_sensor_ns.class_("BaseAction", Action, cg.Parented) +BaseAction = duty_time_sensor_ns.class_("BaseAction", automation.Action, cg.Parented) StartAction = duty_time_sensor_ns.class_("StartAction", BaseAction) StopAction = duty_time_sensor_ns.class_("StopAction", BaseAction) ResetAction = duty_time_sensor_ns.class_("ResetAction", BaseAction) SetAction = duty_time_sensor_ns.class_("SetAction", BaseAction) RunningCondition = duty_time_sensor_ns.class_( - "RunningCondition", Condition, cg.Parented + "RunningCondition", automation.Condition, cg.Parented ) @@ -81,7 +80,7 @@ async def to_code(config: ConfigType) -> None: # AUTOMATIONS -DUTY_TIME_ID_SCHEMA = maybe_simple_id( +DUTY_TIME_ID_SCHEMA = automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(DutyTimeSensor), } @@ -112,7 +111,7 @@ automation.register_parented_action( ) -@register_condition( +@automation.register_condition( "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) async def duty_time_is_running_to_code( @@ -125,7 +124,7 @@ async def duty_time_is_running_to_code( return cg.new_Pvariable(condition_id, template_arg, paren, True) -@register_condition( +@automation.register_condition( "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) async def duty_time_is_not_running_to_code( From 9a9169305e532d91aaca166655dad3515bd0974c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:31:34 +0100 Subject: [PATCH 05/94] [haier] Use register_apply_action for the thirteen climate.haier actions (#19624) --- esphome/components/haier/automation.h | 128 -------------- esphome/components/haier/climate.py | 165 ++++-------------- tests/components/haier/common-actions.yaml | 21 +++ .../haier/test-actions.esp8266-ard.yaml | 8 + 4 files changed, 63 insertions(+), 259 deletions(-) delete mode 100644 esphome/components/haier/automation.h create mode 100644 tests/components/haier/common-actions.yaml create mode 100644 tests/components/haier/test-actions.esp8266-ard.yaml diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h deleted file mode 100644 index a81fd4bdb7d..00000000000 --- a/esphome/components/haier/automation.h +++ /dev/null @@ -1,128 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "haier_base.h" -#include "hon_climate.h" - -namespace esphome::haier { - -template class DisplayOnAction final : public Action { - public: - DisplayOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_display_state(true); } - - protected: - HaierClimateBase *parent_; -}; - -template class DisplayOffAction final : public Action { - public: - DisplayOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_display_state(false); } - - protected: - HaierClimateBase *parent_; -}; - -template class BeeperOnAction final : public Action { - public: - BeeperOnAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_beeper_state(true); } - - protected: - HonClimate *parent_; -}; - -template class BeeperOffAction final : public Action { - public: - BeeperOffAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_beeper_state(false); } - - protected: - HonClimate *parent_; -}; - -template class VerticalAirflowAction final : public Action { - public: - VerticalAirflowAction(HonClimate *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(hon_protocol::VerticalSwingMode, direction) - void play(const Ts &...x) { this->parent_->set_vertical_airflow(this->direction_.value(x...)); } - - protected: - HonClimate *parent_; -}; - -template class HorizontalAirflowAction final : public Action { - public: - HorizontalAirflowAction(HonClimate *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(hon_protocol::HorizontalSwingMode, direction) - void play(const Ts &...x) { this->parent_->set_horizontal_airflow(this->direction_.value(x...)); } - - protected: - HonClimate *parent_; -}; - -template class HealthOnAction final : public Action { - public: - HealthOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_health_mode(true); } - - protected: - HaierClimateBase *parent_; -}; - -template class HealthOffAction final : public Action { - public: - HealthOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_health_mode(false); } - - protected: - HaierClimateBase *parent_; -}; - -template class StartSelfCleaningAction final : public Action { - public: - StartSelfCleaningAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->start_self_cleaning(); } - - protected: - HonClimate *parent_; -}; - -template class StartSteriCleaningAction final : public Action { - public: - StartSteriCleaningAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->start_steri_cleaning(); } - - protected: - HonClimate *parent_; -}; - -template class PowerOnAction final : public Action { - public: - PowerOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->send_power_on_command(); } - - protected: - HaierClimateBase *parent_; -}; - -template class PowerOffAction final : public Action { - public: - PowerOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->send_power_off_command(); } - - protected: - HaierClimateBase *parent_; -}; - -template class PowerToggleAction final : public Action { - public: - PowerToggleAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->toggle_power(); } - - protected: - HaierClimateBase *parent_; -}; - -} // namespace esphome::haier diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 5a87ec1557a..5c4d498912d 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_BEEPER, CONF_CURRENT_TEMPERATURE, CONF_DISPLAY, - CONF_ID, CONF_LEVEL, CONF_LOGGER, CONF_LOGS, @@ -254,23 +253,6 @@ CONFIG_SCHEMA = cv.All( ) -# Actions -DisplayOnAction = haier_ns.class_("DisplayOnAction", automation.Action) -DisplayOffAction = haier_ns.class_("DisplayOffAction", automation.Action) -BeeperOnAction = haier_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = haier_ns.class_("BeeperOffAction", automation.Action) -StartSelfCleaningAction = haier_ns.class_("StartSelfCleaningAction", automation.Action) -StartSteriCleaningAction = haier_ns.class_( - "StartSteriCleaningAction", automation.Action -) -VerticalAirflowAction = haier_ns.class_("VerticalAirflowAction", automation.Action) -HorizontalAirflowAction = haier_ns.class_("HorizontalAirflowAction", automation.Action) -HealthOnAction = haier_ns.class_("HealthOnAction", automation.Action) -HealthOffAction = haier_ns.class_("HealthOffAction", automation.Action) -PowerOnAction = haier_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = haier_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = haier_ns.class_("PowerToggleAction", automation.Action) - HAIER_BASE_ACTION_SCHEMA = automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(HaierClimateBase), @@ -284,59 +266,35 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_simple_action( - "climate.haier.display_on", - DisplayOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) +for _name, _schema, _call in ( + ("climate.haier.display_on", HAIER_BASE_ACTION_SCHEMA, "set_display_state(true)"), + ("climate.haier.display_off", HAIER_BASE_ACTION_SCHEMA, "set_display_state(false)"), + ("climate.haier.beeper_on", HAIER_HON_BASE_ACTION_SCHEMA, "set_beeper_state(true)"), + ( + "climate.haier.beeper_off", + HAIER_HON_BASE_ACTION_SCHEMA, + "set_beeper_state(false)", + ), + ( + "climate.haier.start_self_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_self_cleaning()", + ), + ( + "climate.haier.start_steri_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_steri_cleaning()", + ), + ("climate.haier.health_on", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(true)"), + ("climate.haier.health_off", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(false)"), + ("climate.haier.power_on", HAIER_BASE_ACTION_SCHEMA, "send_power_on_command()"), + ("climate.haier.power_off", HAIER_BASE_ACTION_SCHEMA, "send_power_off_command()"), + ("climate.haier.power_toggle", HAIER_BASE_ACTION_SCHEMA, "toggle_power()"), +): + automation.register_apply_action(_name, _schema, automation.ApplyCall(_call)) - -automation.register_simple_action( - "climate.haier.display_off", - DisplayOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.beeper_on", - BeeperOnAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.beeper_off", - BeeperOffAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -# Start self cleaning or steri-cleaning action action -automation.register_simple_action( - "climate.haier.start_self_cleaning", - StartSelfCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.start_steri_cleaning", - StartSteriCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -# Set vertical airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_vertical_airflow", - VerticalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -345,22 +303,13 @@ automation.register_simple_action( ), } ), - synchronous=True, + automation.ApplyField( + CONF_VERTICAL_AIRFLOW, "set_vertical_airflow", AirflowVerticalDirection + ), ) -async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_VERTICAL_AIRFLOW], args, AirflowVerticalDirection - ) - cg.add(var.set_direction(template_)) - return var - -# Set horizontal airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_horizontal_airflow", - HorizontalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -369,55 +318,9 @@ async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, ar ), } ), - synchronous=True, -) -async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_HORIZONTAL_AIRFLOW], args, AirflowHorizontalDirection - ) - cg.add(var.set_direction(template_)) - return var - - -automation.register_simple_action( - "climate.haier.health_on", - HealthOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.health_off", - HealthOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.power_on", - PowerOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.power_off", - PowerOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.power_toggle", - PowerToggleAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + CONF_HORIZONTAL_AIRFLOW, "set_horizontal_airflow", AirflowHorizontalDirection + ), ) diff --git a/tests/components/haier/common-actions.yaml b/tests/components/haier/common-actions.yaml new file mode 100644 index 00000000000..11b0902d79b --- /dev/null +++ b/tests/components/haier/common-actions.yaml @@ -0,0 +1,21 @@ +button: + - platform: template + name: Haier Actions + on_press: + - climate.haier.display_on: haier_ac + - climate.haier.display_off: haier_ac + - climate.haier.beeper_on: haier_ac + - climate.haier.beeper_off: haier_ac + - climate.haier.start_self_cleaning: haier_ac + - climate.haier.start_steri_cleaning: haier_ac + - climate.haier.health_on: haier_ac + - climate.haier.health_off: haier_ac + - climate.haier.power_on: haier_ac + - climate.haier.power_off: haier_ac + - climate.haier.power_toggle: haier_ac + - climate.haier.set_vertical_airflow: + id: haier_ac + vertical_airflow: UP + - climate.haier.set_horizontal_airflow: + id: haier_ac + horizontal_airflow: !lambda return esphome::haier::hon_protocol::HorizontalSwingMode::LEFT; diff --git a/tests/components/haier/test-actions.esp8266-ard.yaml b/tests/components/haier/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..079cc9f6393 --- /dev/null +++ b/tests/components/haier/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + haier: !include common.yaml + actions: !include common-actions.yaml From 73548b0f15797b06e0b23d0f6f24d56eccbd602a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:32:16 +0100 Subject: [PATCH 06/94] [midea] Use register_apply_action for the eight midea_ac actions (#19625) --- esphome/components/midea/ac_automations.h | 66 --------- esphome/components/midea/climate.py | 130 ++++-------------- tests/components/midea/common-actions.yaml | 26 ++++ .../midea/test-actions.esp8266-ard.yaml | 9 ++ 4 files changed, 59 insertions(+), 172 deletions(-) delete mode 100644 esphome/components/midea/ac_automations.h create mode 100644 tests/components/midea/common-actions.yaml create mode 100644 tests/components/midea/test-actions.esp8266-ard.yaml diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h deleted file mode 100644 index 9572ec6c650..00000000000 --- a/esphome/components/midea/ac_automations.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) - -#include "esphome/core/automation.h" -#include "air_conditioner.h" - -namespace esphome::midea::ac { - -template class MideaActionBase : public Action { - public: - void set_parent(AirConditioner *parent) { this->parent_ = parent; } - - protected: - AirConditioner *parent_; -}; - -template class FollowMeAction : public MideaActionBase { - TEMPLATABLE_VALUE(float, temperature) - TEMPLATABLE_VALUE(bool, use_fahrenheit) - TEMPLATABLE_VALUE(bool, beeper) - - void play(const Ts &...x) override { - this->parent_->do_follow_me(this->temperature_.value(x...), this->use_fahrenheit_.value(x...), - this->beeper_.value(x...)); - } -}; - -template class SwingStepAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_swing_step(); } -}; - -template class DisplayToggleAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_display_toggle(); } -}; - -template class BeeperOnAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_beeper_on(); } -}; - -template class BeeperOffAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_beeper_off(); } -}; - -template class PowerOnAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_on(); } -}; - -template class PowerOffAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_off(); } -}; - -template class PowerToggleAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_toggle(); } -}; - -} // namespace esphome::midea::ac - -#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 07ad02d3afc..e7a684babea 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT, ) -from esphome.core import CORE, coroutine +from esphome.core import CORE CODEOWNERS = ["@dudanov"] DEPENDENCIES = ["climate", "uart"] @@ -53,25 +53,6 @@ def templatize(value): return cv.Schema(ret) -def register_action(name, type_, schema): - validator = templatize(schema).extend(MIDEA_ACTION_BASE_SCHEMA) - registerer = automation.register_action( - f"midea_ac.{name}", type_, validator, synchronous=True - ) - - def decorator(func): - async def new_func(config, action_id, template_arg, args): - ac_ = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(ac_)) - await coroutine(func)(var, config, args) - return var - - return registerer(new_func) - - return decorator - - ALLOWED_CLIMATE_MODES = { "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, "COOL": ClimateMode.CLIMATE_MODE_COOL, @@ -161,16 +142,6 @@ CONFIG_SCHEMA = cv.All( ), ) -# Actions -FollowMeAction = midea_ac_ns.class_("FollowMeAction", automation.Action) -DisplayToggleAction = midea_ac_ns.class_("DisplayToggleAction", automation.Action) -SwingStepAction = midea_ac_ns.class_("SwingStepAction", automation.Action) -BeeperOnAction = midea_ac_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = midea_ac_ns.class_("BeeperOffAction", automation.Action) -PowerOnAction = midea_ac_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = midea_ac_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = midea_ac_ns.class_("PowerToggleAction", automation.Action) - MIDEA_ACTION_BASE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(AirConditioner), @@ -187,84 +158,31 @@ MIDEA_FOLLOW_ME_SCHEMA = cv.Schema( ) -@register_action("follow_me", FollowMeAction, MIDEA_FOLLOW_ME_SCHEMA) -async def follow_me_to_code(var, config, args): - template_ = await cg.templatable(config[CONF_BEEPER], args, cg.bool_) - cg.add(var.set_beeper(template_)) - template_ = await cg.templatable(config[CONF_USE_FAHRENHEIT], args, cg.bool_) - cg.add(var.set_use_fahrenheit(template_)) - template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_temperature(template_)) - - -# Toggle Display action -@register_action( - "display_toggle", - DisplayToggleAction, - cv.Schema({}), +automation.register_apply_action( + "midea_ac.follow_me", + templatize(MIDEA_FOLLOW_ME_SCHEMA).extend(MIDEA_ACTION_BASE_SCHEMA), + automation.ApplyCall( + "do_follow_me({}, {}, {})", + ( + (CONF_TEMPERATURE, cg.float_), + (CONF_USE_FAHRENHEIT, cg.bool_), + (CONF_BEEPER, cg.bool_), + ), + ), ) -async def display_toggle_to_code(var, config, args): - pass - -# Swing Step action -@register_action( - "swing_step", - SwingStepAction, - cv.Schema({}), -) -async def swing_step_to_code(var, config, args): - pass - - -# Beeper On action -@register_action( - "beeper_on", - BeeperOnAction, - cv.Schema({}), -) -async def beeper_on_to_code(var, config, args): - pass - - -# Beeper Off action -@register_action( - "beeper_off", - BeeperOffAction, - cv.Schema({}), -) -async def beeper_off_to_code(var, config, args): - pass - - -# Power On action -@register_action( - "power_on", - PowerOnAction, - cv.Schema({}), -) -async def power_on_to_code(var, config, args): - pass - - -# Power Off action -@register_action( - "power_off", - PowerOffAction, - cv.Schema({}), -) -async def power_off_to_code(var, config, args): - pass - - -# Power Toggle action -@register_action( - "power_toggle", - PowerToggleAction, - cv.Schema({}), -) -async def power_inv_to_code(var, config, args): - pass +for _name, _call in ( + ("midea_ac.display_toggle", "do_display_toggle()"), + ("midea_ac.swing_step", "do_swing_step()"), + ("midea_ac.beeper_on", "do_beeper_on()"), + ("midea_ac.beeper_off", "do_beeper_off()"), + ("midea_ac.power_on", "do_power_on()"), + ("midea_ac.power_off", "do_power_off()"), + ("midea_ac.power_toggle", "do_power_toggle()"), +): + automation.register_apply_action( + _name, MIDEA_ACTION_BASE_SCHEMA, automation.ApplyCall(_call) + ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( diff --git a/tests/components/midea/common-actions.yaml b/tests/components/midea/common-actions.yaml new file mode 100644 index 00000000000..adda7ce3d9b --- /dev/null +++ b/tests/components/midea/common-actions.yaml @@ -0,0 +1,26 @@ +button: + - platform: template + name: Midea Actions + on_press: + - midea_ac.follow_me: + id: midea_unit + temperature: 23.0 + beeper: true + - midea_ac.follow_me: + id: midea_unit + temperature: !lambda return id(midea_unit).target_temperature + 1.0f; + use_fahrenheit: !lambda return false; + - midea_ac.display_toggle: + id: midea_unit + - midea_ac.swing_step: + id: midea_unit + - midea_ac.beeper_on: + id: midea_unit + - midea_ac.beeper_off: + id: midea_unit + - midea_ac.power_on: + id: midea_unit + - midea_ac.power_off: + id: midea_unit + - midea_ac.power_toggle: + id: midea_unit diff --git a/tests/components/midea/test-actions.esp8266-ard.yaml b/tests/components/midea/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..dcb7f75b9ed --- /dev/null +++ b/tests/components/midea/test-actions.esp8266-ard.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea: !include common.yaml + actions: !include common-actions.yaml + +wifi: + ssid: MySSID + password: password1 From c598d2dee58378c198639feb510030f5074a515e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:32:46 +0100 Subject: [PATCH 07/94] [display_menu_base] Use the apply helpers for the eight display_menu actions and is_active (#19626) --- .../components/display_menu_base/__init__.py | 98 +++---------------- .../components/display_menu_base/automation.h | 88 ----------------- tests/components/lcd_menu/common-actions.yaml | 17 ++++ .../lcd_menu/test-actions.esp8266-ard.yaml | 11 +++ 4 files changed, 43 insertions(+), 171 deletions(-) create mode 100644 tests/components/lcd_menu/common-actions.yaml create mode 100644 tests/components/lcd_menu/test-actions.esp8266-ard.yaml diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index a5204cfe2fc..bb493ae8d33 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -55,18 +55,6 @@ MenuItemSwitch = display_menu_base_ns.class_("MenuItemSwitch") MenuItemCommand = display_menu_base_ns.class_("MenuItemCommand") MenuItemCustom = display_menu_base_ns.class_("MenuItemCustom") -UpAction = display_menu_base_ns.class_("UpAction", automation.Action) -DownAction = display_menu_base_ns.class_("DownAction", automation.Action) -LeftAction = display_menu_base_ns.class_("LeftAction", automation.Action) -RightAction = display_menu_base_ns.class_("RightAction", automation.Action) -EnterAction = display_menu_base_ns.class_("EnterAction", automation.Action) -ShowAction = display_menu_base_ns.class_("ShowAction", automation.Action) -HideAction = display_menu_base_ns.class_("HideAction", automation.Action) -ShowMainAction = display_menu_base_ns.class_("ShowMainAction", automation.Action) - -IsActiveCondition = display_menu_base_ns.class_( - "IsActiveCondition", automation.Condition -) MenuItemType = display_menu_base_ns.enum("MenuItemType") @@ -294,78 +282,22 @@ MENU_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "display_menu.up", - UpAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("display_menu.up", "up()"), + ("display_menu.down", "down()"), + ("display_menu.left", "left()"), + ("display_menu.right", "right()"), + ("display_menu.enter", "enter()"), + ("display_menu.show", "show()"), + ("display_menu.hide", "hide()"), + ("display_menu.show_main", "show_main()"), +): + automation.register_apply_action( + _name, MENU_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -automation.register_simple_action( - "display_menu.down", - DownAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.left", - LeftAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.right", - RightAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.enter", - EnterAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.show", - ShowAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.hide", - HideAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.show_main", - ShowMainAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_condition( - "display_menu.is_active", - IsActiveCondition, - automation.maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.use_id(DisplayMenuComponent), - } - ), +automation.register_apply_condition( + "display_menu.is_active", MENU_ACTION_SCHEMA, "is_active()" ) diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index be0044ffa4d..477a971d479 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -5,94 +5,6 @@ namespace esphome::display_menu_base { -template class UpAction final : public Action { - public: - explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->up(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class DownAction final : public Action { - public: - explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->down(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class LeftAction final : public Action { - public: - explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->left(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class RightAction final : public Action { - public: - explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->right(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class EnterAction final : public Action { - public: - explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->enter(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class ShowAction final : public Action { - public: - explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->show(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class HideAction final : public Action { - public: - explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->hide(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class ShowMainAction final : public Action { - public: - explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->show_main(); } - - protected: - DisplayMenuComponent *menu_; -}; -template class IsActiveCondition final : public Condition { - public: - explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} - bool check(const Ts &...x) override { return this->menu_->is_active(); } - - protected: - DisplayMenuComponent *menu_; -}; - class DisplayMenuOnEnterTrigger final : public Trigger { public: explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { diff --git a/tests/components/lcd_menu/common-actions.yaml b/tests/components/lcd_menu/common-actions.yaml new file mode 100644 index 00000000000..9c697a7a363 --- /dev/null +++ b/tests/components/lcd_menu/common-actions.yaml @@ -0,0 +1,17 @@ +button: + - platform: template + name: Menu Actions + on_press: + - display_menu.show: test_lcd_menu + - display_menu.up: test_lcd_menu + - display_menu.down: test_lcd_menu + - display_menu.left: test_lcd_menu + - display_menu.right: test_lcd_menu + - display_menu.enter: test_lcd_menu + - if: + condition: + display_menu.is_active: test_lcd_menu + then: + - display_menu.hide: test_lcd_menu + else: + - display_menu.show_main: test_lcd_menu diff --git a/tests/components/lcd_menu/test-actions.esp8266-ard.yaml b/tests/components/lcd_menu/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..49b48563c3a --- /dev/null +++ b/tests/components/lcd_menu/test-actions.esp8266-ard.yaml @@ -0,0 +1,11 @@ +substitutions: + d0_pin: GPIO0 + d1_pin: GPIO2 + d2_pin: GPIO14 + d3_pin: GPIO15 + enable_pin: GPIO16 + rs_pin: GPIO5 + +packages: + lcd_menu: !include common.yaml + actions: !include common-actions.yaml From 96d0d677b66536cb002f0f615658a97c809bcdcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:33:35 +0100 Subject: [PATCH 08/94] [alarm_control_panel] Use register_apply_action for pending and triggered, drop the chime and ready actions that had no class (#19627) --- .../alarm_control_panel/__init__.py | 44 +++++-------------- .../alarm_control_panel/automation.h | 20 --------- .../alarm_control_panel/common-actions.yaml | 6 +++ .../test-actions.esp8266-ard.yaml | 3 ++ 4 files changed, 19 insertions(+), 54 deletions(-) create mode 100644 tests/components/alarm_control_panel/common-actions.yaml create mode 100644 tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b0fea438017..37b27b5391e 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -41,10 +41,6 @@ StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder") StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -PendingAction = alarm_control_panel_ns.class_("PendingAction", automation.Action) -TriggeredAction = alarm_control_panel_ns.class_("TriggeredAction", automation.Action) -ChimeAction = alarm_control_panel_ns.class_("ChimeAction", automation.Action) -ReadyAction = alarm_control_panel_ns.class_("ReadyAction", automation.Action) AlarmControlPanelCondition = alarm_control_panel_ns.class_( "AlarmControlPanelCondition", automation.Condition @@ -208,36 +204,16 @@ for _name, _arm in ( ) -automation.register_simple_action( - "alarm_control_panel.pending", - PendingAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "alarm_control_panel.triggered", - TriggeredAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "alarm_control_panel.chime", - ChimeAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "alarm_control_panel.ready", - ReadyAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("alarm_control_panel.pending", "pending()"), + ("alarm_control_panel.triggered", "triggered()"), +): + automation.register_apply_action( + _name, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + automation.ApplyCall(_call), + call="make_call", + ) automation.register_simple_condition( diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index cd9b5eadced..7a683086a46 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,26 +27,6 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class PendingAction final : public Action { - public: - explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - void play(const Ts &...x) override { this->alarm_control_panel_->make_call().pending().perform(); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class TriggeredAction final : public Action { - public: - explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - void play(const Ts &...x) override { this->alarm_control_panel_->make_call().triggered().perform(); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - template class AlarmControlPanelCondition final : public Condition { public: AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} diff --git a/tests/components/alarm_control_panel/common-actions.yaml b/tests/components/alarm_control_panel/common-actions.yaml new file mode 100644 index 00000000000..1ba7342c891 --- /dev/null +++ b/tests/components/alarm_control_panel/common-actions.yaml @@ -0,0 +1,6 @@ +button: + - platform: template + name: Alarm Calls + on_press: + - alarm_control_panel.pending: alarmcontrolpanel1 + - alarm_control_panel.triggered: alarmcontrolpanel1 diff --git a/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml b/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..ce3259f91fc --- /dev/null +++ b/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + alarm_control_panel: !include common.yaml + actions: !include common-actions.yaml From 7294da9f490d89bebe0f11651f25dfa44ac7508e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:34:39 +0100 Subject: [PATCH 09/94] [integration] Use register_apply_action for sensor.integration.reset and set_value (#19628) --- .../integration/integration_sensor.h | 12 --------- esphome/components/integration/sensor.py | 26 +++---------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 019c3ee0740..22d664cd555 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -71,16 +71,4 @@ class IntegrationSensor final : public sensor::Sensor, public Component { float last_value_{0.0f}; }; -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetValueAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { this->parent_->set_value(this->value_.value(x...)); } -}; - } // namespace esphome::integration diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 672af46e3e3..a68cb575192 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,17 +11,13 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, ) -from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType integration_ns = cg.esphome_ns.namespace("integration") IntegrationSensor = integration_ns.class_( "IntegrationSensor", sensor.Sensor, cg.Component ) -ResetAction = integration_ns.class_("ResetAction", automation.Action) -SetValueAction = integration_ns.class_("SetValueAction", automation.Action) IntegrationSensorTime = integration_ns.enum("IntegrationSensorTime") INTEGRATION_TIMES = { @@ -106,37 +102,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_restore(config[CONF_RESTORE])) -automation.register_parented_action( +automation.register_apply_action( "sensor.integration.reset", - ResetAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), } ), - synchronous=True, + automation.ApplyCall("reset()"), ) - -@automation.register_action( +automation.register_apply_action( "sensor.integration.set_value", - SetValueAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), ) -async def sensor_integration_set_value_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_value(template_)) - return var From c561c6bbe8c9c5959edec59e8b9858fd8795d618 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:02:58 +0100 Subject: [PATCH 10/94] [scd4x] Use register_apply_action for perform_forced_calibration and factory_reset (#19630) --- esphome/components/scd4x/automation.h | 31 ------------------- esphome/components/scd4x/sensor.py | 29 +++-------------- tests/components/scd4x/common-actions.yaml | 11 +++++++ .../scd4x/test-actions.esp8266-ard.yaml | 4 +++ 4 files changed, 19 insertions(+), 56 deletions(-) delete mode 100644 esphome/components/scd4x/automation.h create mode 100644 tests/components/scd4x/common-actions.yaml create mode 100644 tests/components/scd4x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h deleted file mode 100644 index e0cc04e2cb7..00000000000 --- a/esphome/components/scd4x/automation.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd4x.h" - -namespace esphome::scd4x { - -template -class PerformForcedCalibrationAction final : public Action, public Parented { - public: - // User provided, not "= default": `new(p) PerformForcedCalibrationAction()` would zero-fill .bss that is already - // zero. - PerformForcedCalibrationAction() {} - - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->perform_forced_calibration(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::scd4x diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index fcae9e670ed..71be6e43b16 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -26,8 +26,6 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@sjtrny", "@martgras"] @@ -47,12 +45,6 @@ MEASUREMENT_MODE_OPTIONS = { } -# Actions -PerformForcedCalibrationAction = scd4x_ns.class_( - "PerformForcedCalibrationAction", automation.Action -) -FactoryResetAction = scd4x_ns.class_("FactoryResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -140,23 +132,11 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "scd4x.perform_forced_calibration", - PerformForcedCalibrationAction, SCD4X_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VALUE, "perform_forced_calibration", cg.uint16), ) -async def scd4x_frc_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) - cg.add(var.set_value(template_)) - return var SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( @@ -166,9 +146,8 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( +automation.register_apply_action( "scd4x.factory_reset", - FactoryResetAction, SCD4X_RESET_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("factory_reset()"), ) diff --git a/tests/components/scd4x/common-actions.yaml b/tests/components/scd4x/common-actions.yaml new file mode 100644 index 00000000000..20c30e2cada --- /dev/null +++ b/tests/components/scd4x/common-actions.yaml @@ -0,0 +1,11 @@ +button: + - platform: template + name: SCD4x Actions + on_press: + - scd4x.perform_forced_calibration: + id: scd40 + value: 419 + - scd4x.perform_forced_calibration: + id: scd40 + value: !lambda return 420; + - scd4x.factory_reset: scd40 diff --git a/tests/components/scd4x/test-actions.esp8266-ard.yaml b/tests/components/scd4x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c5bf817cb86 --- /dev/null +++ b/tests/components/scd4x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + scd4x: !include common.yaml + actions: !include common-actions.yaml From eb85778a6044932aaafe321118bdff1f291d4551 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:07:15 +0100 Subject: [PATCH 11/94] [mhz19] Use register_apply_action for the four mhz19 actions (#19631) --- esphome/components/mhz19/mhz19.h | 24 ------- esphome/components/mhz19/sensor.py | 64 +++---------------- tests/components/mhz19/common-actions.yaml | 10 +++ tests/components/mhz19/common.yaml | 1 + .../mhz19/test-actions.esp8266-ard.yaml | 8 +++ 5 files changed, 29 insertions(+), 78 deletions(-) create mode 100644 tests/components/mhz19/common-actions.yaml create mode 100644 tests/components/mhz19/test-actions.esp8266-ard.yaml diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index 3cef3a3930d..0aacdda8886 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -49,27 +48,4 @@ class MHZ19Component final : public PollingComponent, public uart::UARTDevice { MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; }; -template class MHZ19CalibrateZeroAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_zero(); } -}; - -template class MHZ19ABCEnableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->abc_enable(); } -}; - -template class MHZ19ABCDisableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->abc_disable(); } -}; - -template -class MHZ19DetectionRangeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(MHZ19DetectionRange, detection_range) - - void play(const Ts &...x) override { this->parent_->range_set(this->detection_range_.value(x...)); } -}; - } // namespace esphome::mhz19 diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 4434eddb08d..9b0eeeb28ea 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -15,8 +15,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -26,21 +24,6 @@ CONF_DETECTION_RANGE = "detection_range" mhz19_ns = cg.esphome_ns.namespace("mhz19") MHZ19Component = mhz19_ns.class_("MHZ19Component", cg.PollingComponent, uart.UARTDevice) -MHZ19CalibrateZeroAction = mhz19_ns.class_( - "MHZ19CalibrateZeroAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19ABCEnableAction = mhz19_ns.class_( - "MHZ19ABCEnableAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19ABCDisableAction = mhz19_ns.class_( - "MHZ19ABCDisableAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19DetectionRangeSetAction = mhz19_ns.class_( - "MHZ19DetectionRangeSetAction", - automation.Action, - cg.Parented.template(MHZ19Component), -) - mhz19_detection_range = mhz19_ns.enum("MHZ19DetectionRange") MHZ19_DETECTION_RANGE_ENUM = { 2000: mhz19_detection_range.MHZ19_DETECTION_RANGE_0_2000PPM, @@ -122,28 +105,14 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( - "mhz19.calibrate_zero", - MHZ19CalibrateZeroAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "mhz19.abc_enable", - MHZ19ABCEnableAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "mhz19.abc_disable", - MHZ19ABCDisableAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("mhz19.calibrate_zero", "calibrate_zero()"), + ("mhz19.abc_enable", "abc_enable()"), + ("mhz19.abc_disable", "abc_disable()"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) RANGE_ACTION_SCHEMA = maybe_simple_id( @@ -156,21 +125,8 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "mhz19.detection_range_set", - MHZ19DetectionRangeSetAction, RANGE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_DETECTION_RANGE, "range_set", mhz19_detection_range), ) -async def mhz19_detection_range_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - detection_range = config.get(CONF_DETECTION_RANGE) - template_ = await cg.templatable(detection_range, args, mhz19_detection_range) - cg.add(var.set_detection_range(template_)) - return var diff --git a/tests/components/mhz19/common-actions.yaml b/tests/components/mhz19/common-actions.yaml new file mode 100644 index 00000000000..2213019da72 --- /dev/null +++ b/tests/components/mhz19/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: MH-Z19 Actions + on_press: + - mhz19.calibrate_zero: mhz19_sensor + - mhz19.abc_enable: mhz19_sensor + - mhz19.abc_disable: mhz19_sensor + - mhz19.detection_range_set: + id: mhz19_sensor + detection_range: 2000ppm diff --git a/tests/components/mhz19/common.yaml b/tests/components/mhz19/common.yaml index b12ca501975..7f5e13b5650 100644 --- a/tests/components/mhz19/common.yaml +++ b/tests/components/mhz19/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: mhz19 + id: mhz19_sensor co2: name: MH-Z19 CO2 Value temperature: diff --git a/tests/components/mhz19/test-actions.esp8266-ard.yaml b/tests/components/mhz19/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..b3e895ffc86 --- /dev/null +++ b/tests/components/mhz19/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + mhz19: !include common.yaml + actions: !include common-actions.yaml From 4bb6c0f877ea03d675f18d938f68d86ff9bb836a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:07:48 +0100 Subject: [PATCH 12/94] [update] Use the apply helpers for update.check and update.is_available (#19632) --- esphome/components/update/__init__.py | 34 ++++++++------------------ esphome/components/update/automation.h | 19 -------------- 2 files changed, 10 insertions(+), 43 deletions(-) delete mode 100644 esphome/components/update/automation.h diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 87707159893..1533a893ba6 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -32,13 +32,6 @@ UpdateEntity = update_ns.class_("UpdateEntity", cg.EntityBase) UpdateInfo = update_ns.struct("UpdateInfo") -CheckAction = update_ns.class_( - "CheckAction", automation.Action, cg.Parented.template(UpdateEntity) -) -IsAvailableCondition = update_ns.class_( - "IsAvailableCondition", automation.Condition, cg.Parented.template(UpdateEntity) -) - DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_FIRMWARE, @@ -144,24 +137,17 @@ automation.register_apply_action( ) -automation.register_parented_action( - "update.check", - CheckAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(UpdateEntity), - } - ), - synchronous=True, +UPDATE_AUTOMATION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(UpdateEntity), + } ) - -automation.register_parented_condition( +automation.register_apply_action( + "update.check", UPDATE_AUTOMATION_SCHEMA, automation.ApplyCall("check()") +) +automation.register_apply_condition( "update.is_available", - IsAvailableCondition, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(UpdateEntity), - } - ), + UPDATE_AUTOMATION_SCHEMA, + "state == update::UPDATE_STATE_AVAILABLE", ) diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h deleted file mode 100644 index 3d303fdb639..00000000000 --- a/esphome/components/update/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "update_entity.h" - -#include "esphome/core/automation.h" - -namespace esphome::update { - -template class CheckAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->check(); } -}; - -template class IsAvailableCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } -}; - -} // namespace esphome::update From c73a71e037355c8b69a8b0a0e0dfa04a32706037 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:08:18 +0100 Subject: [PATCH 13/94] [animation] Use register_apply_action for next_frame, prev_frame and set_frame (#19633) --- esphome/components/animation/animation.h | 30 -------------- esphome/components/animation/image.py | 40 ++++--------------- .../components/animation/common-actions.yaml | 9 +++++ .../animation/test-actions.esp8266-ard.yaml | 14 +++++++ 4 files changed, 31 insertions(+), 62 deletions(-) create mode 100644 tests/components/animation/common-actions.yaml create mode 100644 tests/components/animation/test-actions.esp8266-ard.yaml diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index 64cddbf09c9..4ee060c1213 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -1,8 +1,6 @@ #pragma once #include "esphome/components/image/image.h" -#include "esphome/core/automation.h" - namespace esphome::animation { class Animation final : public image::Image { @@ -35,32 +33,4 @@ class Animation final : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction final : public Action { - public: - AnimationNextFrameAction(Animation *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->next_frame(); } - - protected: - Animation *parent_; -}; - -template class AnimationPrevFrameAction final : public Action { - public: - AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->prev_frame(); } - - protected: - Animation *parent_; -}; - -template class AnimationSetFrameAction final : public Action { - public: - AnimationSetFrameAction(Animation *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, frame) - void play(const Ts &...x) override { this->parent_->set_frame(this->frame_.value(x...)); } - - protected: - Animation *parent_; -}; - } // namespace esphome::animation diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 0265a350f71..09e0e8753cf 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -6,8 +6,6 @@ from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] @@ -26,17 +24,6 @@ animation_ns = cg.esphome_ns.namespace("animation") Animation_ = animation_ns.class_("Animation", Image_) -# Actions -NextFrameAction = animation_ns.class_( - "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) -) -PrevFrameAction = animation_ns.class_( - "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) -) -SetFrameAction = animation_ns.class_( - "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) -) - ANIMATION_SCHEMA = image_schema(Animation_).extend( { cv.Optional(CONF_LOOP): cv.All( @@ -72,28 +59,17 @@ SET_FRAME_SCHEMA = cv.Schema( ) -@automation.register_action( - "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.next_frame", NEXT_FRAME_SCHEMA, automation.ApplyCall("next_frame()") ) -@automation.register_action( - "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.prev_frame", PREV_FRAME_SCHEMA, automation.ApplyCall("prev_frame()") ) -@automation.register_action( - "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.set_frame", + SET_FRAME_SCHEMA, + automation.ApplyField(CONF_FRAME, "set_frame", cg.uint16), ) -async def animation_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if (frame := config.get(CONF_FRAME)) is not None: - template_ = await cg.templatable(frame, args, cg.uint16) - cg.add(var.set_frame(template_)) - return var async def setup_animation(config: ConfigType) -> None: diff --git a/tests/components/animation/common-actions.yaml b/tests/components/animation/common-actions.yaml new file mode 100644 index 00000000000..45812374838 --- /dev/null +++ b/tests/components/animation/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: Animation Actions + on_press: + - animation.next_frame: rgb_animation + - animation.prev_frame: rgb_animation + - animation.set_frame: + id: rgb_animation + frame: 2 diff --git a/tests/components/animation/test-actions.esp8266-ard.yaml b/tests/components/animation/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..0d8a73f8bda --- /dev/null +++ b/tests/components/animation/test-actions.esp8266-ard.yaml @@ -0,0 +1,14 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + animation: !include common.yaml + actions: !include common-actions.yaml + +display: + - platform: ili9xxx + id: animation_main_lcd + spi_id: spi_bus + model: ili9342 + cs_pin: 5 + dc_pin: 15 + reset_pin: 16 + invert_colors: false From d6831c8c754d5c5495dc423311bbc457ef59665f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:13:46 +0100 Subject: [PATCH 14/94] [duty_time] Use the apply helpers for the start, stop, reset and is_running automations (#19634) --- .../components/duty_time/duty_time_sensor.h | 24 ------- esphome/components/duty_time/sensor.py | 69 ++++--------------- .../components/duty_time/common-actions.yaml | 15 ++++ tests/components/duty_time/common.yaml | 1 + .../duty_time/test-actions.esp8266-ard.yaml | 3 + 5 files changed, 32 insertions(+), 80 deletions(-) create mode 100644 tests/components/duty_time/common-actions.yaml create mode 100644 tests/components/duty_time/test-actions.esp8266-ard.yaml diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index a9e91de0b19..9b41ac7e0f6 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -2,7 +2,6 @@ #include -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" #include "esphome/components/sensor/sensor.h" @@ -47,27 +46,4 @@ class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { bool restore_; }; -template class BaseAction : public Action, public Parented {}; - -template class StartAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class ResetAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class RunningCondition final : public Condition, public Parented { - public: - explicit RunningCondition(DutyTimeSensor *parent, bool state) : Parented(parent), state_(state) {} - - protected: - bool check(const Ts &...x) override { return this->parent_->is_running() == this->state_; } - bool state_; -}; - } // namespace esphome::duty_time_sensor diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 4afcf0a0ce6..7fd01db8360 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -13,8 +13,6 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CONF_LAST_TIME = "last_time" @@ -23,14 +21,6 @@ duty_time_sensor_ns = cg.esphome_ns.namespace("duty_time_sensor") DutyTimeSensor = duty_time_sensor_ns.class_( "DutyTimeSensor", sensor.Sensor, cg.PollingComponent ) -BaseAction = duty_time_sensor_ns.class_("BaseAction", automation.Action, cg.Parented) -StartAction = duty_time_sensor_ns.class_("StartAction", BaseAction) -StopAction = duty_time_sensor_ns.class_("StopAction", BaseAction) -ResetAction = duty_time_sensor_ns.class_("ResetAction", BaseAction) -SetAction = duty_time_sensor_ns.class_("SetAction", BaseAction) -RunningCondition = duty_time_sensor_ns.class_( - "RunningCondition", automation.Condition, cg.Parented -) CONFIG_SCHEMA = cv.All( @@ -87,51 +77,18 @@ DUTY_TIME_ID_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sensor.duty_time.start", - StartAction, - DUTY_TIME_ID_SCHEMA, - synchronous=True, +for _name, _call in ( + ("sensor.duty_time.start", "start()"), + ("sensor.duty_time.stop", "stop()"), + ("sensor.duty_time.reset", "reset()"), +): + automation.register_apply_action( + _name, DUTY_TIME_ID_SCHEMA, automation.ApplyCall(_call) + ) + +automation.register_apply_condition( + "sensor.duty_time.is_running", DUTY_TIME_ID_SCHEMA, "is_running()" ) - - -automation.register_parented_action( - "sensor.duty_time.stop", - StopAction, - DUTY_TIME_ID_SCHEMA, - synchronous=True, +automation.register_apply_condition( + "sensor.duty_time.is_not_running", DUTY_TIME_ID_SCHEMA, "is_running() == false" ) - - -automation.register_parented_action( - "sensor.duty_time.reset", - ResetAction, - DUTY_TIME_ID_SCHEMA, - synchronous=True, -) - - -@automation.register_condition( - "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA -) -async def duty_time_is_running_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition( - "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA -) -async def duty_time_is_not_running_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) diff --git a/tests/components/duty_time/common-actions.yaml b/tests/components/duty_time/common-actions.yaml new file mode 100644 index 00000000000..18b1a7b6aac --- /dev/null +++ b/tests/components/duty_time/common-actions.yaml @@ -0,0 +1,15 @@ +button: + - platform: template + name: Duty Time Actions + on_press: + - if: + condition: + sensor.duty_time.is_running: pump_duty_time + then: + - sensor.duty_time.stop: pump_duty_time + - if: + condition: + sensor.duty_time.is_not_running: pump_duty_time + then: + - sensor.duty_time.start: pump_duty_time + - sensor.duty_time.reset: pump_duty_time diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 12e4397c491..a2a710e2d95 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -9,5 +9,6 @@ binary_sensor: sensor: - platform: duty_time + id: pump_duty_time name: Duty Time sensor: duty_time_bin1 diff --git a/tests/components/duty_time/test-actions.esp8266-ard.yaml b/tests/components/duty_time/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..bc514ad4c96 --- /dev/null +++ b/tests/components/duty_time/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + duty_time: !include common.yaml + actions: !include common-actions.yaml From bdb492c196a15a907b2d801ca6257843fa6c07a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:15:13 +0100 Subject: [PATCH 15/94] [sx127x] Use register_apply_action for the five mode and calibration actions (#19636) --- esphome/components/sx127x/__init__.py | 63 ++++---------------------- esphome/components/sx127x/automation.h | 25 ---------- 2 files changed, 10 insertions(+), 78 deletions(-) diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 5701f2acb7c..9f95c618445 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -120,24 +120,9 @@ SHAPING = { "NONE": SX127xPaRamp.SHAPING_NONE, } -RunImageCalAction = sx127x_ns.class_( - "RunImageCalAction", automation.Action, cg.Parented.template(SX127x) -) SendPacketAction = sx127x_ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(SX127x) ) -SetModeTxAction = sx127x_ns.class_( - "SetModeTxAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeRxAction = sx127x_ns.class_( - "SetModeRxAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeSleepAction = sx127x_ns.class_( - "SetModeSleepAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeStandbyAction = sx127x_ns.class_( - "SetModeStandbyAction", automation.Action, cg.Parented.template(SX127x) -) def validate_raw_data(value: Any) -> bytes | list[int]: @@ -286,44 +271,16 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sx127x.run_image_cal", - RunImageCalAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_tx", - SetModeTxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_rx", - SetModeRxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_sleep", - SetModeSleepAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_standby", - SetModeStandbyAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("sx127x.run_image_cal", "run_image_cal()"), + ("sx127x.set_mode_tx", "set_mode_tx()"), + ("sx127x.set_mode_rx", "set_mode_rx()"), + ("sx127x.set_mode_sleep", "set_mode_sleep()"), + ("sx127x.set_mode_standby", "set_mode_standby()"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index f6a4537e231..2ed16146813 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,11 +6,6 @@ namespace esphome::sx127x { -template class RunImageCalAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->run_image_cal(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { @@ -43,24 +38,4 @@ template class SendPacketAction final : public Action, pu } data_; }; -template class SetModeTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_tx(); } -}; - -template class SetModeRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_rx(); } -}; - -template class SetModeSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } -}; - -template class SetModeStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_standby(); } -}; - } // namespace esphome::sx127x From 11e016be5989fc3804b501cc39eb8d3612527a23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:16:06 +0100 Subject: [PATCH 16/94] [sx126x] Use register_apply_action for the five mode and calibration actions (#19635) --- esphome/components/sx126x/__init__.py | 72 +++---------------- esphome/components/sx126x/automation.h | 26 ------- tests/components/sx126x/common-actions.yaml | 9 +++ .../sx126x/test-actions.esp8266-ard.yaml | 13 ++++ 4 files changed, 33 insertions(+), 87 deletions(-) create mode 100644 tests/components/sx126x/common-actions.yaml create mode 100644 tests/components/sx126x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index f8f51296a16..ce47a570501 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -131,24 +131,9 @@ SHAPING = { "NONE": SX126xPulseShape.NO_FILTER, } -RunImageCalAction = sx126x_ns.class_( - "RunImageCalAction", automation.Action, cg.Parented.template(SX126x) -) SendPacketAction = sx126x_ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(SX126x) ) -SetModeTxAction = sx126x_ns.class_( - "SetModeTxAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeRxAction = sx126x_ns.class_( - "SetModeRxAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeSleepAction = sx126x_ns.class_( - "SetModeSleepAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeStandbyAction = sx126x_ns.class_( - "SetModeStandbyAction", automation.Action, cg.Parented.template(SX126x) -) def validate_raw_data(value: Any) -> bytes | list[int]: @@ -302,37 +287,15 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sx126x.run_image_cal", - RunImageCalAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx126x.set_mode_tx", - SetModeTxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx126x.set_mode_rx", - SetModeRxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx126x.set_mode_standby", - SetModeStandbyAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - +for _name, _call in ( + ("sx126x.run_image_cal", "run_image_cal()"), + ("sx126x.set_mode_tx", "set_mode_tx()"), + ("sx126x.set_mode_rx", "set_mode_rx()"), + ("sx126x.set_mode_standby", "set_mode_standby(sx126x::STDBY_XOSC)"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( { @@ -341,24 +304,11 @@ SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( } ) - -@automation.register_action( +automation.register_apply_action( "sx126x.set_mode_sleep", - SetModeSleepAction, SET_MODE_SLEEP_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_COLD, "set_mode_sleep", cg.bool_), ) -async def set_mode_sleep_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_COLD], args, bool) - cg.add(var.set_cold(template_)) - return var SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 4eb33abaa1e..411de123413 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,11 +6,6 @@ namespace esphome::sx126x { -template class RunImageCalAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->run_image_cal(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { @@ -43,25 +38,4 @@ template class SendPacketAction final : public Action, pu } data_; }; -template class SetModeTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_tx(); } -}; - -template class SetModeRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_rx(); } -}; - -template class SetModeSleepAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, cold) - void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } -}; - -template class SetModeStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } -}; - } // namespace esphome::sx126x diff --git a/tests/components/sx126x/common-actions.yaml b/tests/components/sx126x/common-actions.yaml new file mode 100644 index 00000000000..2612095cc60 --- /dev/null +++ b/tests/components/sx126x/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: SX126x Mode Actions + on_press: + - sx126x.set_mode_tx + - sx126x.set_mode_sleep: + cold: true + - sx126x.set_mode_sleep: + cold: !lambda return id(sx126x_my_number).state > 1; diff --git a/tests/components/sx126x/test-actions.esp8266-ard.yaml b/tests/components/sx126x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..1d412d58967 --- /dev/null +++ b/tests/components/sx126x/test-actions.esp8266-ard.yaml @@ -0,0 +1,13 @@ +substitutions: + clk_pin: GPIO0 + mosi_pin: GPIO15 + miso_pin: GPIO16 + cs_pin: GPIO1 + rst_pin: GPIO2 + busy_pin: GPIO4 + dio1_pin: GPIO3 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + sx126x: !include common.yaml + actions: !include common-actions.yaml From 23c317f163bbf18104dfe24136ce6c2347bc0fb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:43:10 +0100 Subject: [PATCH 17/94] [pcf8563] Use register_apply_action for pcf8563.write_time and pcf8563.read_time (#19639) --- esphome/components/pcf8563/pcf8563.h | 9 --------- esphome/components/pcf8563/time.py | 30 +++++++++------------------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index e208774c2c6..2921d4f0475 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -109,13 +109,4 @@ class PCF8563Component final : public time::RealTimeClock, public i2c::I2CDevice } pcf8563_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::pcf8563 diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 141949bf35d..3f677280c5b 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -14,8 +14,6 @@ pcf8563_ns = cg.esphome_ns.namespace("pcf8563") pcf8563Component = pcf8563_ns.class_( "PCF8563Component", time.RealTimeClock, i2c.I2CDevice ) -WriteAction = pcf8563_ns.class_("WriteAction", automation.Action) -ReadAction = pcf8563_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -25,27 +23,17 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -automation.register_parented_action( - "pcf8563.write_time", - WriteAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(pcf8563Component), - } - ), - synchronous=True, +PCF8563_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(pcf8563Component), + } ) - -automation.register_parented_action( - "pcf8563.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(pcf8563Component), - } - ), - synchronous=True, +automation.register_apply_action( + "pcf8563.write_time", PCF8563_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) +automation.register_apply_action( + "pcf8563.read_time", PCF8563_ACTION_SCHEMA, automation.ApplyCall("read_time()") ) From 0cb421f79398315731918115c147f7eaf2a3db78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:45:38 +0100 Subject: [PATCH 18/94] [max7219digit] Use register_apply_action for the seven max7219digit actions (#19641) --- esphome/components/max7219digit/automation.h | 50 --------- esphome/components/max7219digit/display.py | 110 +++---------------- 2 files changed, 13 insertions(+), 147 deletions(-) delete mode 100644 esphome/components/max7219digit/automation.h diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h deleted file mode 100644 index f06dfd5087a..00000000000 --- a/esphome/components/max7219digit/automation.h +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -#include "max7219digit.h" - -namespace esphome::max7219digit { - -template class DisplayInvertAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->invert_on_off(state); - } -}; - -template class DisplayVisibilityAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->turn_on_off(state); - } -}; - -template class DisplayReverseAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->set_reverse(state); - } -}; - -template class DisplayIntensityAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, state) - - void play(const Ts &...x) override { - uint8_t state = this->state_.value(x...); - this->parent_->set_intensity(state); - } -}; - -} // namespace esphome::max7219digit diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 54711263dda..7974ef20202 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -10,8 +10,6 @@ from esphome.const import ( CONF_NUM_CHIPS, CONF_STATE, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@rspaargaren"] @@ -112,14 +110,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_writer(lambda_)) -DisplayInvertAction = max7219_ns.class_("DisplayInvertAction", automation.Action) -DisplayVisibilityAction = max7219_ns.class_( - "DisplayVisibilityAction", automation.Action -) -DisplayReverseAction = max7219_ns.class_("DisplayReverseAction", automation.Action) -DisplayIntensityAction = max7219_ns.class_("DisplayIntensityAction", automation.Action) - - MAX7219_OFF_ACTION_SCHEMA = automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(MAX7219Component), @@ -135,79 +125,17 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "max7219digit.invert_off", - DisplayInvertAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.invert_on", - DisplayInvertAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -async def max7219digit_invert_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "max7219digit.turn_off", - DisplayVisibilityAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.turn_on", - DisplayVisibilityAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -async def max7219digit_visible_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "max7219digit.reverse_off", - DisplayReverseAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.reverse_on", - DisplayReverseAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -async def max7219digit_reverse_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var +for _name, _schema, _method in ( + ("max7219digit.invert_off", MAX7219_OFF_ACTION_SCHEMA, "invert_on_off"), + ("max7219digit.invert_on", MAX7219_ON_ACTION_SCHEMA, "invert_on_off"), + ("max7219digit.turn_off", MAX7219_OFF_ACTION_SCHEMA, "turn_on_off"), + ("max7219digit.turn_on", MAX7219_ON_ACTION_SCHEMA, "turn_on_off"), + ("max7219digit.reverse_off", MAX7219_OFF_ACTION_SCHEMA, "set_reverse"), + ("max7219digit.reverse_on", MAX7219_ON_ACTION_SCHEMA, "set_reverse"), +): + automation.register_apply_action( + _name, _schema, automation.ApplyField(CONF_STATE, _method, cg.bool_) + ) MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( @@ -221,20 +149,8 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "max7219digit.intensity", - DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_INTENSITY, "set_intensity", cg.uint8), ) -async def max7219digit_intensity_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8) - cg.add(var.set_state(template_)) - return var From a088eff4289a99a2ee95de70be2449931643f692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:53:02 +0100 Subject: [PATCH 19/94] [binary_sensor] Use register_apply_action for binary_sensor.invalidate_state (#19637) --- esphome/components/binary_sensor/__init__.py | 8 ++------ esphome/components/binary_sensor/automation.h | 10 ---------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 696d7345ddc..6b7c3dcb163 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -134,9 +134,6 @@ MultiClickTriggerBase = binary_sensor_ns.class_( MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -BinarySensorInvalidateAction = binary_sensor_ns.class_( - "BinarySensorInvalidateAction", automation.Action -) # Filters Filter = binary_sensor_ns.class_("Filter") @@ -655,16 +652,15 @@ async def to_code(config): cg.add_global(binary_sensor_ns.using) -automation.register_simple_action( +automation.register_apply_action( "binary_sensor.invalidate_state", - BinarySensorInvalidateAction, cv.maybe_simple_value( { cv.Required(CONF_ID): cv.use_id(BinarySensor), }, key=CONF_ID, ), - synchronous=True, + automation.ApplyCall("invalidate_state()"), ) diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 538e2316076..49457200bc6 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -120,14 +120,4 @@ template class MultiClickTrigger final : public MultiClickTriggerBase std::array timing_storage_{}; }; -template class BinarySensorInvalidateAction final : public Action { - public: - explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} - - void play(const Ts &...x) override { this->sensor_->invalidate_state(); } - - protected: - BinarySensor *sensor_; -}; - } // namespace esphome::binary_sensor From 63796e74bb1d5094e3dd17ddd0577d4d46c77c95 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:05:26 +0100 Subject: [PATCH 20/94] Bump aioesphomeapi from 46.5.0 to 46.6.0 (#19648) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f678a9e5e9d..275626b1f07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.5.0 +aioesphomeapi==46.6.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From 2157ff5b4b2b0ffd493eaff06d1b34262e4f58b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:09:26 +0100 Subject: [PATCH 21/94] [ds1307] Use register_apply_action for ds1307.write_time and ds1307.read_time (#19638) --- esphome/components/ds1307/ds1307.h | 9 --------- esphome/components/ds1307/time.py | 13 ++++--------- tests/components/ds1307/common-actions.yaml | 7 +++++++ .../components/ds1307/test-actions.esp8266-ard.yaml | 4 ++++ 4 files changed, 15 insertions(+), 18 deletions(-) create mode 100644 tests/components/ds1307/common-actions.yaml create mode 100644 tests/components/ds1307/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 238fc7b21af..ec544ef3a4d 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -55,13 +55,4 @@ class DS1307Component final : public time::RealTimeClock, public i2c::I2CDevice } ds1307_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::ds1307 diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 278494c81fc..7a15283f3b7 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -9,8 +9,6 @@ CODEOWNERS = ["@badbadc0ffee"] DEPENDENCIES = ["i2c"] ds1307_ns = cg.esphome_ns.namespace("ds1307") DS1307Component = ds1307_ns.class_("DS1307Component", time.RealTimeClock, i2c.I2CDevice) -WriteAction = ds1307_ns.class_("WriteAction", automation.Action) -ReadAction = ds1307_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -20,27 +18,24 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x68)) -automation.register_parented_action( +automation.register_apply_action( "ds1307.write_time", - WriteAction, cv.Schema( { cv.GenerateID(): cv.use_id(DS1307Component), } ), - synchronous=True, + automation.ApplyCall("write_time()"), ) - -automation.register_parented_action( +automation.register_apply_action( "ds1307.read_time", - ReadAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(DS1307Component), } ), - synchronous=True, + automation.ApplyCall("read_time()"), ) diff --git a/tests/components/ds1307/common-actions.yaml b/tests/components/ds1307/common-actions.yaml new file mode 100644 index 00000000000..b6c03723b54 --- /dev/null +++ b/tests/components/ds1307/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: DS1307 Actions + on_press: + - ds1307.write_time: + id: ds1307_time + - ds1307.read_time: ds1307_time diff --git a/tests/components/ds1307/test-actions.esp8266-ard.yaml b/tests/components/ds1307/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..f2326f446c3 --- /dev/null +++ b/tests/components/ds1307/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds1307: !include common.yaml + actions: !include common-actions.yaml From 262ade0598fb69511a6dc5cdf1025a3124f6c79b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:09:37 +0100 Subject: [PATCH 22/94] [senseair] Use register_apply_action for the five senseair actions (#19643) --- esphome/components/senseair/senseair.h | 51 --------------------- esphome/components/senseair/sensor.py | 63 ++++---------------------- 2 files changed, 10 insertions(+), 104 deletions(-) diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 48154a53d95..b376f1f657b 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -37,54 +36,4 @@ class SenseAirComponent final : public PollingComponent, public uart::UARTDevice sensor::Sensor *co2_sensor_{nullptr}; }; -template class SenseAirBackgroundCalibrationAction final : public Action { - public: - SenseAirBackgroundCalibrationAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->background_calibration(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirBackgroundCalibrationResultAction final : public Action { - public: - SenseAirBackgroundCalibrationResultAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->background_calibration_result(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCEnableAction final : public Action { - public: - SenseAirABCEnableAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_enable(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCDisableAction final : public Action { - public: - SenseAirABCDisableAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_disable(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCGetPeriodAction final : public Action { - public: - SenseAirABCGetPeriodAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_get_period(); } - - protected: - SenseAirComponent *senseair_; -}; - } // namespace esphome::senseair diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index ac92efa3a40..9445e5867c3 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -19,21 +19,6 @@ senseair_ns = cg.esphome_ns.namespace("senseair") SenseAirComponent = senseair_ns.class_( "SenseAirComponent", cg.PollingComponent, uart.UARTDevice ) -SenseAirBackgroundCalibrationAction = senseair_ns.class_( - "SenseAirBackgroundCalibrationAction", automation.Action -) -SenseAirBackgroundCalibrationResultAction = senseair_ns.class_( - "SenseAirBackgroundCalibrationResultAction", automation.Action -) -SenseAirABCEnableAction = senseair_ns.class_( - "SenseAirABCEnableAction", automation.Action -) -SenseAirABCDisableAction = senseair_ns.class_( - "SenseAirABCDisableAction", automation.Action -) -SenseAirABCGetPeriodAction = senseair_ns.class_( - "SenseAirABCGetPeriodAction", automation.Action -) CONFIG_SCHEMA = ( cv.Schema( @@ -80,41 +65,13 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "senseair.background_calibration", - SenseAirBackgroundCalibrationAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.background_calibration_result", - SenseAirBackgroundCalibrationResultAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.abc_enable", - SenseAirABCEnableAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.abc_disable", - SenseAirABCDisableAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.abc_get_period", - SenseAirABCGetPeriodAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("senseair.background_calibration", "background_calibration()"), + ("senseair.background_calibration_result", "background_calibration_result()"), + ("senseair.abc_enable", "abc_enable()"), + ("senseair.abc_disable", "abc_disable()"), + ("senseair.abc_get_period", "abc_get_period()"), +): + automation.register_apply_action( + _name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call) + ) From ad6996a63c570f8b3be01f3ee232f0709cf80478 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:11:05 +0100 Subject: [PATCH 23/94] [key_collector] Use register_apply_action for key_collector.enable and key_collector.disable (#19646) --- esphome/components/key_collector/__init__.py | 32 +++++++------------ .../components/key_collector/key_collector.h | 8 ----- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index c95bdc46b00..dedd960c368 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -35,8 +35,6 @@ CONF_ON_RESULT = "on_result" key_collector_ns = cg.esphome_ns.namespace("key_collector") KeyCollector = key_collector_ns.class_("KeyCollector", cg.Component) -EnableAction = key_collector_ns.class_("EnableAction", automation.Action) -DisableAction = key_collector_ns.class_("DisableAction", automation.Action) X_TYPE = cg.std_string_ref.operator("const") @@ -134,25 +132,19 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_enabled(config[CONF_ENABLE_ON_BOOT])) -automation.register_parented_action( +KEY_COLLECTOR_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(KeyCollector), + } +) + +automation.register_apply_action( "key_collector.enable", - EnableAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(KeyCollector), - } - ), - synchronous=True, + KEY_COLLECTOR_ACTION_SCHEMA, + automation.ApplyCall("set_enabled(true)"), ) - - -automation.register_parented_action( +automation.register_apply_action( "key_collector.disable", - DisableAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(KeyCollector), - } - ), - synchronous=True, + KEY_COLLECTOR_ACTION_SCHEMA, + automation.ApplyCall("set_enabled(false)"), ) diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index c9eeabeb2de..e49cac46108 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -54,12 +54,4 @@ class KeyCollector final : public Component { bool enabled_{}; }; -template class EnableAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_enabled(true); } -}; - -template class DisableAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_enabled(false); } -}; - } // namespace esphome::key_collector From 32624e248c32c18ecc2e29398e4af051cd03eb3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:11:51 +0100 Subject: [PATCH 24/94] [fingerprint_grow] Use register_apply_action for the six fingerprint_grow actions (#19640) --- .../components/fingerprint_grow/__init__.py | 171 +++++++----------- .../fingerprint_grow/fingerprint_grow.h | 68 ------- .../fingerprint_grow/common-actions.yaml | 16 ++ .../test-actions.esp8266-ard.yaml | 9 + 4 files changed, 87 insertions(+), 177 deletions(-) create mode 100644 tests/components/fingerprint_grow/common-actions.yaml create mode 100644 tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 85fe3994c52..0266a9caad5 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -37,16 +37,6 @@ FingerprintGrowComponent = fingerprint_grow_ns.class_( "FingerprintGrowComponent", cg.PollingComponent, uart.UARTDevice ) -EnrollmentAction = fingerprint_grow_ns.class_("EnrollmentAction", automation.Action) -CancelEnrollmentAction = fingerprint_grow_ns.class_( - "CancelEnrollmentAction", automation.Action -) -DeleteAction = fingerprint_grow_ns.class_("DeleteAction", automation.Action) -DeleteAllAction = fingerprint_grow_ns.class_("DeleteAllAction", automation.Action) -LEDControlAction = fingerprint_grow_ns.class_("LEDControlAction", automation.Action) -AuraLEDControlAction = fingerprint_grow_ns.class_( - "AuraLEDControlAction", automation.Action -) AuraLEDState = fingerprint_grow_ns.enum("GrowAuraLEDState", True) AURA_LED_STATES = { @@ -181,103 +171,68 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( - "fingerprint_grow.enroll", - EnrollmentAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), - cv.Optional(CONF_NUM_SCANS): cv.templatable(cv.uint8_t), - }, - key=CONF_FINGER_ID, - ), - synchronous=True, -) -async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_ = await cg.templatable(config[CONF_FINGER_ID], args, cg.uint16) - cg.add(var.set_finger_id(template_)) - if CONF_NUM_SCANS in config: - template_ = await cg.templatable(config[CONF_NUM_SCANS], args, cg.uint8) - cg.add(var.set_num_scans(template_)) - return var - - -automation.register_parented_action( - "fingerprint_grow.cancel_enroll", - CancelEnrollmentAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - } - ), - synchronous=True, -) - - -@automation.register_action( - "fingerprint_grow.delete", - DeleteAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), - }, - key=CONF_FINGER_ID, - ), - synchronous=True, -) -async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_ = await cg.templatable(config[CONF_FINGER_ID], args, cg.uint16) - cg.add(var.set_finger_id(template_)) - return var - - -automation.register_parented_action( - "fingerprint_grow.delete_all", - DeleteAllAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - } - ), - synchronous=True, -) - - -FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA = cv.maybe_simple_value( +FINGERPRINT_GROW_ID_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_STATE): cv.templatable(cv.boolean), - }, - key=CONF_STATE, + } ) +automation.register_apply_action( + "fingerprint_grow.enroll", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), + cv.Optional(CONF_NUM_SCANS, default=2): cv.templatable( + cv.int_range(min=1, max=255) + ), + }, + key=CONF_FINGER_ID, + ), + automation.ApplyCall( + "enroll_fingerprint({}, {})", + ((CONF_FINGER_ID, cg.uint16), (CONF_NUM_SCANS, cg.uint8)), + ), +) -@automation.register_action( +automation.register_apply_action( + "fingerprint_grow.cancel_enroll", + FINGERPRINT_GROW_ID_SCHEMA, + automation.ApplyCall("finish_enrollment(1)"), +) + +automation.register_apply_action( + "fingerprint_grow.delete", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), + }, + key=CONF_FINGER_ID, + ), + automation.ApplyField(CONF_FINGER_ID, "delete_fingerprint", cg.uint16), +) + +automation.register_apply_action( + "fingerprint_grow.delete_all", + FINGERPRINT_GROW_ID_SCHEMA, + automation.ApplyCall("delete_all_fingerprints()"), +) + +automation.register_apply_action( "fingerprint_grow.led_control", - LEDControlAction, - FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA, - synchronous=True, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_STATE): cv.templatable(cv.boolean), + }, + key=CONF_STATE, + ), + automation.ApplyField(CONF_STATE, "led_control", cg.bool_), ) -async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "fingerprint_grow.aura_led_control", - AuraLEDControlAction, cv.Schema( { cv.GenerateID(): cv.use_id(FingerprintGrowComponent), @@ -287,15 +242,13 @@ async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, cv.Required(CONF_COUNT): cv.templatable(cv.uint8_t), } ), - synchronous=True, + automation.ApplyCall( + "aura_led_control({}, {}, {}, {})", + ( + (CONF_STATE, cg.uint8), + (CONF_SPEED, cg.uint8), + (CONF_COLOR, cg.uint8), + (CONF_COUNT, cg.uint8), + ), + ), ) -async def fingerprint_grow_aura_led_control_to_code( - config, action_id, template_arg, args -): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - for key in [CONF_STATE, CONF_SPEED, CONF_COLOR, CONF_COUNT]: - template_ = await cg.templatable(config[key], args, cg.uint8) - cg.add(getattr(var, f"set_{key}")(template_)) - return var diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 67662192ee4..5256d4489ea 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/uart/uart.h" @@ -209,71 +208,4 @@ class FingerprintGrowComponent final : public PollingComponent, public uart::UAR CallbackManager enrollment_failed_callback_; }; -template -class EnrollmentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, finger_id) - TEMPLATABLE_VALUE(uint8_t, num_scans) - - void play(const Ts &...x) override { - auto finger_id = this->finger_id_.value(x...); - auto num_scans = this->num_scans_.value(x...); - if (num_scans) { - this->parent_->enroll_fingerprint(finger_id, num_scans); - } else { - this->parent_->enroll_fingerprint(finger_id, 2); - } - } -}; - -template -class CancelEnrollmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->finish_enrollment(1); } -}; - -template class DeleteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, finger_id) - - void play(const Ts &...x) override { - auto finger_id = this->finger_id_.value(x...); - this->parent_->delete_fingerprint(finger_id); - } -}; - -template class DeleteAllAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->delete_all_fingerprints(); } -}; - -template -class LEDControlAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto state = this->state_.value(x...); - this->parent_->led_control(state); - } -}; - -template -class AuraLEDControlAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, state) - TEMPLATABLE_VALUE(uint8_t, speed) - TEMPLATABLE_VALUE(uint8_t, color) - TEMPLATABLE_VALUE(uint8_t, count) - - void play(const Ts &...x) override { - auto state = this->state_.value(x...); - auto speed = this->speed_.value(x...); - auto color = this->color_.value(x...); - auto count = this->count_.value(x...); - - this->parent_->aura_led_control(state, speed, color, count); - } -}; - } // namespace esphome::fingerprint_grow diff --git a/tests/components/fingerprint_grow/common-actions.yaml b/tests/components/fingerprint_grow/common-actions.yaml new file mode 100644 index 00000000000..1cecad5875d --- /dev/null +++ b/tests/components/fingerprint_grow/common-actions.yaml @@ -0,0 +1,16 @@ +button: + - platform: template + name: Fingerprint LED Actions + on_press: + - fingerprint_grow.enroll: 3 + - fingerprint_grow.enroll: + finger_id: !lambda return 4; + num_scans: !lambda return 3; + - fingerprint_grow.led_control: true + - fingerprint_grow.led_control: + state: !lambda return false; + - fingerprint_grow.aura_led_control: + state: BREATHING + speed: 200 + color: BLUE + count: 2 diff --git a/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml b/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..a1e3cf67ede --- /dev/null +++ b/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + sensing_pin: GPIO15 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + fingerprint_grow: !include common.yaml + actions: !include common-actions.yaml From 22035cb9f5e639e4cb90f453451704f37c821b51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:12:27 +0100 Subject: [PATCH 25/94] [tm1651] Use register_apply_action for the five tm1651 actions (#19647) --- esphome/components/tm1651/__init__.py | 118 ++++++-------------------- esphome/components/tm1651/tm1651.h | 41 --------- 2 files changed, 25 insertions(+), 134 deletions(-) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index b07576e443a..dfe475f7f2c 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -9,8 +9,6 @@ from esphome.const import ( CONF_ID, CONF_LEVEL, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@mrtoy-me"] @@ -21,11 +19,6 @@ tm1651_ns = cg.esphome_ns.namespace("tm1651") TM1651Brightness = tm1651_ns.enum("TM1651Brightness") TM1651Display = tm1651_ns.class_("TM1651Display", cg.Component) -SetBrightnessAction = tm1651_ns.class_("SetBrightnessAction", automation.Action) -SetLevelAction = tm1651_ns.class_("SetLevelAction", automation.Action) -SetLevelPercentAction = tm1651_ns.class_("SetLevelPercentAction", automation.Action) -TurnOnAction = tm1651_ns.class_("TurnOnAction", automation.Action) -TurnOffAction = tm1651_ns.class_("TurnOffAction", automation.Action) TM1651_BRIGHTNESS_OPTIONS = { 1: TM1651Brightness.TM1651_DARKEST, @@ -66,92 +59,31 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "tm1651.set_brightness", - SetBrightnessAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_BRIGHTNESS): cv.templatable(validate_brightness), - }, - key=CONF_BRIGHTNESS, +for _name, _key, _validator, _method in ( + ("tm1651.set_brightness", CONF_BRIGHTNESS, validate_brightness, "set_brightness"), + ("tm1651.set_level", CONF_LEVEL, validate_level, "set_level"), + ( + "tm1651.set_level_percent", + CONF_LEVEL_PERCENT, + validate_level_percent, + "set_level_percent", ), - synchronous=True, +): + automation.register_apply_action( + _name, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(TM1651Display), + cv.Required(_key): cv.templatable(_validator), + }, + key=_key, + ), + automation.ApplyField(_key, _method, cg.uint8), + ) + +automation.register_apply_action( + "tm1651.turn_off", BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall("turn_off()") ) -async def tm1651_set_brightness_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) - cg.add(var.set_brightness(template_)) - return var - - -@automation.register_action( - "tm1651.set_level", - SetLevelAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_LEVEL): cv.templatable(validate_level), - }, - key=CONF_LEVEL, - ), - synchronous=True, -) -async def tm1651_set_level_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) - cg.add(var.set_level(template_)) - return var - - -@automation.register_action( - "tm1651.set_level_percent", - SetLevelPercentAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_LEVEL_PERCENT): cv.templatable(validate_level_percent), - }, - key=CONF_LEVEL_PERCENT, - ), - synchronous=True, -) -async def tm1651_set_level_percent_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_LEVEL_PERCENT], args, cg.uint8) - cg.add(var.set_level_percent(template_)) - return var - - -automation.register_parented_action( - "tm1651.turn_off", - TurnOffAction, - BINARY_OUTPUT_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tm1651.turn_on", - TurnOnAction, - BINARY_OUTPUT_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "tm1651.turn_on", BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall("turn_on()") ) diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index 2021f902668..caf526046b8 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -56,44 +55,4 @@ class TM1651Display final : public Component { uint8_t level_{0}; }; -template class SetBrightnessAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, brightness) - - void play(const Ts &...x) override { - auto brightness = this->brightness_.value(x...); - this->parent_->set_brightness(brightness); - } -}; - -template class SetLevelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level) - - void play(const Ts &...x) override { - auto level = this->level_.value(x...); - this->parent_->set_level(level); - } -}; - -template class SetLevelPercentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level_percent) - - void play(const Ts &...x) override { - auto level_percent = this->level_percent_.value(x...); - this->parent_->set_level_percent(level_percent); - } -}; - -template class TurnOnAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->turn_on(); } -}; - -template class TurnOffAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->turn_off(); } -}; - } // namespace esphome::tm1651 From 15d756d559b2c8b437855089123eb01fd53bd635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:12:35 +0100 Subject: [PATCH 26/94] [sps30] Use register_apply_action for the three sps30 actions (#19644) --- esphome/components/sps30/automation.h | 24 ------------- esphome/components/sps30/sensor.py | 35 +++++-------------- tests/components/sps30/common-actions.yaml | 7 ++++ tests/components/sps30/common.yaml | 1 + .../sps30/test-actions.esp8266-ard.yaml | 4 +++ 5 files changed, 20 insertions(+), 51 deletions(-) delete mode 100644 esphome/components/sps30/automation.h create mode 100644 tests/components/sps30/common-actions.yaml create mode 100644 tests/components/sps30/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h deleted file mode 100644 index ba978e7770b..00000000000 --- a/esphome/components/sps30/automation.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" -#include "sps30.h" - -namespace esphome::sps30 { - -template class StartFanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } -}; - -template class StartMeasurementAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start_measurement(); } -}; - -template class StopMeasurementAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_measurement(); } -}; - -} // namespace esphome::sps30 diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index a310993d82c..0026e38979b 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -37,11 +37,6 @@ SPS30Component = sps30_ns.class_( "SPS30Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) -# Actions -StartFanAction = sps30_ns.class_("StartFanAction", automation.Action) -StartMeasurementAction = sps30_ns.class_("StartMeasurementAction", automation.Action) -StopMeasurementAction = sps30_ns.class_("StopMeasurementAction", automation.Action) - CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" CONF_IDLE_INTERVAL = "idle_interval" @@ -180,25 +175,11 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( - "sps30.start_fan_autoclean", - StartFanAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sps30.start_measurement", - StartMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sps30.stop_measurement", - StopMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("sps30.start_fan_autoclean", "start_fan_cleaning()"), + ("sps30.start_measurement", "start_measurement()"), + ("sps30.stop_measurement", "stop_measurement()"), +): + automation.register_apply_action( + _name, SPS30_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/sps30/common-actions.yaml b/tests/components/sps30/common-actions.yaml new file mode 100644 index 00000000000..00b85ca7671 --- /dev/null +++ b/tests/components/sps30/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: SPS30 Actions + on_press: + - sps30.start_fan_autoclean: workshop_sps30 + - sps30.stop_measurement: workshop_sps30 + - sps30.start_measurement: workshop_sps30 diff --git a/tests/components/sps30/common.yaml b/tests/components/sps30/common.yaml index a83477b764d..47811f7544e 100644 --- a/tests/components/sps30/common.yaml +++ b/tests/components/sps30/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: sps30 + id: workshop_sps30 i2c_id: i2c_bus pm_1_0: name: Workshop PM <1µm Weight concentration diff --git a/tests/components/sps30/test-actions.esp8266-ard.yaml b/tests/components/sps30/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..1d4f15d8873 --- /dev/null +++ b/tests/components/sps30/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sps30: !include common.yaml + actions: !include common-actions.yaml From 857a51994623857107198afd201335a3c2dffcf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:12:51 +0100 Subject: [PATCH 27/94] [sim800l] Use register_apply_action for the five sim800l actions (#19645) --- esphome/components/sim800l/__init__.py | 71 ++++++-------------------- esphome/components/sim800l/sim800l.h | 63 ----------------------- 2 files changed, 17 insertions(+), 117 deletions(-) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 7f42edb8631..c472e01f83a 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -11,15 +11,6 @@ MULTI_CONF = True sim800l_ns = cg.esphome_ns.namespace("sim800l") Sim800LComponent = sim800l_ns.class_("Sim800LComponent", cg.Component) -# Actions -Sim800LSendSmsAction = sim800l_ns.class_("Sim800LSendSmsAction", automation.Action) -Sim800LSendUssdAction = sim800l_ns.class_("Sim800LSendUssdAction", automation.Action) -Sim800LDialAction = sim800l_ns.class_("Sim800LDialAction", automation.Action) -Sim800LConnectAction = sim800l_ns.class_("Sim800LConnectAction", automation.Action) -Sim800LDisconnectAction = sim800l_ns.class_( - "Sim800LDisconnectAction", automation.Action -) - CONF_SIM800L_ID = "sim800l_id" CONF_ON_SMS_RECEIVED = "on_sms_received" CONF_ON_USSD_RECEIVED = "on_ussd_received" @@ -90,21 +81,14 @@ SIM800L_SEND_SMS_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "sim800l.send_sms", - Sim800LSendSmsAction, SIM800L_SEND_SMS_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_sms({}, {})", + ((CONF_RECIPIENT, cg.std_string), (CONF_MESSAGE, cg.std_string)), + ), ) -async def sim800l_send_sms_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RECIPIENT], args, cg.std_string) - cg.add(var.set_recipient(template_)) - template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) - cg.add(var.set_message(template_)) - return var - SIM800L_DIAL_SCHEMA = cv.Schema( { @@ -113,25 +97,11 @@ SIM800L_DIAL_SCHEMA = cv.Schema( } ) - -@automation.register_action( - "sim800l.dial", Sim800LDialAction, SIM800L_DIAL_SCHEMA, synchronous=True +automation.register_apply_action( + "sim800l.dial", + SIM800L_DIAL_SCHEMA, + automation.ApplyField(CONF_RECIPIENT, "dial", cg.std_string), ) -async def sim800l_dial_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RECIPIENT], args, cg.std_string) - cg.add(var.set_recipient(template_)) - return var - - -automation.register_simple_action( - "sim800l.connect", - Sim800LConnectAction, - cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), - synchronous=True, -) - SIM800L_SEND_USSD_SCHEMA = cv.Schema( { @@ -140,24 +110,17 @@ SIM800L_SEND_USSD_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "sim800l.send_ussd", - Sim800LSendUssdAction, SIM800L_SEND_USSD_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_USSD, "send_ussd", cg.std_string), ) -async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_USSD], args, cg.std_string) - cg.add(var.set_ussd(template_)) - return var +SIM800L_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}) -automation.register_simple_action( - "sim800l.disconnect", - Sim800LDisconnectAction, - cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), - synchronous=True, +automation.register_apply_action( + "sim800l.connect", SIM800L_ID_SCHEMA, automation.ApplyCall("connect()") +) +automation.register_apply_action( + "sim800l.disconnect", SIM800L_ID_SCHEMA, automation.ApplyCall("disconnect()") ) diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 276131cfed8..bbe8e14cf7e 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -120,67 +120,4 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent CallbackManager ussd_received_callback_; }; -template class Sim800LSendSmsAction final : public Action { - public: - Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, recipient) - TEMPLATABLE_VALUE(std::string, message) - - void play(const Ts &...x) { - auto recipient = this->recipient_.value(x...); - auto message = this->message_.value(x...); - this->parent_->send_sms(recipient, message); - } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LSendUssdAction final : public Action { - public: - Sim800LSendUssdAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, ussd) - - void play(const Ts &...x) { - auto ussd_code = this->ussd_.value(x...); - this->parent_->send_ussd(ussd_code); - } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LDialAction final : public Action { - public: - Sim800LDialAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, recipient) - - void play(const Ts &...x) { - auto recipient = this->recipient_.value(x...); - this->parent_->dial(recipient); - } - - protected: - Sim800LComponent *parent_; -}; -template class Sim800LConnectAction final : public Action { - public: - Sim800LConnectAction(Sim800LComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->connect(); } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LDisconnectAction final : public Action { - public: - Sim800LDisconnectAction(Sim800LComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->disconnect(); } - - protected: - Sim800LComponent *parent_; -}; - } // namespace esphome::sim800l From 340e105a76b365c20f5d9cf6140b11315337b0c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:28:01 +0100 Subject: [PATCH 28/94] [pzemac] Use register_apply_action for pzemac.reset_energy (#19650) --- esphome/components/pzemac/pzemac.cpp | 2 +- esphome/components/pzemac/pzemac.h | 17 +---------------- esphome/components/pzemac/sensor.py | 8 ++------ 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 409de911249..c0d163c1dec 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,7 +77,7 @@ void PZEMAC::dump_config() { LOG_SENSOR("", "Power Factor", this->power_factor_sensor_); } -void PZEMAC::reset_energy_() { +void PZEMAC::reset_energy() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; this->queue_pdu(pdu); } diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 723b21e0b05..5eb712d4cc8 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" @@ -9,8 +8,6 @@ namespace esphome::pzemac { -template class ResetEnergyAction; - class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } @@ -28,27 +25,15 @@ class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice modbus::ResponseStatus status) override; void dump_config() override; + void reset_energy(); protected: - template friend class ResetEnergyAction; sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; sensor::Sensor *energy_sensor_{nullptr}; sensor::Sensor *frequency_sensor_{nullptr}; sensor::Sensor *power_factor_sensor_{nullptr}; - - void reset_energy_(); -}; - -template class ResetEnergyAction final : public Action { - public: - ResetEnergyAction(PZEMAC *pzemac) : pzemac_(pzemac) {} - - void play(const Ts &...x) override { this->pzemac_->reset_energy_(); } - - protected: - PZEMAC *pzemac_; }; } // namespace esphome::pzemac diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 1670204468c..ff4cedc8146 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -33,9 +33,6 @@ AUTO_LOAD = ["modbus"] pzemac_ns = cg.esphome_ns.namespace("pzemac") PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice) -# Actions -ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -83,15 +80,14 @@ CONFIG_SCHEMA = ( ) -automation.register_simple_action( +automation.register_apply_action( "pzemac.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PZEMAC), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) From 9ae83f28301decb9dcad1a49472d8cfb679aa981 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:30:30 +0100 Subject: [PATCH 29/94] [hbridge] Use register_apply_action for fan.hbridge.brake (#19649) --- esphome/components/hbridge/fan/__init__.py | 8 ++------ esphome/components/hbridge/fan/hbridge_fan.h | 10 ---------- tests/components/hbridge/common-actions.yaml | 5 +++++ tests/components/hbridge/test-actions.esp8266-ard.yaml | 10 ++++++++++ 4 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 tests/components/hbridge/common-actions.yaml create mode 100644 tests/components/hbridge/test-actions.esp8266-ard.yaml diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index cd47d100be4..e862760e6ae 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -27,9 +27,6 @@ DECAY_MODE_OPTIONS = { "FAST": DecayMode.DECAY_MODE_FAST, } -# Actions -BrakeAction = hbridge_ns.class_("BrakeAction", automation.Action) - CONFIG_SCHEMA = ( fan.fan_schema(HBridgeFan) .extend( @@ -48,11 +45,10 @@ CONFIG_SCHEMA = ( ) -automation.register_simple_action( +automation.register_apply_action( "fan.hbridge.brake", - BrakeAction, maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), - synchronous=True, + automation.ApplyCall("brake()"), ) diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 187b6d2a97f..c786f606740 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" #include "esphome/components/fan/fan.h" @@ -46,13 +45,4 @@ class HBridgeFan final : public Component, public fan::Fan { void set_hbridge_levels_(float a_level, float b_level, float enable); }; -template class BrakeAction final : public Action { - public: - explicit BrakeAction(HBridgeFan *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->brake(); } - - HBridgeFan *parent_; -}; - } // namespace esphome::hbridge diff --git a/tests/components/hbridge/common-actions.yaml b/tests/components/hbridge/common-actions.yaml new file mode 100644 index 00000000000..e0638c49c04 --- /dev/null +++ b/tests/components/hbridge/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: Brake the fan + on_press: + - fan.hbridge.brake: fan_hbridge diff --git a/tests/components/hbridge/test-actions.esp8266-ard.yaml b/tests/components/hbridge/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..752ab72bf9a --- /dev/null +++ b/tests/components/hbridge/test-actions.esp8266-ard.yaml @@ -0,0 +1,10 @@ +substitutions: + pwm_platform: "esp8266_pwm" + output1_pin: "4" + output2_pin: "5" + output3_pin: "12" + output4_pin: "13" + +packages: + hbridge: !include common.yaml + actions: !include common-actions.yaml From 9c2616ef7efbd4ee19c909619d7a64b7e2dfa2ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:31:11 +0100 Subject: [PATCH 30/94] [pzemdc] Use register_apply_action for pzemdc.reset_energy (#19651) --- esphome/components/pzemdc/pzemdc.h | 11 ----------- esphome/components/pzemdc/sensor.py | 8 ++------ 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 69c8a9dd6c2..954fbae9783 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" @@ -34,14 +33,4 @@ class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice sensor::Sensor *energy_sensor_{nullptr}; }; -template class ResetEnergyAction final : public Action { - public: - ResetEnergyAction(PZEMDC *pzemdc) : pzemdc_(pzemdc) {} - - void play(const Ts &...x) override { this->pzemdc_->reset_energy(); } - - protected: - PZEMDC *pzemdc_; -}; - } // namespace esphome::pzemdc diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 97e9d09d058..8ec88d69095 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -27,9 +27,6 @@ AUTO_LOAD = ["modbus"] pzemdc_ns = cg.esphome_ns.namespace("pzemdc") PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice) -# Actions -ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -65,15 +62,14 @@ CONFIG_SCHEMA = ( ) -automation.register_simple_action( +automation.register_apply_action( "pzemdc.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.GenerateID(CONF_ID): cv.use_id(PZEMDC), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) From 6a115e6b252ab11cfdf72ab317948fd9696630b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:31:39 +0100 Subject: [PATCH 31/94] [sen5x] Use register_apply_action for sen5x.start_fan_autoclean (#19653) --- esphome/components/sen5x/automation.h | 19 ------------------- esphome/components/sen5x/sensor.py | 8 ++------ tests/components/sen5x/common-actions.yaml | 5 +++++ .../sen5x/test-actions.esp8266-ard.yaml | 4 ++++ 4 files changed, 11 insertions(+), 25 deletions(-) delete mode 100644 esphome/components/sen5x/automation.h create mode 100644 tests/components/sen5x/common-actions.yaml create mode 100644 tests/components/sen5x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h deleted file mode 100644 index 21d938c4fea..00000000000 --- a/esphome/components/sen5x/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "sen5x.h" - -namespace esphome::sen5x { - -template class StartFanAction final : public Action { - public: - explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} - - void play(const Ts &...x) override { this->sen5x_->start_fan_cleaning(); } - - protected: - SEN5XComponent *sen5x_; -}; - -} // namespace esphome::sen5x diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index e7f61d8b2de..7bfce0f26a5 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -60,9 +60,6 @@ CONF_ACCELERATION_MODE = "acceleration_mode" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" -# Actions -StartFanAction = sen5x_ns.class_("StartFanAction", automation.Action) - ACCELERATION_MODES = { "low": RhtAccelerationMode.LOW_ACCELERATION, "medium": RhtAccelerationMode.MEDIUM_ACCELERATION, @@ -280,9 +277,8 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( +automation.register_apply_action( "sen5x.start_fan_autoclean", - StartFanAction, SEN5X_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("start_fan_cleaning()"), ) diff --git a/tests/components/sen5x/common-actions.yaml b/tests/components/sen5x/common-actions.yaml new file mode 100644 index 00000000000..a6294878016 --- /dev/null +++ b/tests/components/sen5x/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: SEN5x Clean the fan + on_press: + - sen5x.start_fan_autoclean: sen54 diff --git a/tests/components/sen5x/test-actions.esp8266-ard.yaml b/tests/components/sen5x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..6b6ff010670 --- /dev/null +++ b/tests/components/sen5x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sen5x: !include common.yaml + actions: !include common-actions.yaml From 11c8e8c0c097085259d786a460250b537a644ad7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:32:05 +0100 Subject: [PATCH 32/94] [scd30] Use register_apply_action for scd30.force_recalibration_with_reference (#19652) --- esphome/components/scd30/automation.h | 22 ------------------ esphome/components/scd30/sensor.py | 23 ++----------------- tests/components/scd30/common-actions.yaml | 8 +++++++ tests/components/scd30/common.yaml | 1 + .../scd30/test-actions.esp8266-ard.yaml | 4 ++++ 5 files changed, 15 insertions(+), 43 deletions(-) delete mode 100644 esphome/components/scd30/automation.h create mode 100644 tests/components/scd30/common-actions.yaml create mode 100644 tests/components/scd30/test-actions.esp8266-ard.yaml diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h deleted file mode 100644 index a816ae1f260..00000000000 --- a/esphome/components/scd30/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd30.h" - -namespace esphome::scd30 { - -template -class ForceRecalibrationWithReference final : public Action, public Parented { - public: - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->force_recalibration_with_reference(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -} // namespace esphome::scd30 diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index 37789100f72..aadc85ddb7e 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -22,8 +22,6 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -34,11 +32,6 @@ SCD30Component = scd30_ns.class_( "SCD30Component", cg.Component, sensirion_common.SensirionI2CDevice ) -# Actions -ForceRecalibrationWithReference = scd30_ns.class_( - "ForceRecalibrationWithReference", automation.Action -) - CONFIG_SCHEMA = ( cv.Schema( { @@ -119,9 +112,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_temperature_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "scd30.force_recalibration_with_reference", - ForceRecalibrationWithReference, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SCD30Component), @@ -131,16 +123,5 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_VALUE, ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "force_recalibration_with_reference", cg.uint16), ) -async def scd30_force_recalibration_with_reference_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) - cg.add(var.set_value(template_)) - return var diff --git a/tests/components/scd30/common-actions.yaml b/tests/components/scd30/common-actions.yaml new file mode 100644 index 00000000000..ed88e566da3 --- /dev/null +++ b/tests/components/scd30/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: SCD30 Calibrate + on_press: + - scd30.force_recalibration_with_reference: 419 + - scd30.force_recalibration_with_reference: + id: co2_scd30 + value: !lambda return 420; diff --git a/tests/components/scd30/common.yaml b/tests/components/scd30/common.yaml index f21d8944dce..4d15e83c653 100644 --- a/tests/components/scd30/common.yaml +++ b/tests/components/scd30/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: scd30 + id: co2_scd30 i2c_id: i2c_bus co2: name: SCD30 CO2 diff --git a/tests/components/scd30/test-actions.esp8266-ard.yaml b/tests/components/scd30/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..eccbb90cc76 --- /dev/null +++ b/tests/components/scd30/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + scd30: !include common.yaml + actions: !include common-actions.yaml From 432cae58aee8c08a83443fa62f93d91832f9305c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:51:14 +0100 Subject: [PATCH 33/94] [nau7802] Use register_apply_action for the three calibration actions (#19657) --- esphome/components/nau7802/nau7802.h | 18 ------------ esphome/components/nau7802/sensor.py | 43 ++++++---------------------- 2 files changed, 8 insertions(+), 53 deletions(-) diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index c53a018234d..e31070e0a39 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -96,21 +95,4 @@ class NAU7802Sensor final : public sensor::Sensor, public PollingComponent, publ void set_calibration_failure_(bool failed); }; -template -class NAU7802CalbrateExternalOffsetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_external_offset(); } -}; - -template -class NAU7802CalbrateInternalOffsetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_internal_offset(); } -}; - -template class NAU7802CalbrateGainAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_gain(); } -}; - } // namespace esphome::nau7802 diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 3112df99d03..2647d40441a 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -18,19 +18,6 @@ nau7802_ns = cg.esphome_ns.namespace("nau7802") NAU7802Sensor = nau7802_ns.class_( "NAU7802Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) -NAU7802CalbrateExternalOffsetAction = nau7802_ns.class_( - "NAU7802CalbrateExternalOffsetAction", - automation.Action, - cg.Parented.template(NAU7802Sensor), -) -NAU7802CalbrateInternalOffsetAction = nau7802_ns.class_( - "NAU7802CalbrateInternalOffsetAction", - automation.Action, - cg.Parented.template(NAU7802Sensor), -) -NAU7802CalbrateGainAction = nau7802_ns.class_( - "NAU7802CalbrateGainAction", automation.Action, cg.Parented.template(NAU7802Sensor) -) NAU7802Gain = nau7802_ns.enum("NAU7802Gain") GAINS = { @@ -114,25 +101,11 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( - "nau7802.calibrate_internal_offset", - NAU7802CalbrateInternalOffsetAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "nau7802.calibrate_external_offset", - NAU7802CalbrateExternalOffsetAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "nau7802.calibrate_gain", - NAU7802CalbrateGainAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("nau7802.calibrate_internal_offset", "calibrate_internal_offset()"), + ("nau7802.calibrate_external_offset", "calibrate_external_offset()"), + ("nau7802.calibrate_gain", "calibrate_gain()"), +): + automation.register_apply_action( + _name, NAU7802_CALIBRATE_SCHEMA, automation.ApplyCall(_call) + ) From c16f75f0e2c839dbe98efcaf0411d3a60d7364d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:51:41 +0100 Subject: [PATCH 34/94] [pcf85063] Use register_apply_action for pcf85063.write_time and pcf85063.read_time (#19659) --- esphome/components/pcf85063/pcf85063.h | 9 --------- esphome/components/pcf85063/time.py | 13 ++++--------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 659260ba5e5..717803490a5 100644 --- a/esphome/components/pcf85063/pcf85063.h +++ b/esphome/components/pcf85063/pcf85063.h @@ -81,13 +81,4 @@ class PCF85063Component final : public time::RealTimeClock, public i2c::I2CDevic } pcf85063_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::pcf85063 diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index cc7a373b42d..3f993c17e19 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -11,8 +11,6 @@ pcf85063_ns = cg.esphome_ns.namespace("pcf85063") PCF85063Component = pcf85063_ns.class_( "PCF85063Component", time.RealTimeClock, i2c.I2CDevice ) -WriteAction = pcf85063_ns.class_("WriteAction", automation.Action) -ReadAction = pcf85063_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -22,27 +20,24 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -automation.register_parented_action( +automation.register_apply_action( "pcf85063.write_time", - WriteAction, cv.Schema( { cv.GenerateID(): cv.use_id(PCF85063Component), } ), - synchronous=True, + automation.ApplyCall("write_time()"), ) - -automation.register_parented_action( +automation.register_apply_action( "pcf85063.read_time", - ReadAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(PCF85063Component), } ), - synchronous=True, + automation.ApplyCall("read_time()"), ) From 288a7b376a56b8aa620c4bdd66b91acfd80d73ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:52:16 +0100 Subject: [PATCH 35/94] [htu21d] Use register_apply_action for htu21d.set_heater and htu21d.set_heater_level (#19658) --- esphome/components/htu21d/htu21d.h | 23 ------------------ esphome/components/htu21d/sensor.py | 37 ++++------------------------- 2 files changed, 4 insertions(+), 56 deletions(-) diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index f86d62c5e8d..c4babfd797d 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -3,7 +3,6 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -#include "esphome/core/automation.h" namespace esphome::htu21d { @@ -34,26 +33,4 @@ class HTU21DComponent final : public PollingComponent, public i2c::I2CDevice { HTU21DSensorModels sensor_model_{HTU21D_SENSOR_MODEL_HTU21D}; }; -template class SetHeaterLevelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level) - - void play(const Ts &...x) override { - auto level = this->level_.value(x...); - - this->parent_->set_heater_level(level); - } -}; - -template class SetHeaterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, status) - - void play(const Ts &...x) override { - auto status = this->status_.value(x...); - - this->parent_->set_heater(status); - } -}; - } // namespace esphome::htu21d diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 86dca777255..08888c17e5c 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -17,8 +17,6 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -27,8 +25,6 @@ htu21d_ns = cg.esphome_ns.namespace("htu21d") HTU21DComponent = htu21d_ns.class_( "HTU21DComponent", cg.PollingComponent, i2c.I2CDevice ) -SetHeaterLevelAction = htu21d_ns.class_("SetHeaterLevelAction", automation.Action) -SetHeaterAction = htu21d_ns.class_("SetHeaterAction", automation.Action) HTU21DSensorModels = htu21d_ns.enum("HTU21DSensorModels") MODELS = { @@ -86,9 +82,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sensor_model(config[CONF_MODEL])) -@automation.register_action( +automation.register_apply_action( "htu21d.set_heater_level", - SetHeaterLevelAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HTU21DComponent), @@ -96,24 +91,11 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_LEVEL, ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "set_heater_level", cg.uint8), ) -async def set_heater_level_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - level_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) - cg.add(var.set_level(level_)) - return var - -@automation.register_action( +automation.register_apply_action( "htu21d.set_heater", - SetHeaterAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HTU21DComponent), @@ -121,16 +103,5 @@ async def set_heater_level_to_code( }, key=CONF_STATUS, ), - synchronous=True, + automation.ApplyField(CONF_STATUS, "set_heater", cg.bool_), ) -async def set_heater_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) - cg.add(var.set_status(status_)) - return var From 1edc3278354bd2bbcc447b79fe660aac1adb7e8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:56:06 +0100 Subject: [PATCH 36/94] [cc1101] Use register_apply_action for begin_tx, begin_rx, reset and set_idle (#19661) --- esphome/components/cc1101/__init__.py | 43 ++++++--------------------- esphome/components/cc1101/cc1101.h | 20 ------------- 2 files changed, 9 insertions(+), 54 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index d851d7386df..5de39a16cec 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -351,10 +351,6 @@ async def to_code(config): # Actions -BeginTxAction = ns.class_("BeginTxAction", automation.Action) -BeginRxAction = ns.class_("BeginRxAction", automation.Action) -ResetAction = ns.class_("ResetAction", automation.Action) -SetIdleAction = ns.class_("SetIdleAction", automation.Action) SendPacketAction = ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) ) @@ -364,36 +360,15 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -automation.register_parented_action( - "cc1101.begin_tx", - BeginTxAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "cc1101.begin_rx", - BeginRxAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "cc1101.reset", - ResetAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "cc1101.set_idle", - SetIdleAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("cc1101.begin_tx", "begin_tx()"), + ("cc1101.begin_rx", "begin_rx()"), + ("cc1101.reset", "reset()"), + ("cc1101.set_idle", "set_idle()"), +): + automation.register_apply_action( + _name, CC1101_ACTION_SCHEMA, automation.ApplyCall(_call) + ) def validate_raw_data(value): diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 1f7a55fbf14..499f77a809c 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -130,26 +130,6 @@ class CC1101Component final : public Component, }; // Action Wrappers -template class BeginTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_tx(); } -}; - -template class BeginRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_rx(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetIdleAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_idle(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } From 14bd50d46912a277eb407afc615e45d48de3dfd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:56:32 +0100 Subject: [PATCH 37/94] [sound_level] Use register_apply_action for sound_level.start and sound_level.stop (#19656) --- esphome/components/sound_level/sensor.py | 18 ++++-------------- esphome/components/sound_level/sound_level.h | 11 ----------- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index fe00ae41ba7..a28e91dfd52 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -25,8 +25,6 @@ CONF_RMS = "rms" sound_level_ns = cg.esphome_ns.namespace("sound_level") SoundLevelComponent = sound_level_ns.class_("SoundLevelComponent", cg.Component) -StartAction = sound_level_ns.class_("StartAction", automation.Action) -StopAction = sound_level_ns.class_("StopAction", automation.Action) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -90,17 +88,9 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sound_level.start", - StartAction, - SOUND_LEVEL_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "sound_level.start", SOUND_LEVEL_ACTION_SCHEMA, automation.ApplyCall("start()") ) - - -automation.register_parented_action( - "sound_level.stop", - StopAction, - SOUND_LEVEL_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "sound_level.stop", SOUND_LEVEL_ACTION_SCHEMA, automation.ApplyCall("stop()") ) diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 94c18421baf..008b8d62262 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -7,7 +7,6 @@ #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::sound_level { @@ -59,16 +58,6 @@ class SoundLevelComponent final : public Component { uint32_t measurement_duration_ms_; }; -template class StartAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - } // namespace esphome::sound_level #endif From a1b0e85a9e024dbf1134bab914fd7c3cbaa533e8 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 25 Sep 2026 12:18:32 -0700 Subject: [PATCH 38/94] [core] Report unknown for switch, climate and water heater with no state (#19458) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 9 ++ esphome/components/api/api_connection.cpp | 3 + esphome/components/api/api_pb2.cpp | 6 + esphome/components/api/api_pb2.h | 9 +- esphome/components/api/api_pb2_dump.cpp | 3 + esphome/components/climate/climate.cpp | 1 + esphome/components/switch/switch.cpp | 1 + .../components/template/climate/__init__.py | 1 + .../water_heater/template_water_heater.cpp | 6 +- .../components/water_heater/water_heater.cpp | 1 + .../fixtures/entity_missing_state.yaml | 78 +++++++++++ .../integration/test_entity_missing_state.py | 132 ++++++++++++++++++ 12 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/entity_missing_state.yaml create mode 100644 tests/integration/test_entity_missing_state.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 21972decad1..687dc1ca951 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -803,6 +803,9 @@ message SwitchStateResponse { fixed32 key = 1 [(force) = true]; bool state = 2; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; + // If the switch does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 4; } message SwitchCommandRequest { option (id) = 33; @@ -1244,6 +1247,9 @@ message ClimateStateResponse { float current_humidity = 14; float target_humidity = 15; uint32 device_id = 16 [(field_ifdef) = "USE_DEVICES"]; + // If the climate device does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 17; } message ClimateCommandRequest { option (id) = 48; @@ -1330,6 +1336,9 @@ message WaterHeaterStateResponse { uint32 state = 6; float target_temperature_low = 7; float target_temperature_high = 8; + // If the water heater does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 9; } // Bitmask for WaterHeaterCommandRequest.has_fields diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 3d685e22d98..0207bc14b9f 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -709,6 +709,7 @@ uint16_t APIConnection::try_send_switch_state(EntityBase *entity, APIConnection auto *a_switch = static_cast(entity); SwitchStateResponse resp; resp.state = a_switch->state; + resp.missing_state = !a_switch->has_state(); return fill_and_encode_entity_state(a_switch, resp, conn, remaining_size); } @@ -754,6 +755,7 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection auto traits = climate->get_traits(); resp.mode = static_cast(climate->mode); resp.action = static_cast(climate->action); + resp.missing_state = !climate->has_state(); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) resp.current_temperature = climate->current_temperature; if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | @@ -1447,6 +1449,7 @@ uint16_t APIConnection::try_send_water_heater_state(EntityBase *entity, APIConne auto *wh = static_cast(entity); WaterHeaterStateResponse resp; resp.mode = static_cast(wh->get_mode()); + resp.missing_state = !wh->has_state(); resp.current_temperature = wh->get_current_temperature(); resp.target_temperature = wh->get_target_temperature(); resp.target_temperature_low = wh->get_target_temperature_low(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 9e4000b55bc..1d1f594aa73 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -938,6 +938,7 @@ uint8_t *SwitchStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buf #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.missing_state); return pos; } uint32_t SwitchStateResponse::calc_size_msg(const void *self) { @@ -948,6 +949,7 @@ uint32_t SwitchStateResponse::calc_size_msg(const void *self) { #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, msg.device_id); #endif + size += ProtoSize::calc_bool(1, msg.missing_state); return size; } void SwitchCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -1630,6 +1632,7 @@ uint8_t *ClimateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &bu #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.missing_state); return pos; } uint32_t ClimateStateResponse::calc_size_msg(const void *self) { @@ -1652,6 +1655,7 @@ uint32_t ClimateStateResponse::calc_size_msg(const void *self) { #ifdef USE_DEVICES size += ProtoSize::calc_uint32(2, msg.device_id); #endif + size += ProtoSize::calc_bool(2, msg.missing_state); return size; } void ClimateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -1806,6 +1810,7 @@ uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, ProtoWriteBuffer if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.missing_state); return pos; } uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { @@ -1821,6 +1826,7 @@ uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.state); size += ProtoSize::calc_float(1, msg.target_temperature_low); size += ProtoSize::calc_float(1, msg.target_temperature_high); + size += ProtoSize::calc_bool(1, msg.missing_state); return size; } void WaterHeaterCommandRequest::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 5b3683a1ab9..dbf0fb49a1e 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1109,11 +1109,12 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { class SwitchStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 26; - static constexpr uint8_t ESTIMATED_SIZE = 11; + static constexpr uint8_t ESTIMATED_SIZE = 13; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("switch_state_response"); } #endif bool state{false}; + bool missing_state{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); @@ -1690,7 +1691,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { class ClimateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 47; - static constexpr uint8_t ESTIMATED_SIZE = 68; + static constexpr uint8_t ESTIMATED_SIZE = 71; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_state_response"); } #endif @@ -1707,6 +1708,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { StringRef custom_preset{nullptr, 0}; // null until set, encode only 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); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1786,7 +1788,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { class WaterHeaterStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 133; - static constexpr uint8_t ESTIMATED_SIZE = 35; + static constexpr uint8_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_state_response"); } #endif @@ -1796,6 +1798,7 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { uint32_t state{0}; 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); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bb244973a11..d854a0731be 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1342,6 +1342,7 @@ const char *SwitchStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *SwitchCommandRequest::dump_to(DumpBuffer &out) const { @@ -1684,6 +1685,7 @@ const char *ClimateStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *ClimateCommandRequest::dump_to(DumpBuffer &out) const { @@ -1751,6 +1753,7 @@ const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("state"), this->state); dump_field(out, ESPHOME_PSTR("target_temperature_low"), this->target_temperature_low); dump_field(out, ESPHOME_PSTR("target_temperature_high"), this->target_temperature_high); + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *WaterHeaterCommandRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index f80de151b11..494efd8edc4 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -435,6 +435,7 @@ void Climate::save_state_(const ClimateTraits &traits) { } void Climate::publish_state() { + this->set_has_state(true); ESP_LOGV(TAG, "'%s' >>", this->name_.c_str()); auto traits = this->get_traits(); diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 57e4f222bce..2cc8ad782d9 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -57,6 +57,7 @@ void Switch::publish_state(bool state) { if (!this->publish_dedup_.next(state)) return; this->state = state != this->inverted_; + this->set_has_state(true); if (restore_mode & RESTORE_MODE_PERSISTENT_MASK) this->rtc_.save(&this->state); diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 11ffaf282c3..5f7627c5c38 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -374,6 +374,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_preset(v)) if (v := initial_state.get(CONF_CUSTOM_PRESET)) is not None: cg.add(var.set_custom_preset(v)) + cg.add(var.set_has_state(True)) CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All( diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 9d6a3523d28..7718688d68a 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -91,7 +91,11 @@ void TemplateWaterHeater::loop() { } } - if (changed) { + // The first value always publishes, even one equal to the default (mode OFF, say), or the + // entity would report unknown forever. + const bool has_value = + curr_temp.has_value() || target_temp.has_value() || new_mode.has_value() || away.has_value() || is_on.has_value(); + if (changed || (has_value && !this->has_state())) { this->publish_state(); } } diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 1dc2d008a13..4b65e907de7 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -161,6 +161,7 @@ void WaterHeaterCall::validate_() { } void WaterHeater::publish_state() { + this->set_has_state(true); auto traits = this->get_traits(); ESP_LOGV(TAG, "'%s' >>\n" diff --git a/tests/integration/fixtures/entity_missing_state.yaml b/tests/integration/fixtures/entity_missing_state.yaml new file mode 100644 index 00000000000..6297716c6be --- /dev/null +++ b/tests/integration/fixtures/entity_missing_state.yaml @@ -0,0 +1,78 @@ +esphome: + name: test-missing-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + +# None of these publish a state at startup, so each one starts out unknown +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true + restore_mode: DISABLED + +climate: + - platform: template + name: "Test Climate" + id: test_climate + optimistic: true + supported_modes: + - "OFF" + - HEAT + + # A configured initial state is a state, so this one starts out known + - platform: template + name: "Initial Climate" + id: initial_climate + optimistic: true + restore_mode: NO_RESTORE + supported_modes: + - "OFF" + - HEAT + initial_state: + mode: HEAT + +globals: + - id: lambda_mode_known + type: bool + initial_value: "false" + +water_heater: + - platform: template + name: "Test Water Heater" + id: test_water_heater + supported_modes: + - "OFF" + - ECO + + # Reports OFF, which is also the default, once the MCU-equivalent global says so + - platform: template + name: "Lambda Water Heater" + id: lambda_water_heater + supported_modes: + - "OFF" + - ECO + mode: |- + if (!id(lambda_mode_known)) { + return {}; + } + return water_heater::WATER_HEATER_MODE_OFF; + +button: + - platform: template + name: "Publish States" + id: publish_states_button + on_press: + - switch.turn_on: test_switch + - climate.template.publish: + id: test_climate + mode: HEAT + - water_heater.template.publish: + id: test_water_heater + mode: ECO + - globals.set: + id: lambda_mode_known + value: "true" diff --git a/tests/integration/test_entity_missing_state.py b/tests/integration/test_entity_missing_state.py new file mode 100644 index 00000000000..f87ee213462 --- /dev/null +++ b/tests/integration/test_entity_missing_state.py @@ -0,0 +1,132 @@ +"""Integration test for missing_state on switch, climate and water heater. + +These three entity types always sent a concrete state, so a client could not +tell a real OFF from an entity whose value has not been read yet. They now +report missing_state until something publishes, like every other stateful +entity does. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + ClimateInfo, + ClimateMode, + ClimateState, + EntityInfo, + EntityState, + SwitchInfo, + SwitchState, + WaterHeaterInfo, + WaterHeaterMode, + WaterHeaterState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_entity_missing_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a switch, climate and water heater start out unknown. + + This verifies that: + 1. They report missing_state=True while nothing has published + 2. Publishing clears missing_state and reports the published value + 3. A first value that happens to equal the default still publishes + """ + loop = asyncio.get_running_loop() + futures: dict[int, asyncio.Future[EntityState]] = {} + + def on_state(state: EntityState) -> None: + """Resolve the pending future for the entity that changed.""" + future = futures.get(state.key) + if future is not None and not future.done(): + future.set_result(state) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + switch_info = require_entity(entities, "test_switch", SwitchInfo) + climate_info = require_entity(entities, "test_climate", ClimateInfo) + initial_climate_info = require_entity(entities, "initial_climate", ClimateInfo) + water_heater_info = require_entity( + entities, "test_water_heater", WaterHeaterInfo + ) + lambda_water_heater_info = require_entity( + entities, "lambda_water_heater", WaterHeaterInfo + ) + publish_button = require_entity( + entities, "publish_states", description="Publish States button" + ) + stateful: list[EntityInfo] = [ + switch_info, + climate_info, + water_heater_info, + lambda_water_heater_info, + ] + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Nothing has published yet, so all of them must report unknown + for info in stateful: + state = initial_state_helper.initial_states.get(info.key) + assert state is not None, f"No initial state received for {info.object_id}" + assert state.missing_state is True, ( + f"Initial state for {info.object_id} should have " + f"missing_state=True, got {state}" + ) + + # A configured initial_state must not start out unknown + initial_climate_state = initial_state_helper.initial_states.get( + initial_climate_info.key + ) + assert isinstance(initial_climate_state, ClimateState) + assert initial_climate_state.missing_state is False + assert initial_climate_state.mode is ClimateMode.HEAT + + # Publishing a state on each one clears missing_state + futures = {info.key: loop.create_future() for info in stateful} + client.button_command(publish_button.key) + + try: + await asyncio.wait_for(asyncio.gather(*futures.values()), timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for published states") + + switch_state = futures[switch_info.key].result() + assert isinstance(switch_state, SwitchState) + assert switch_state.missing_state is False + assert switch_state.state is True + + climate_state = futures[climate_info.key].result() + assert isinstance(climate_state, ClimateState) + assert climate_state.missing_state is False + assert climate_state.mode is ClimateMode.HEAT + + water_heater_state = futures[water_heater_info.key].result() + assert isinstance(water_heater_state, WaterHeaterState) + assert water_heater_state.missing_state is False + assert water_heater_state.mode is WaterHeaterMode.ECO + + # Its first value is OFF, which is also the default the entity starts on, + # so it only leaves unknown if the first value publishes regardless + lambda_state = futures[lambda_water_heater_info.key].result() + assert isinstance(lambda_state, WaterHeaterState) + assert lambda_state.missing_state is False + assert lambda_state.mode is WaterHeaterMode.OFF From 456e13adee56822a85096ede7d343dee62e3f92b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:20:44 +0100 Subject: [PATCH 39/94] [ld2410] Use register_apply_action for bluetooth_password.set (#19667) --- esphome/components/ld2410/__init__.py | 24 ++----------------- esphome/components/ld2410/automation.h | 20 ---------------- esphome/components/ld2410/ld2410.h | 1 - tests/components/ld2410/common-actions.yaml | 10 ++++++++ .../ld2410/test-actions.esp8266-ard.yaml | 8 +++++++ 5 files changed, 20 insertions(+), 43 deletions(-) delete mode 100644 esphome/components/ld2410/automation.h create mode 100644 tests/components/ld2410/common-actions.yaml create mode 100644 tests/components/ld2410/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 19786f38d32..f157cb50fd1 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,8 +4,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_THROTTLE, CONF_TIMEOUT -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -86,11 +84,6 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( # Actions -BluetoothPasswordSetAction = ld2410_ns.class_( - "BluetoothPasswordSetAction", automation.Action -) - - BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(LD2410Component), @@ -98,21 +91,8 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "bluetooth_password.set", - BluetoothPasswordSetAction, BLUETOOTH_PASSWORD_SET_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_PASSWORD, "set_bluetooth_password", cg.std_string), ) -async def bluetooth_password_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_PASSWORD], args, cg.std_string) - cg.add(var.set_password(template_)) - return var diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h deleted file mode 100644 index b0b9591d378..00000000000 --- a/esphome/components/ld2410/automation.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "ld2410.h" - -namespace esphome::ld2410 { - -template class BluetoothPasswordSetAction final : public Action { - public: - explicit BluetoothPasswordSetAction(LD2410Component *ld2410_comp) : ld2410_comp_(ld2410_comp) {} - TEMPLATABLE_VALUE(std::string, password) - - void play(const Ts &...x) override { this->ld2410_comp_->set_bluetooth_password(this->password_.value(x...)); } - - protected: - LD2410Component *ld2410_comp_; -}; - -} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 061846f1f19..6c7601df3d2 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -24,7 +24,6 @@ #endif #include "esphome/components/ld24xx/ld24xx.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" #include "esphome/core/helpers.h" #include diff --git a/tests/components/ld2410/common-actions.yaml b/tests/components/ld2410/common-actions.yaml new file mode 100644 index 00000000000..7cd4d6abbc0 --- /dev/null +++ b/tests/components/ld2410/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: LD2410 Actions + on_press: + - bluetooth_password.set: + id: my_ld2410 + password: HiLink + - bluetooth_password.set: + id: my_ld2410 + password: !lambda return "HiLink"; diff --git a/tests/components/ld2410/test-actions.esp8266-ard.yaml b/tests/components/ld2410/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..188d8cb87fc --- /dev/null +++ b/tests/components/ld2410/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ld2410: !include common.yaml + actions: !include common-actions.yaml From 29512cf323ae3da4074566899bbeb4d242f10002 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:20:46 +0100 Subject: [PATCH 40/94] [pmwcs3] Use register_apply_action for the calibration and address actions (#19666) --- esphome/components/pmwcs3/pmwcs3.h | 32 -------------- esphome/components/pmwcs3/sensor.py | 43 +++---------------- tests/components/pmwcs3/common-actions.yaml | 9 ++++ .../pmwcs3/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 19 insertions(+), 69 deletions(-) create mode 100644 tests/components/pmwcs3/common-actions.yaml create mode 100644 tests/components/pmwcs3/test-actions.esp8266-ard.yaml diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index 4ce4a5ce9c7..8ca995fbe7d 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -1,5 +1,4 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -32,35 +31,4 @@ class PMWCS3Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *vwc_sensor_{nullptr}; }; -template class PMWCS3AirCalibrationAction final : public Action { - public: - PMWCS3AirCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->air_calibration(); } - - protected: - PMWCS3Component *parent_; -}; - -template class PMWCS3WaterCalibrationAction final : public Action { - public: - PMWCS3WaterCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->water_calibration(); } - - protected: - PMWCS3Component *parent_; -}; - -template class PMWCS3NewI2cAddressAction final : public Action { - public: - PMWCS3NewI2cAddressAction(PMWCS3Component *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(int, new_address) - - void play(const Ts &...x) override { this->parent_->new_i2c_address(this->new_address_.value(x...)); } - - protected: - PMWCS3Component *parent_; -}; - } // namespace esphome::pmwcs3 diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 552cfafa04d..19d0083fd04 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -10,8 +10,6 @@ from esphome.const import ( ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@SeByDocKy"] @@ -29,17 +27,6 @@ PMWCS3Component = pmwcs3_ns.class_( "PMWCS3Component", cg.PollingComponent, i2c.I2CDevice ) -# Actions -PMWCS3AirCalibrationAction = pmwcs3_ns.class_( - "PMWCS3AirCalibrationAction", automation.Action -) -PMWCS3WaterCalibrationAction = pmwcs3_ns.class_( - "PMWCS3WaterCalibrationAction", automation.Action -) -PMWCS3NewI2cAddressAction = pmwcs3_ns.class_( - "PMWCS3NewI2cAddressAction", automation.Action -) - CONFIG_SCHEMA = ( cv.Schema( { @@ -104,23 +91,18 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( } ) - -automation.register_simple_action( +automation.register_apply_action( "pmwcs3.air_calibration", - PMWCS3AirCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, - synchronous=True, + automation.ApplyCall("air_calibration()"), ) - -automation.register_simple_action( +automation.register_apply_action( "pmwcs3.water_calibration", - PMWCS3WaterCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, - synchronous=True, + automation.ApplyCall("water_calibration()"), ) - PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(PMWCS3Component), @@ -129,21 +111,8 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( key=CONF_ADDRESS, ) - -@automation.register_action( +automation.register_apply_action( "pmwcs3.new_i2c_address", - PMWCS3NewI2cAddressAction, PMWCS3_NEW_I2C_ADDRESS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "new_i2c_address", cg.uint8), ) -async def pmwcs3newi2caddress_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - address = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) - cg.add(var.set_new_address(address)) - return var diff --git a/tests/components/pmwcs3/common-actions.yaml b/tests/components/pmwcs3/common-actions.yaml new file mode 100644 index 00000000000..c87f4c8c5b1 --- /dev/null +++ b/tests/components/pmwcs3/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: PMWCS3 Actions + on_press: + - pmwcs3.air_calibration: + - pmwcs3.water_calibration: + - pmwcs3.new_i2c_address: 0x64 + - pmwcs3.new_i2c_address: + address: !lambda return 0x65; diff --git a/tests/components/pmwcs3/test-actions.esp8266-ard.yaml b/tests/components/pmwcs3/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..33c1f8a83a3 --- /dev/null +++ b/tests/components/pmwcs3/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pmwcs3: !include common.yaml + actions: !include common-actions.yaml From cf9d807bf05bf777405e5d200d83faea2aa07bf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:20:57 +0100 Subject: [PATCH 41/94] [core] Make Action::stop_complex and Action::is_running non-virtual (#19662) --- esphome/core/automation.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index b6058925b4a..61f34d70f45 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -486,7 +486,7 @@ template class Action { this->play(x...); this->play_next_(x...); } - virtual void stop_complex() { + void stop_complex() { if (num_running_) { this->stop(); this->num_running_ = 0; @@ -494,7 +494,7 @@ template class Action { this->stop_next_(); } /// Check if this or any of the following actions are currently running. - virtual bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } + bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } /// The total number of actions that are currently running in this plus any of /// the following actions in the chain. From dfd9d2639f7f5fe3f37517c485df013f26f2cf3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:21:23 +0100 Subject: [PATCH 42/94] [ezo_pmp] Use register_apply_action for the twelve ezo_pmp actions (#19663) --- esphome/components/ezo_pmp/__init__.py | 193 +++--------------- esphome/components/ezo_pmp/ezo_pmp.h | 134 ------------ tests/components/ezo_pmp/common-actions.yaml | 20 ++ .../ezo_pmp/test-actions.esp8266-ard.yaml | 4 + 4 files changed, 53 insertions(+), 298 deletions(-) create mode 100644 tests/components/ezo_pmp/common-actions.yaml create mode 100644 tests/components/ezo_pmp/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 3477de88a4e..1157ef1fb01 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -44,91 +44,18 @@ EZO_PMP_NO_ARGS_ACTION_SCHEMA = maybe_simple_id( } ) -# Actions that do not require more arguments +for _name, _method in ( + ("ezo_pmp.find", "find()"), + ("ezo_pmp.dose_continuously", "dose_continuously()"), + ("ezo_pmp.clear_total_volume_dosed", "clear_total_volume_dosed()"), + ("ezo_pmp.clear_calibration", "clear_calibration()"), + ("ezo_pmp.pause_dosing", "pause_dosing()"), + ("ezo_pmp.stop_dosing", "stop_dosing()"), +): + automation.register_apply_action( + _name, EZO_PMP_NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_method) + ) -EzoPMPFindAction = ezo_pmp_ns.class_("EzoPMPFindAction", automation.Action) -EzoPMPClearTotalVolumeDispensedAction = ezo_pmp_ns.class_( - "EzoPMPClearTotalVolumeDispensedAction", automation.Action -) -EzoPMPClearCalibrationAction = ezo_pmp_ns.class_( - "EzoPMPClearCalibrationAction", automation.Action -) -EzoPMPPauseDosingAction = ezo_pmp_ns.class_( - "EzoPMPPauseDosingAction", automation.Action -) -EzoPMPStopDosingAction = ezo_pmp_ns.class_("EzoPMPStopDosingAction", automation.Action) -EzoPMPDoseContinuouslyAction = ezo_pmp_ns.class_( - "EzoPMPDoseContinuouslyAction", automation.Action -) - -# Actions that require more arguments -EzoPMPDoseVolumeAction = ezo_pmp_ns.class_("EzoPMPDoseVolumeAction", automation.Action) -EzoPMPDoseVolumeOverTimeAction = ezo_pmp_ns.class_( - "EzoPMPDoseVolumeOverTimeAction", automation.Action -) -EzoPMPDoseWithConstantFlowRateAction = ezo_pmp_ns.class_( - "EzoPMPDoseWithConstantFlowRateAction", automation.Action -) -EzoPMPSetCalibrationVolumeAction = ezo_pmp_ns.class_( - "EzoPMPSetCalibrationVolumeAction", automation.Action -) -EzoPMPChangeI2CAddressAction = ezo_pmp_ns.class_( - "EzoPMPChangeI2CAddressAction", automation.Action -) -EzoPMPArbitraryCommandAction = ezo_pmp_ns.class_( - "EzoPMPArbitraryCommandAction", automation.Action -) - - -automation.register_simple_action( - "ezo_pmp.find", - EzoPMPFindAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.dose_continuously", - EzoPMPDoseContinuouslyAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.clear_total_volume_dosed", - EzoPMPClearTotalVolumeDispensedAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.clear_calibration", - EzoPMPClearCalibrationAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.pause_dosing", - EzoPMPPauseDosingAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.stop_dosing", - EzoPMPStopDosingAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -# Actions that require Multiple Args EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( { @@ -139,21 +66,11 @@ EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_volume", - EzoPMPDoseVolumeAction, EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "dose_volume", cg.double), ) -async def ezo_pmp_dose_volume_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - return var EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( @@ -168,24 +85,14 @@ EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_volume_over_time", - EzoPMPDoseVolumeOverTimeAction, EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "dose_volume_over_time({}, {})", + ((CONF_VOLUME, cg.double), (CONF_DURATION, cg.int_)), + ), ) -async def ezo_pmp_dose_volume_over_time_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) - cg.add(var.set_duration(template_)) - - return var EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( @@ -200,26 +107,14 @@ EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_with_constant_flow_rate", - EzoPMPDoseWithConstantFlowRateAction, EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "dose_with_constant_flow_rate({}, {})", + ((CONF_VOLUME_PER_MINUTE, cg.double), (CONF_DURATION, cg.int_)), + ), ) -async def ezo_pmp_dose_with_constant_flow_rate_to_code( - config, action_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME_PER_MINUTE], args, cg.double) - cg.add(var.set_volume(template_)) - - template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) - cg.add(var.set_duration(template_)) - - return var EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( @@ -231,21 +126,11 @@ EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.set_calibration_volume", - EzoPMPSetCalibrationVolumeAction, EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_calibration_volume", cg.double), ) -async def ezo_pmp_set_calibration_volume_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - return var EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( @@ -255,21 +140,11 @@ EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.change_i2c_address", - EzoPMPChangeI2CAddressAction, EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "change_i2c_address", cg.int_), ) -async def ezo_pmp_change_i2c_address_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) - cg.add(var.set_address(template_)) - - return var EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( @@ -279,18 +154,8 @@ EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.arbitrary_command", - EzoPMPArbitraryCommandAction, EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_COMMAND, "exec_arbitrary_command", cg.std_string), ) -async def ezo_pmp_arbitrary_command_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) - cg.add(var.set_command(template_)) - - return var diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index 55283f2d097..ab05302a55b 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -2,7 +2,6 @@ #include "esphome/core/defines.h" #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/i2c/i2c.h" #ifdef USE_BINARY_SENSOR @@ -113,137 +112,4 @@ class EzoPMP final : public PollingComponent, public i2c::I2CDevice { #endif }; -// Action Templates -template class EzoPMPFindAction final : public Action { - public: - EzoPMPFindAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->find(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseContinuouslyAction final : public Action { - public: - EzoPMPDoseContinuouslyAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->dose_continuously(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseVolumeAction final : public Action { - public: - EzoPMPDoseVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->dose_volume(this->volume_.value(x...)); } - TEMPLATABLE_VALUE(double, volume) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseVolumeOverTimeAction final : public Action { - public: - EzoPMPDoseVolumeOverTimeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { - this->ezopmp_->dose_volume_over_time(this->volume_.value(x...), this->duration_.value(x...)); - } - TEMPLATABLE_VALUE(double, volume) - TEMPLATABLE_VALUE(int, duration) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseWithConstantFlowRateAction final : public Action { - public: - EzoPMPDoseWithConstantFlowRateAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { - this->ezopmp_->dose_with_constant_flow_rate(this->volume_.value(x...), this->duration_.value(x...)); - } - TEMPLATABLE_VALUE(double, volume) - TEMPLATABLE_VALUE(int, duration) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPSetCalibrationVolumeAction final : public Action { - public: - EzoPMPSetCalibrationVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->set_calibration_volume(this->volume_.value(x...)); } - TEMPLATABLE_VALUE(double, volume) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPClearTotalVolumeDispensedAction final : public Action { - public: - EzoPMPClearTotalVolumeDispensedAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->clear_total_volume_dosed(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPClearCalibrationAction final : public Action { - public: - EzoPMPClearCalibrationAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->clear_calibration(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPPauseDosingAction final : public Action { - public: - EzoPMPPauseDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->pause_dosing(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPStopDosingAction final : public Action { - public: - EzoPMPStopDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->stop_dosing(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPChangeI2CAddressAction final : public Action { - public: - EzoPMPChangeI2CAddressAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->change_i2c_address(this->address_.value(x...)); } - TEMPLATABLE_VALUE(int, address) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPArbitraryCommandAction final : public Action { - public: - EzoPMPArbitraryCommandAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->exec_arbitrary_command(this->command_.value(x...)); } - TEMPLATABLE_VALUE(std::string, command) - - protected: - EzoPMP *ezopmp_; -}; - } // namespace esphome::ezo_pmp diff --git a/tests/components/ezo_pmp/common-actions.yaml b/tests/components/ezo_pmp/common-actions.yaml new file mode 100644 index 00000000000..2bb94247c63 --- /dev/null +++ b/tests/components/ezo_pmp/common-actions.yaml @@ -0,0 +1,20 @@ +button: + - platform: template + name: EZO PMP Actions + on_press: + - ezo_pmp.change_i2c_address: + id: hcl_pump + address: 104 + - ezo_pmp.change_i2c_address: + id: hcl_pump + address: !lambda return 105; + - ezo_pmp.dose_volume: + id: hcl_pump + volume: !lambda return 2.5; + - ezo_pmp.dose_volume_over_time: + id: hcl_pump + volume: !lambda return 2.5; + duration: !lambda return 3; + - ezo_pmp.arbitrary_command: + id: hcl_pump + command: !lambda return "D,?"; diff --git a/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml b/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..602fa34c9cf --- /dev/null +++ b/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ezo_pmp: !include common.yaml + actions: !include common-actions.yaml From 0c548f77fa34484626afab64118f10593047f444 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:24:57 +0100 Subject: [PATCH 43/94] [bm8563] Use register_apply_action for read_time, write_time and start_timer (#19665) --- esphome/components/bm8563/bm8563.h | 20 ------- esphome/components/bm8563/time.py | 53 +++++-------------- tests/components/bm8563/common-actions.yaml | 7 +++ .../bm8563/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 24 insertions(+), 60 deletions(-) create mode 100644 tests/components/bm8563/common-actions.yaml create mode 100644 tests/components/bm8563/test-actions.esp8266-ard.yaml diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index 5ca9714091d..b9b7a2c3276 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -34,24 +34,4 @@ class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { uint8_t byte_to_bcd2_(uint8_t value); }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; - -template class TimerAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint32_t, duration) - - void play(const Ts &...x) override { - auto duration = this->duration_.value(x...); - this->parent_->start_timer(duration); - } -}; - } // namespace esphome::bm8563 diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index 3a15d0eb1c0..c28976eb872 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -13,9 +11,6 @@ I2C_ADDR = 0x51 bm8563_ns = cg.esphome_ns.namespace("bm8563") BM8563 = bm8563_ns.class_("BM8563", time.RealTimeClock, i2c.I2CDevice) -WriteAction = bm8563_ns.class_("WriteAction", automation.Action) -ReadAction = bm8563_ns.class_("ReadAction", automation.Action) -TimerAction = bm8563_ns.class_("TimerAction", automation.Action) CONFIG_SCHEMA = ( time.TIME_SCHEMA.extend( @@ -28,51 +23,29 @@ CONFIG_SCHEMA = ( ) -automation.register_parented_action( - "bm8563.write_time", - WriteAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(BM8563), - } - ), - synchronous=True, +BM8563_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(BM8563), + } ) +automation.register_apply_action( + "bm8563.write_time", BM8563_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) -@automation.register_action( +automation.register_apply_action( + "bm8563.read_time", BM8563_ACTION_SCHEMA, automation.ApplyCall("read_time()") +) + +automation.register_apply_action( "bm8563.start_timer", - TimerAction, cv.Schema( { cv.GenerateID(): cv.use_id(BM8563), cv.Required(CONF_DURATION): cv.templatable(cv.positive_time_period_seconds), } ), - synchronous=True, -) -async def bm8563_start_timer_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(template_)) - return var - - -automation.register_parented_action( - "bm8563.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(BM8563), - } - ), - synchronous=True, + automation.ApplyField(CONF_DURATION, "start_timer", cg.uint32), ) diff --git a/tests/components/bm8563/common-actions.yaml b/tests/components/bm8563/common-actions.yaml new file mode 100644 index 00000000000..c8175e3177a --- /dev/null +++ b/tests/components/bm8563/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: BM8563 Actions + on_press: + - bm8563.read_time: + - bm8563.start_timer: + duration: !lambda return 60; diff --git a/tests/components/bm8563/test-actions.esp8266-ard.yaml b/tests/components/bm8563/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..eaabbc63fe9 --- /dev/null +++ b/tests/components/bm8563/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bm8563: !include common.yaml + actions: !include common-actions.yaml From 6622e1fe64f48021e8ff1c20b8f0b7ec9792a08e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:28:12 +0100 Subject: [PATCH 44/94] [grove_tb6612fng] Use register_apply_action for the six motor actions (#19664) --- .../components/grove_tb6612fng/__init__.py | 129 +++++------------- .../grove_tb6612fng/grove_tb6612fng.h | 58 -------- .../grove_tb6612fng/common-actions.yaml | 22 +++ .../test-actions.esp8266-ard.yaml | 4 + 4 files changed, 57 insertions(+), 156 deletions(-) create mode 100644 tests/components/grove_tb6612fng/common-actions.yaml create mode 100644 tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index e7937b1f298..2d44d883230 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -20,28 +20,9 @@ grove_tb6612fng_ns = cg.esphome_ns.namespace("grove_tb6612fng") GROVE_TB6612FNG = grove_tb6612fng_ns.class_( "GroveMotorDriveTB6612FNG", cg.Component, i2c.I2CDevice ) -GROVETB6612FNGMotorRunAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorRunAction", automation.Action -) -GROVETB6612FNGMotorBrakeAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorBrakeAction", automation.Action -) -GROVETB6612FNGMotorStopAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorStopAction", automation.Action -) -GROVETB6612FNGMotorStandbyAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorStandbyAction", automation.Action -) -GROVETB6612FNGMotorNoStandbyAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorNoStandbyAction", automation.Action -) -GROVETB6612FNGMotorChangeAddressAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorChangeAddressAction", automation.Action -) - DIRECTION_TYPE = { "FORWARD": 1, - "BACKWARD": 2, + "BACKWARD": -1, } CONFIG_SCHEMA = ( @@ -61,9 +42,8 @@ async def to_code(config): await i2c.register_i2c_device(var, config) -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.run", - GROVETB6612FNGMotorRunAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), @@ -72,99 +52,52 @@ async def to_code(config): cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), - synchronous=True, + automation.ApplyCall( + "dc_motor_run({}, {} * {})", + ((CONF_CHANNEL, cg.uint8), (CONF_DIRECTION, cg.int16), (CONF_SPEED, cg.uint16)), + ), ) -async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - template_speed = await cg.templatable(config[CONF_SPEED], args, cg.uint16) - cg.add(var.set_channel(template_channel)) - cg.add(var.set_speed(template_speed)) - cg.add(var.set_direction(config[CONF_DIRECTION] == "FORWARD")) - return var +CHANNEL_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), + cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), + } +) - -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.break", - GROVETB6612FNGMotorBrakeAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - } - ), - synchronous=True, + CHANNEL_SCHEMA, + automation.ApplyField(CONF_CHANNEL, "dc_motor_brake", cg.uint8), ) -async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_channel)) - return var - - -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.stop", - GROVETB6612FNGMotorStopAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - } - ), - synchronous=True, -) -async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_channel)) - return var - - -automation.register_parented_action( - "grove_tb6612fng.standby", - GROVETB6612FNGMotorStandbyAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - } - ), - synchronous=True, + CHANNEL_SCHEMA, + automation.ApplyField(CONF_CHANNEL, "dc_motor_stop", cg.uint8), ) - -automation.register_parented_action( - "grove_tb6612fng.no_standby", - GROVETB6612FNGMotorNoStandbyAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - } - ), - synchronous=True, +PARENT_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), + } ) +automation.register_apply_action( + "grove_tb6612fng.standby", PARENT_SCHEMA, automation.ApplyCall("standby()") +) -@automation.register_action( +automation.register_apply_action( + "grove_tb6612fng.no_standby", PARENT_SCHEMA, automation.ApplyCall("not_standby()") +) + +automation.register_apply_action( "grove_tb6612fng.change_address", - GROVETB6612FNGMotorChangeAddressAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), cv.Required(CONF_ADDRESS): cv.i2c_address, } ), - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "set_i2c_addr", cg.uint8), ) -async def grove_tb6612fng_change_address_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_channel = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_channel)) - return var diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index a8648025b98..08a3ac2374e 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/components/i2c/i2c.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" // #include "esphome/core/helpers.h" @@ -161,61 +160,4 @@ class GroveMotorDriveTB6612FNG final : public Component, public i2c::I2CDevice { uint8_t buffer_[16]; }; -template -class GROVETB6612FNGMotorRunAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint16_t, speed) - - void set_direction(bool forward) { this->forward_ = forward; } - - void play(const Ts &...x) override { - auto channel = this->channel_.value(x...); - int16_t speed = this->speed_.value(x...); - if (!this->forward_) { - speed = -speed; - } - this->parent_->dc_motor_run(channel, speed); - } - - protected: - bool forward_{true}; -}; - -template -class GROVETB6612FNGMotorBrakeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - - void play(const Ts &...x) override { this->parent_->dc_motor_brake(this->channel_.value(x...)); } -}; - -template -class GROVETB6612FNGMotorStopAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - - void play(const Ts &...x) override { this->parent_->dc_motor_stop(this->channel_.value(x...)); } -}; - -template -class GROVETB6612FNGMotorStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->standby(); } -}; - -template -class GROVETB6612FNGMotorNoStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->not_standby(); } -}; - -template -class GROVETB6612FNGMotorChangeAddressAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, address) - - void play(const Ts &...x) override { this->parent_->set_i2c_addr(this->address_.value(x...)); } -}; - } // namespace esphome::grove_tb6612fng diff --git a/tests/components/grove_tb6612fng/common-actions.yaml b/tests/components/grove_tb6612fng/common-actions.yaml new file mode 100644 index 00000000000..de3537c8f28 --- /dev/null +++ b/tests/components/grove_tb6612fng/common-actions.yaml @@ -0,0 +1,22 @@ +button: + - platform: template + name: Grove Motor Actions + on_press: + - grove_tb6612fng.run: + channel: !lambda return 0; + speed: 100 + direction: FORWARD + id: test_motor + - grove_tb6612fng.stop: + channel: !lambda return 1; + id: test_motor + - grove_tb6612fng.break: + channel: !lambda return 0; + id: test_motor + - grove_tb6612fng.standby: + id: test_motor + - grove_tb6612fng.no_standby: + id: test_motor + - grove_tb6612fng.change_address: + address: 0x15 + id: test_motor diff --git a/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml b/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..f29d189fd58 --- /dev/null +++ b/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + grove_tb6612fng: !include common.yaml + actions: !include common-actions.yaml From 9654dd391225976bb8f5a39b1a0d4a0379dc8672 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:34:38 +0000 Subject: [PATCH 45/94] Bump platformdirs from 4.11.11 to 4.11.12 (#19668) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 275626b1f07..ff0ad9c944a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.11 # native esp-idf toolchain global cache dir +platformdirs==4.11.12 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 5aeab5f361ea853efd2803a8fb3cde9a4539ecb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:40:42 +0100 Subject: [PATCH 46/94] [hdc302x] Use register_apply_action for heater_on and heater_off (#19673) --- esphome/components/hdc302x/hdc302x.h | 18 ---------- esphome/components/hdc302x/sensor.py | 34 ++++--------------- tests/components/hdc302x/common-actions.yaml | 12 +++++++ .../hdc302x/test-actions.esp8266-ard.yaml | 4 +++ 4 files changed, 22 insertions(+), 46 deletions(-) create mode 100644 tests/components/hdc302x/common-actions.yaml create mode 100644 tests/components/hdc302x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h index cc5343ee89c..0cf8b7a3868 100644 --- a/esphome/components/hdc302x/hdc302x.h +++ b/esphome/components/hdc302x/hdc302x.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -48,21 +47,4 @@ class HDC302XComponent final : public PollingComponent, public i2c::I2CDevice { uint32_t conversion_delay_ms_(); }; -template class HeaterOnAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, power) - TEMPLATABLE_VALUE(uint32_t, duration) - - void play(const Ts &...x) override { - auto power_val = this->power_.value(x...); - auto duration_val = this->duration_.value(x...); - this->parent_->start_heater(power_val, duration_val); - } -}; - -template class HeaterOffAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_heater(); } -}; - } // namespace esphome::hdc302x diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 31671c5b1e5..2686ba53223 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -18,8 +18,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -37,10 +35,6 @@ POWER_MODE_OPTIONS = { "ULTRA_LOW_POWER": HDC302XPowerMode.ULTRA_LOW_POWER, } -# Actions -HeaterOnAction = hdc302x_ns.class_("HeaterOnAction", automation.Action) -HeaterOffAction = hdc302x_ns.class_("HeaterOffAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -118,30 +112,14 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "hdc302x.heater_on", - HeaterOnAction, HDC302X_HEATER_ON_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "start_heater({}, {})", ((CONF_POWER, cg.uint16), (CONF_DURATION, cg.uint32)) + ), ) -async def hdc302x_heater_on_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_POWER], args, cg.uint16) - cg.add(var.set_power(template_)) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(template_)) - return var - -automation.register_parented_action( - "hdc302x.heater_off", - HeaterOffAction, - HDC302X_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "hdc302x.heater_off", HDC302X_ACTION_SCHEMA, automation.ApplyCall("stop_heater()") ) diff --git a/tests/components/hdc302x/common-actions.yaml b/tests/components/hdc302x/common-actions.yaml new file mode 100644 index 00000000000..9bc3c13312b --- /dev/null +++ b/tests/components/hdc302x/common-actions.yaml @@ -0,0 +1,12 @@ +button: + - platform: template + name: HDC302x Actions + on_press: + - hdc302x.heater_on: + id: hdc302x_sensor + power: 0x03FF + duration: !lambda return 2000; + - hdc302x.heater_on: + id: hdc302x_sensor + power: !lambda return 0x3FFF; + - hdc302x.heater_off: hdc302x_sensor diff --git a/tests/components/hdc302x/test-actions.esp8266-ard.yaml b/tests/components/hdc302x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..01bf8fac7df --- /dev/null +++ b/tests/components/hdc302x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + hdc302x: !include common.yaml + actions: !include common-actions.yaml From aef3a586de6444ff738f3dbf17025dfd23fb742b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:40:48 +0100 Subject: [PATCH 47/94] [max6956] Use register_apply_action for set_brightness_global and set_brightness_mode (#19672) --- esphome/components/max6956/__init__.py | 46 ++++--------------- esphome/components/max6956/automation.h | 38 --------------- tests/components/max6956/common-actions.yaml | 14 ++++++ .../max6956/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 27 insertions(+), 75 deletions(-) delete mode 100644 esphome/components/max6956/automation.h create mode 100644 tests/components/max6956/common-actions.yaml create mode 100644 tests/components/max6956/test-actions.esp8266-ard.yaml diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 5e45d718999..557f497e1ab 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,8 +11,7 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@looping40"] @@ -29,10 +28,6 @@ max6956_ns = cg.esphome_ns.namespace("max6956") MAX6956 = max6956_ns.class_("MAX6956", cg.Component, i2c.I2CDevice) MAX6956GPIOPin = max6956_ns.class_("MAX6956GPIOPin", cg.GPIOPin) -# Actions -SetCurrentGlobalAction = max6956_ns.class_("SetCurrentGlobalAction", automation.Action) -SetCurrentModeAction = max6956_ns.class_("SetCurrentModeAction", automation.Action) - MAX6956_CURRENTMODE = max6956_ns.enum("MAX6956CURRENTMODE") CURRENT_MODES = { "global": MAX6956_CURRENTMODE.GLOBAL, @@ -103,9 +98,8 @@ async def max6956_pin_to_code(config: ConfigType) -> MockObj: return var -@automation.register_action( +automation.register_apply_action( "max6956.set_brightness_global", - SetCurrentGlobalAction, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(MAX6956), @@ -115,24 +109,12 @@ async def max6956_pin_to_code(config: ConfigType) -> MockObj: }, key=CONF_BRIGHTNESS_GLOBAL, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS_GLOBAL, "set_brightness_global", cg.uint8), + automation.ApplyCall("write_brightness_global()"), ) -async def max6956_set_brightness_global_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, cg.uint8) - cg.add(var.set_brightness_global(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "max6956.set_brightness_mode", - SetCurrentModeAction, cv.maybe_simple_value( { cv.Required(CONF_ID): cv.use_id(MAX6956), @@ -142,18 +124,8 @@ async def max6956_set_brightness_global_to_code( }, key=CONF_BRIGHTNESS_MODE, ), - synchronous=True, + automation.ApplyField( + CONF_BRIGHTNESS_MODE, "set_brightness_mode", MAX6956_CURRENTMODE + ), + automation.ApplyCall("write_brightness_mode()"), ) -async def max6956_set_brightness_mode_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_BRIGHTNESS_MODE], args, MAX6956_CURRENTMODE - ) - cg.add(var.set_brightness_mode(template_)) - return var diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h deleted file mode 100644 index f1db2e32400..00000000000 --- a/esphome/components/max6956/automation.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/max6956/max6956.h" - -namespace esphome::max6956 { - -template class SetCurrentGlobalAction final : public Action { - public: - SetCurrentGlobalAction(MAX6956 *max6956) : max6956_(max6956) {} - - TEMPLATABLE_VALUE(uint8_t, brightness_global) - - void play(const Ts &...x) override { - this->max6956_->set_brightness_global(this->brightness_global_.value(x...)); - this->max6956_->write_brightness_global(); - } - - protected: - MAX6956 *max6956_; -}; - -template class SetCurrentModeAction final : public Action { - public: - SetCurrentModeAction(MAX6956 *max6956) : max6956_(max6956) {} - - TEMPLATABLE_VALUE(max6956::MAX6956CURRENTMODE, brightness_mode) - - void play(const Ts &...x) override { - this->max6956_->set_brightness_mode(this->brightness_mode_.value(x...)); - this->max6956_->write_brightness_mode(); - } - - protected: - MAX6956 *max6956_; -}; -} // namespace esphome::max6956 diff --git a/tests/components/max6956/common-actions.yaml b/tests/components/max6956/common-actions.yaml new file mode 100644 index 00000000000..04886342c67 --- /dev/null +++ b/tests/components/max6956/common-actions.yaml @@ -0,0 +1,14 @@ +button: + - platform: template + name: MAX6956 Actions + on_press: + - max6956.set_brightness_global: 10 + - max6956.set_brightness_global: + id: max6956_1 + brightness_global: !lambda return 5; + - max6956.set_brightness_mode: + id: max6956_1 + brightness_mode: segment + - max6956.set_brightness_mode: + id: max6956_1 + brightness_mode: !lambda return max6956::GLOBAL; diff --git a/tests/components/max6956/test-actions.esp8266-ard.yaml b/tests/components/max6956/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..96c170a9f0e --- /dev/null +++ b/tests/components/max6956/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + max6956: !include common.yaml + actions: !include common-actions.yaml From 0ef68c17fd1eed783cd6014992188d38ace540b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:41:18 +0100 Subject: [PATCH 48/94] [uart_mux] Use the apply helpers for select_local, select_bridge and is_local (#19671) --- esphome/components/uart_mux/__init__.py | 22 +++++++++---------- esphome/components/uart_mux/uart_mux.h | 28 ------------------------- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py index 6c479f0cdd2..f97a7b2cb3e 100644 --- a/esphome/components/uart_mux/__init__.py +++ b/esphome/components/uart_mux/__init__.py @@ -20,9 +20,6 @@ ROUTE_LOCAL = "local" uart_mux_ns = cg.esphome_ns.namespace("uart_mux") UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) -SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action) -SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action) -IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -70,15 +67,16 @@ UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_simple_action( - "uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True -) -automation.register_simple_action( - "uart_mux.select_bridge", - SelectBridgeAction, +automation.register_apply_action( + "uart_mux.select_local", UART_MUX_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("select_local()"), ) -automation.register_simple_condition( - "uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA +automation.register_apply_action( + "uart_mux.select_bridge", + UART_MUX_ACTION_SCHEMA, + automation.ApplyCall("select_bridge()"), +) +automation.register_apply_condition( + "uart_mux.is_local", UART_MUX_ACTION_SCHEMA, "is_local()" ) diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h index 8e32a166434..5b9815a3caf 100644 --- a/esphome/components/uart_mux/uart_mux.h +++ b/esphome/components/uart_mux/uart_mux.h @@ -3,7 +3,6 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/uart/uart_component_esp_idf.h" #include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::uart_mux { @@ -77,32 +76,5 @@ class UARTMux final : public uart::UARTComponent, public Component { bool load_settings_warned_{false}; }; -template class SelectLocalAction final : public Action { - public: - explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_local(); } - - protected: - UARTMux *parent_; -}; - -template class SelectBridgeAction final : public Action { - public: - explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_bridge(); } - - protected: - UARTMux *parent_; -}; - -template class IsLocalCondition final : public Condition { - public: - explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_local(); } - - protected: - UARTMux *parent_; -}; - } // namespace esphome::uart_mux #endif From d8696c4f28ce941815891bf7fa3f84cd8b084bfc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:41:23 +0100 Subject: [PATCH 49/94] [ufire_ise] Use register_apply_action for calibrate_probe_low, calibrate_probe_high and reset (#19670) --- esphome/components/ufire_ise/sensor.py | 54 +++---------------- esphome/components/ufire_ise/ufire_ise.h | 33 ------------ .../components/ufire_ise/common-actions.yaml | 10 ++++ .../ufire_ise/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 20 insertions(+), 81 deletions(-) create mode 100644 tests/components/ufire_ise/common-actions.yaml create mode 100644 tests/components/ufire_ise/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index aab30ff50e4..5399692e3cc 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -13,8 +13,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -27,15 +25,6 @@ UFireISEComponent = ufire_ise_ns.class_( "UFireISEComponent", cg.PollingComponent, i2c.I2CDevice ) -# Actions -UFireISECalibrateProbeLowAction = ufire_ise_ns.class_( - "UFireISECalibrateProbeLowAction", automation.Action -) -UFireISECalibrateProbeHighAction = ufire_ise_ns.class_( - "UFireISECalibrateProbeHighAction", automation.Action -) -UFireISEResetAction = ufire_ise_ns.class_("UFireISEResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -89,51 +78,20 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "ufire_ise.calibrate_probe_low", - UFireISECalibrateProbeLowAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SOLUTION, "calibrate_probe_low", cg.float_), ) -async def ufire_ise_calibrate_probe_low_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) - cg.add(var.set_solution(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "ufire_ise.calibrate_probe_high", - UFireISECalibrateProbeHighAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SOLUTION, "calibrate_probe_high", cg.float_), ) -async def ufire_ise_calibrate_probe_high_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) - cg.add(var.set_solution(template_)) - return var - UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent)}) - -automation.register_simple_action( - "ufire_ise.reset", - UFireISEResetAction, - UFIRE_ISE_RESET_SCHEMA, - synchronous=True, +automation.register_apply_action( + "ufire_ise.reset", UFIRE_ISE_RESET_SCHEMA, automation.ApplyCall("reset_board()") ) diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index 85916f227e5..2393524713c 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" @@ -58,36 +57,4 @@ class UFireISEComponent final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *ph_sensor_{nullptr}; }; -template class UFireISECalibrateProbeLowAction final : public Action { - public: - UFireISECalibrateProbeLowAction(UFireISEComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - - void play(const Ts &...x) override { this->parent_->calibrate_probe_low(this->solution_.value(x...)); } - - protected: - UFireISEComponent *parent_; -}; - -template class UFireISECalibrateProbeHighAction final : public Action { - public: - UFireISECalibrateProbeHighAction(UFireISEComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - - void play(const Ts &...x) override { this->parent_->calibrate_probe_high(this->solution_.value(x...)); } - - protected: - UFireISEComponent *parent_; -}; - -template class UFireISEResetAction final : public Action { - public: - UFireISEResetAction(UFireISEComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->reset_board(); } - - protected: - UFireISEComponent *parent_; -}; - } // namespace esphome::ufire_ise diff --git a/tests/components/ufire_ise/common-actions.yaml b/tests/components/ufire_ise/common-actions.yaml new file mode 100644 index 00000000000..64a7fb287e4 --- /dev/null +++ b/tests/components/ufire_ise/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: uFire ISE Actions + on_press: + - ufire_ise.calibrate_probe_high: + id: ufire_ise_sensor + solution: !lambda return 10.0; + - ufire_ise.calibrate_probe_low: + id: ufire_ise_sensor + solution: !lambda return 4.0; diff --git a/tests/components/ufire_ise/test-actions.esp8266-ard.yaml b/tests/components/ufire_ise/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..9dd9c7d45a7 --- /dev/null +++ b/tests/components/ufire_ise/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ufire_ise: !include common.yaml + actions: !include common-actions.yaml From 447a30da0f486d75c3704b43936d3d05be4f3621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:41:54 +0100 Subject: [PATCH 50/94] [mcp4461] Use register_apply_action for the four wiper actions (#19669) --- esphome/components/mcp4461/output/__init__.py | 49 +++++----------- .../components/mcp4461/output/automation.h | 56 ------------------- .../mcp4461/output/mcp4461_output.h | 8 +++ tests/components/mcp4461/common-actions.yaml | 10 ++++ .../mcp4461/test-actions.esp8266-ard.yaml | 4 ++ 5 files changed, 37 insertions(+), 90 deletions(-) delete mode 100644 esphome/components/mcp4461/output/automation.h create mode 100644 tests/components/mcp4461/common-actions.yaml create mode 100644 tests/components/mcp4461/test-actions.esp8266-ard.yaml diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index d2b4b321742..b95a9e15100 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_INITIAL_VALUE -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import CONF_MCP4461_ID, Mcp4461Component, mcp4461_ns @@ -121,13 +119,6 @@ async def to_code(config: ConfigType) -> None: # ---- Actions ---- -WiperIncreaseAction = mcp4461_ns.class_("WiperIncreaseAction", automation.Action) -WiperDecreaseAction = mcp4461_ns.class_("WiperDecreaseAction", automation.Action) -WiperStoreNonvolatileAction = mcp4461_ns.class_( - "WiperStoreNonvolatileAction", automation.Action -) -WiperSetTerminalAction = mcp4461_ns.class_("WiperSetTerminalAction", automation.Action) - WIPER_ACTION_SCHEMA = automation.maybe_simple_id( {cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper)} ) @@ -144,43 +135,33 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( ) -automation.register_simple_action( +def _char_literal(config: ConfigType, value: str) -> str: + return f"'{value}'" + + +automation.register_apply_action( "mcp4461.wiper.increase", - WiperIncreaseAction, WIPER_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("increase_wiper()"), ) - -automation.register_simple_action( +automation.register_apply_action( "mcp4461.wiper.decrease", - WiperDecreaseAction, WIPER_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("decrease_wiper()"), ) - -automation.register_simple_action( +automation.register_apply_action( "mcp4461.wiper.store_nonvolatile", - WiperStoreNonvolatileAction, WIPER_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("store_nonvolatile()"), ) - -@automation.register_action( +automation.register_apply_action( "mcp4461.wiper.set_terminal", - WiperSetTerminalAction, TERMINAL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_terminal({}, {})", + ((CONF_TERMINAL, cg.char, _char_literal), (CONF_ENABLE, cg.bool_)), + ), ) -async def mcp4461_wiper_terminal_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - wiper = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable( - action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] - ) diff --git a/esphome/components/mcp4461/output/automation.h b/esphome/components/mcp4461/output/automation.h deleted file mode 100644 index 4be317b2f8f..00000000000 --- a/esphome/components/mcp4461/output/automation.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mcp4461_output.h" - -namespace esphome::mcp4461 { - -template class WiperIncreaseAction : public Action { - public: - explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->increase_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperDecreaseAction : public Action { - public: - explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->decrease_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -// Persist the current level to the chip's nonvolatile register immediately — useful with -// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to -// bypass the stability delay of the automatic mirroring. -template class WiperStoreNonvolatileAction : public Action { - public: - explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->store_nonvolatile(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperSetTerminalAction : public Action { - public: - WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable) - : wiper_(wiper), terminal_(terminal), enable_(enable) {} - void play(Ts... x) override { - if (this->enable_) { - this->wiper_->enable_terminal(this->terminal_); - } else { - this->wiper_->disable_terminal(this->terminal_); - } - } - - protected: - Mcp4461Wiper *wiper_; - char terminal_; - bool enable_; -}; - -} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 1052369a744..f0af6037e77 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -33,6 +33,14 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedenable_terminal(terminal); + } else { + this->disable_terminal(terminal); + } + } /// @brief Immediately persist the current wiper level to the chip's nonvolatile register /// (independent of the deferred nonvolatile mirroring / its stability delay) void store_nonvolatile(); diff --git a/tests/components/mcp4461/common-actions.yaml b/tests/components/mcp4461/common-actions.yaml new file mode 100644 index 00000000000..09dbccd554d --- /dev/null +++ b/tests/components/mcp4461/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: MCP4461 Actions + on_press: + - mcp4461.wiper.increase: + id: digipot_wiper_2 + - mcp4461.wiper.set_terminal: + id: digipot_wiper_2 + terminal: w + enable: true diff --git a/tests/components/mcp4461/test-actions.esp8266-ard.yaml b/tests/components/mcp4461/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..d489a2feac9 --- /dev/null +++ b/tests/components/mcp4461/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + mcp4461: !include common.yaml + actions: !include common-actions.yaml From 4f47913745b01b1487f21892ad9aec9a1752821e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:55:21 +0100 Subject: [PATCH 51/94] [rx8130] Use register_apply_action for read_time and write_time (#19674) --- esphome/components/rx8130/rx8130.h | 10 ---------- esphome/components/rx8130/time.py | 29 +++++++++-------------------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 0c738a9b78c..2ab3a5ed2b6 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -18,14 +18,4 @@ class RX8130Component final : public time::RealTimeClock, public i2c::I2CDevice void stop_(bool stop); }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts... x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts... x) override { this->parent_->read_time(); } -}; - } // namespace esphome::rx8130 diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 0e67593fb32..2082c487593 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -9,8 +9,6 @@ CODEOWNERS = ["@beormund"] DEPENDENCIES = ["i2c"] rx8130_ns = cg.esphome_ns.namespace("rx8130") RX8130Component = rx8130_ns.class_("RX8130Component", time.RealTimeClock, i2c.I2CDevice) -WriteAction = rx8130_ns.class_("WriteAction", automation.Action) -ReadAction = rx8130_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -20,27 +18,18 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x32)) -automation.register_parented_action( - "rx8130.write_time", - WriteAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(RX8130Component), - } - ), - synchronous=True, +RX8130_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8130Component), + } ) +automation.register_apply_action( + "rx8130.write_time", RX8130_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) -automation.register_parented_action( - "rx8130.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(RX8130Component), - } - ), - synchronous=True, +automation.register_apply_action( + "rx8130.read_time", RX8130_ACTION_SCHEMA, automation.ApplyCall("read_time()") ) From 27ea1a90e32de2e8ea30eb553700de38822e9773 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:56:31 +0100 Subject: [PATCH 52/94] [hub75] Use register_apply_action for hub75.set_brightness (#19677) --- esphome/components/hub75/display.py | 20 +++----------------- esphome/components/hub75/hub75_component.h | 8 -------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 3522acf049a..08d068d0f03 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -18,8 +18,7 @@ from esphome.const import ( CONF_ROTATION, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, EnumValue -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import EnumValue import esphome.final_validate as fv from esphome.helpers import add_class_to_obj from esphome.types import ConfigType @@ -167,7 +166,6 @@ ROTATIONS = { HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) Hub75Config = cg.global_ns.struct("Hub75Config") Hub75Pins = cg.global_ns.struct("Hub75Pins") -SetBrightnessAction = hub75_ns.class_("SetBrightnessAction", automation.Action) def _merge_board_pins(config: ConfigType) -> ConfigType: @@ -619,9 +617,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_writer(lambda_)) -@automation.register_action( +automation.register_apply_action( "hub75.set_brightness", - SetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HUB75Display), @@ -629,16 +626,5 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.uint8), ) -async def hub75_set_brightness_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) - cg.add(var.set_brightness(template_)) - return var diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 98bc2e52e6f..dbadc56c9df 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -5,7 +5,6 @@ #include #include "esphome/components/display/display_buffer.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -51,13 +50,6 @@ class HUB75Display final : public display::Display { bool enabled_{false}; }; -template class SetBrightnessAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, brightness) - - void play(const Ts &...x) override { this->parent_->set_brightness(this->brightness_.value(x...)); } -}; - } // namespace esphome::hub75 #endif From d96d0b80f7b972e8c1c0e9a0d87ecc8b23e94cb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:56:49 +0100 Subject: [PATCH 53/94] [cm1106] Use register_apply_action for cm1106.calibrate_zero (#19678) --- esphome/components/cm1106/cm1106.h | 11 ----------- esphome/components/cm1106/sensor.py | 9 ++------- tests/components/cm1106/common-actions.yaml | 5 +++++ tests/components/cm1106/test-actions.esp8266-ard.yaml | 8 ++++++++ 4 files changed, 15 insertions(+), 18 deletions(-) create mode 100644 tests/components/cm1106/common-actions.yaml create mode 100644 tests/components/cm1106/test-actions.esp8266-ard.yaml diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 844bfdfa880..e381db7b5cd 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -23,14 +22,4 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); }; -template class CM1106CalibrateZeroAction final : public Action { - public: - CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} - - void play(const Ts &...x) override { this->cm1106_->calibrate_zero(400); } - - protected: - CM1106Component *cm1106_; -}; - } // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index cbb0509ce50..bf12bc38d91 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -22,10 +22,6 @@ cm1106_ns = cg.esphome_ns.namespace("cm1106") CM1106Component = cm1106_ns.class_( "CM1106Component", cg.PollingComponent, uart.UARTDevice ) -CM1106CalibrateZeroAction = cm1106_ns.class_( - "CM1106CalibrateZeroAction", - automation.Action, -) CONFIG_SCHEMA = ( cv.Schema( @@ -70,9 +66,8 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( +automation.register_apply_action( "cm1106.calibrate_zero", - CM1106CalibrateZeroAction, CALIBRATION_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("calibrate_zero(400)"), ) diff --git a/tests/components/cm1106/common-actions.yaml b/tests/components/cm1106/common-actions.yaml new file mode 100644 index 00000000000..dd6115d37a5 --- /dev/null +++ b/tests/components/cm1106/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: CM1106 Calibrate Zero + on_press: + - cm1106.calibrate_zero: diff --git a/tests/components/cm1106/test-actions.esp8266-ard.yaml b/tests/components/cm1106/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..04d7c1aa5d7 --- /dev/null +++ b/tests/components/cm1106/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + cm1106: !include common.yaml + actions: !include common-actions.yaml From 6925c5127e77f9c8821df1314a2f768ad115e997 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:58:34 +0100 Subject: [PATCH 54/94] [ags10] Use register_apply_action for new_i2c_address and set_zero_point (#19676) --- esphome/components/ags10/ags10.cpp | 14 ++++++ esphome/components/ags10/ags10.h | 50 ++++++------------- esphome/components/ags10/sensor.py | 48 +++--------------- tests/components/ags10/common-actions.yaml | 19 +++++++ .../ags10/test-actions.esp8266-ard.yaml | 4 ++ 5 files changed, 58 insertions(+), 77 deletions(-) create mode 100644 tests/components/ags10/common-actions.yaml create mode 100644 tests/components/ags10/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index 230548ae947..377677ccdb8 100644 --- a/esphome/components/ags10/ags10.cpp +++ b/esphome/components/ags10/ags10.cpp @@ -106,6 +106,20 @@ bool AGS10Component::set_zero_point_with_factory_defaults() { return this->set_z bool AGS10Component::set_zero_point_with_current_resistance() { return this->set_zero_point_with(ZP_CURRENT); } +void AGS10Component::set_zero_point(AGS10SetZeroPointActionMode mode, uint16_t value) { + switch (mode) { + case FACTORY_DEFAULT: + this->set_zero_point_with_factory_defaults(); + break; + case CURRENT_VALUE: + this->set_zero_point_with_current_resistance(); + break; + case CUSTOM_VALUE: + this->set_zero_point_with(value); + break; + } +} + bool AGS10Component::set_zero_point_with(uint16_t value) { std::array data{0x00, 0x0C, (uint8_t) ((value >> 8) & 0xFF), (uint8_t) (value & 0xFF), 0}; data[4] = crc8(data.data(), 4, 0xFF, 0x31, true); diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 8ebc8da544a..8b5f51f9711 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -2,11 +2,19 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::ags10 { +enum AGS10SetZeroPointActionMode { + // Zero-point reset. + FACTORY_DEFAULT, + // Zero-point calibration with current resistance. + CURRENT_VALUE, + // Zero-point calibration with custom resistance. + CUSTOM_VALUE, +}; + class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** @@ -47,6 +55,11 @@ class AGS10Component final : public PollingComponent, public i2c::I2CDevice { */ bool set_zero_point_with_current_resistance(); + /** + * Sets zero-point by mode; the value is only used for CUSTOM_VALUE. + */ + void set_zero_point(AGS10SetZeroPointActionMode mode, uint16_t value); + /** * Sets zero-point with the value. */ @@ -100,39 +113,4 @@ class AGS10Component final : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, new_address) - - void play(const Ts &...x) override { this->parent_->new_i2c_address(this->new_address_.value(x...)); } -}; - -enum AGS10SetZeroPointActionMode { - // Zero-point reset. - FACTORY_DEFAULT, - // Zero-point calibration with current resistance. - CURRENT_VALUE, - // Zero-point calibration with custom resistance. - CUSTOM_VALUE, -}; - -template class AGS10SetZeroPointAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, value) - TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) - - void play(const Ts &...x) override { - switch (this->mode_.value(x...)) { - case FACTORY_DEFAULT: - this->parent_->set_zero_point_with_factory_defaults(); - break; - case CURRENT_VALUE: - this->parent_->set_zero_point_with_current_resistance(); - break; - case CUSTOM_VALUE: - this->parent_->set_zero_point_with(this->value_.value(x...)); - break; - } - } -}; } // namespace esphome::ags10 diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8606e7c2477..fbd4bf590e3 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -17,8 +17,6 @@ from esphome.const import ( UNIT_OHM, UNIT_PARTS_PER_BILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CONF_RESISTANCE = "resistance" @@ -28,12 +26,6 @@ DEPENDENCIES = ["i2c"] ags10_ns = cg.esphome_ns.namespace("ags10") AGS10Component = ags10_ns.class_("AGS10Component", cg.PollingComponent, i2c.I2CDevice) -# Actions -AGS10NewI2cAddressAction = ags10_ns.class_( - "AGS10NewI2cAddressAction", automation.Action -) -AGS10SetZeroPointAction = ags10_ns.class_("AGS10SetZeroPointAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -91,24 +83,11 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "ags10.new_i2c_address", - AGS10NewI2cAddressAction, AGS10_NEW_I2C_ADDRESS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "new_i2c_address", cg.uint8), ) -async def ags10newi2caddress_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_new_address(address)) - return var - AGS10SetZeroPointActionMode = ags10_ns.enum("AGS10SetZeroPointActionMode") AGS10_SET_ZERO_POINT_ACTION_MODE = { @@ -128,24 +107,11 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ags10.set_zero_point", - AGS10SetZeroPointAction, AGS10_SET_ZERO_POINT_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_zero_point({}, {})", + ((CONF_MODE, AGS10SetZeroPointActionMode), (CONF_VALUE, cg.uint16)), + ), ) -async def ags10setzeropoint_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - mode = await cg.templatable( - config.get(CONF_MODE), args, AGS10SetZeroPointActionMode - ) - cg.add(var.set_mode(mode)) - value = await cg.templatable(config[CONF_VALUE], args, cg.uint16) - cg.add(var.set_value(value)) - return var diff --git a/tests/components/ags10/common-actions.yaml b/tests/components/ags10/common-actions.yaml new file mode 100644 index 00000000000..6c0e70115b1 --- /dev/null +++ b/tests/components/ags10/common-actions.yaml @@ -0,0 +1,19 @@ +button: + - platform: template + name: AGS10 Zero Point + on_press: + - ags10.set_zero_point: + id: ags10_1 + mode: FACTORY_DEFAULT + - ags10.set_zero_point: + id: ags10_1 + mode: CUSTOM_VALUE + value: 0x1234 + - ags10.set_zero_point: + id: ags10_1 + mode: !lambda return ags10::CURRENT_VALUE; + value: !lambda return 0x2345; + - ags10.new_i2c_address: 0x1B + - ags10.new_i2c_address: + id: ags10_1 + address: !lambda return 0x1C; diff --git a/tests/components/ags10/test-actions.esp8266-ard.yaml b/tests/components/ags10/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..2fb84b12f98 --- /dev/null +++ b/tests/components/ags10/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c_low_freq: !include ../../test_build_components/common/i2c_low_freq/esp8266-ard.yaml + ags10: !include common.yaml + actions: !include common-actions.yaml From a81cf8f5cc2d05c894388ef80c8db6770de3ca9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:58:52 +0100 Subject: [PATCH 55/94] [ufire_ec] Use register_apply_action for calibrate_probe and reset (#19675) --- esphome/components/ufire_ec/sensor.py | 36 +++++--------------------- esphome/components/ufire_ec/ufire_ec.h | 25 ------------------ 2 files changed, 7 insertions(+), 54 deletions(-) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 406de3eff35..5bfa959c044 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -14,8 +14,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_MILLISIEMENS_PER_CENTIMETER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -29,12 +27,6 @@ UFireECComponent = ufire_ec_ns.class_( "UFireECComponent", cg.PollingComponent, i2c.I2CDevice ) -# Actions -UFireECCalibrateProbeAction = ufire_ec_ns.class_( - "UFireECCalibrateProbeAction", automation.Action -) -UFireECResetAction = ufire_ec_ns.class_("UFireECResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -96,25 +88,14 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ufire_ec.calibrate_probe", - UFireECCalibrateProbeAction, UFIRE_EC_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "calibrate_probe({}, {})", + ((CONF_SOLUTION, cg.float_), (CONF_TEMPERATURE, cg.float_)), + ), ) -async def ufire_ec_calibrate_probe_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - solution_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) - temperature_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_solution(solution_)) - cg.add(var.set_temperature(temperature_)) - return var UFIRE_EC_RESET_SCHEMA = cv.Schema( @@ -124,9 +105,6 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) -automation.register_simple_action( - "ufire_ec.reset", - UFireECResetAction, - UFIRE_EC_RESET_SCHEMA, - synchronous=True, +automation.register_apply_action( + "ufire_ec.reset", UFIRE_EC_RESET_SCHEMA, automation.ApplyCall("reset_board()") ) diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index 0928fda9ee2..a7db2b14253 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" @@ -58,28 +57,4 @@ class UFireECComponent final : public PollingComponent, public i2c::I2CDevice { float temperature_coefficient_{0.0}; }; -template class UFireECCalibrateProbeAction final : public Action { - public: - UFireECCalibrateProbeAction(UFireECComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { - this->parent_->calibrate_probe(this->solution_.value(x...), this->temperature_.value(x...)); - } - - protected: - UFireECComponent *parent_; -}; - -template class UFireECResetAction final : public Action { - public: - UFireECResetAction(UFireECComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->reset_board(); } - - protected: - UFireECComponent *parent_; -}; - } // namespace esphome::ufire_ec From e83dc253010494543dee430d53950786c26d37b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 21:28:18 +0100 Subject: [PATCH 56/94] [esp_ldo] Use register_apply_action for esp_ldo.voltage.adjust (#19680) --- esphome/components/esp_ldo/__init__.py | 20 +++----------------- esphome/components/esp_ldo/esp_ldo.h | 13 ------------- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index 46810d422de..23536fe3a28 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -1,12 +1,11 @@ from typing import Any -from esphome.automation import Action, register_action +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_VOLTAGE from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.final_validate import full_config from esphome.types import ConfigType @@ -16,7 +15,6 @@ DOMAIN = "esp_ldo" esp_ldo_ns = cg.esphome_ns.namespace("esp_ldo") EspLdo = esp_ldo_ns.class_("EspLdo", cg.Component) -AdjustAction = esp_ldo_ns.class_("AdjustAction", Action) CHANNELS = (1, 2, 3, 4) CHANNELS_INTERNAL = (1, 2) @@ -123,9 +121,8 @@ def adjusted_ldo_id(value: Any) -> ID: return value -@register_action( +automation.register_apply_action( "esp_ldo.voltage.adjust", - AdjustAction, cv.Schema( { cv.GenerateID(CONF_ID): adjusted_ldo_id, @@ -134,16 +131,5 @@ def adjusted_ldo_id(value: Any) -> ID: ), } ), - synchronous=True, + automation.ApplyField(CONF_VOLTAGE, "adjust_voltage", cg.float_), ) -async def ldo_voltage_adjust_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - template_ = await cg.templatable(config[CONF_VOLTAGE], args, cg.float_) - cg.add(var.set_voltage(template_)) - return var diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index 0451c338dd1..0f0131d4d5a 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -1,7 +1,6 @@ #pragma once #ifdef USE_ESP32_VARIANT_ESP32P4 #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esp_ldo_regulator.h" namespace esphome::esp_ldo { @@ -27,18 +26,6 @@ class EspLdo final : public Component { esp_ldo_channel_handle_t handle_{}; }; -template class AdjustAction final : public Action { - public: - explicit AdjustAction(EspLdo *ldo) : ldo_(ldo) {} - - TEMPLATABLE_VALUE(float, voltage) - - void play(const Ts &...x) override { this->ldo_->adjust_voltage(this->voltage_.value(x...)); } - - protected: - EspLdo *ldo_; -}; - } // namespace esphome::esp_ldo #endif // USE_ESP32_VARIANT_ESP32P4 From 64388318e837fd5d22f73997e90416781366a395 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:47:25 +1000 Subject: [PATCH 57/94] [light] Replace restore_mode with restore_state (#18997) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/light/__init__.py | 166 +++--- esphome/components/light/automation.py | 85 ++- esphome/components/light/effects.py | 3 +- esphome/components/light/light_state.cpp | 69 +-- esphome/components/light/light_state.h | 46 +- esphome/components/light/restore_state.py | 345 +++++++++++ .../components/light/bench_light_call.cpp | 1 - .../light/hoermann_hcp_light_test.cpp | 13 +- tests/components/light/common.yaml | 79 +++ tests/components/light/test.esp32-idf.yaml | 3 +- tests/components/light/test.esp8266-ard.yaml | 3 +- .../components/light/test.nrf52-adafruit.yaml | 3 +- tests/components/light/test.nrf52-mcumgr.yaml | 3 +- tests/components/light/test.rp2040-ard.yaml | 3 +- .../fixtures/light_initial_state.yaml | 46 ++ .../fixtures/light_restore_state.yaml | 35 ++ tests/integration/test_light_initial_state.py | 18 + tests/integration/test_light_restore_state.py | 116 ++++ .../components/light/test_automation.py | 58 ++ .../components/light/test_restore_state.py | 535 ++++++++++++++++++ 20 files changed, 1463 insertions(+), 167 deletions(-) create mode 100644 esphome/components/light/restore_state.py create mode 100644 tests/integration/fixtures/light_restore_state.yaml create mode 100644 tests/integration/test_light_restore_state.py create mode 100644 tests/unit_tests/components/light/test_automation.py create mode 100644 tests/unit_tests/components/light/test_restore_state.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 428d1abf1f4..328d01a167d 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -10,20 +10,13 @@ from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( - CONF_BLUE, - CONF_BRIGHTNESS, - CONF_COLD_WHITE, CONF_COLD_WHITE_COLOR_TEMPERATURE, - CONF_COLOR_BRIGHTNESS, CONF_COLOR_CORRECT, - CONF_COLOR_MODE, - CONF_COLOR_TEMPERATURE, CONF_DEFAULT_TRANSITION_LENGTH, CONF_EFFECTS, CONF_ENTITY_CATEGORY, CONF_FLASH_TRANSITION_LENGTH, CONF_GAMMA_CORRECT, - CONF_GREEN, CONF_ICON, CONF_ID, CONF_INITIAL_STATE, @@ -35,17 +28,14 @@ from esphome.const import ( CONF_ON_TURN_ON, CONF_OUTPUT_ID, CONF_POWER_SUPPLY, - CONF_RED, CONF_RESTORE_MODE, + CONF_RESTORE_STATE, CONF_RGB_ORDER, - CONF_STATE, CONF_TRIGGER_ID, - CONF_WARM_WHITE, CONF_WARM_WHITE_COLOR_TEMPERATURE, CONF_WEB_SERVER, - CONF_WHITE, ) -from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, @@ -64,6 +54,17 @@ from .effects import ( RGB_EFFECTS, validate_effects, ) +from .restore_state import ( + LEGACY_RESTORE_MODES, + RESTORE_STATE_NONE, + RESTORE_STATE_SCHEMA, + _build_state_lambda, + _initial_state_overridden_by_legacy_mode, + _initial_state_statements, + _legacy_cold_boot_statements, + _legacy_restore_statements, + _restore_state_statements, +) from .types import ( # noqa: F401 AddressableLight, AddressableLightState, @@ -273,17 +274,34 @@ def migrate_channel_colors( def _final_validate(config: ConfigType) -> None: - """Validate all recorded effect name references against their target lights. + """Validate every configured light's own resolved config, and all recorded + effect name references against their target lights. - This runs once per light platform instance. If no light platform is configured, - this never runs — but the ID validator will catch the missing light ID separately. + FINAL_VALIDATE_SCHEMA for a platform-based domain like `light:` runs once for + the whole domain, not once per entry -- `config` is the full list of light + platform entries across the file, not a single light's own config. """ + for light_config in config: + restore_mode = light_config.get(CONF_RESTORE_MODE) + if restore_mode is not None: + legacy = LEGACY_RESTORE_MODES[restore_mode] + if _initial_state_overridden_by_legacy_mode( + legacy, light_config.get(CONF_INITIAL_STATE) + ): + _LOGGER.warning( + "[%s] 'initial_state: state' is ignored because 'restore_mode: %s' " + "always sets the light %s at boot; use 'restore_state:' instead for " + "per-field control", + light_config.get(CONF_NAME) or light_config[CONF_ID], + restore_mode, + "ON" if legacy.cold_boot_state else "OFF", + ) + data = _get_data() if not data.effect_refs and not data.effect_cycle_refs: return - # Drain the lists so we only validate once even though - # FINAL_VALIDATE_SCHEMA runs for each light platform instance. + # Drain the lists so each recorded reference is only validated once. refs = data.effect_refs data.effect_refs = [] cycle_refs = data.effect_cycle_refs @@ -328,18 +346,6 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -LightRestoreMode = light_ns.enum("LightRestoreMode") -RESTORE_MODES = { - "RESTORE_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_DEFAULT_OFF, - "RESTORE_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_DEFAULT_ON, - "ALWAYS_OFF": LightRestoreMode.LIGHT_ALWAYS_OFF, - "ALWAYS_ON": LightRestoreMode.LIGHT_ALWAYS_ON, - "RESTORE_INVERTED_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_OFF, - "RESTORE_INVERTED_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_ON, - "RESTORE_AND_OFF": LightRestoreMode.LIGHT_RESTORE_AND_OFF, - "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, -} - # Schema default that also matches the C++ initializer in light_state.h; codegen # skips the setter when the config equals it. DEFAULT_FLASH_TRANSITION_LENGTH = "0s" @@ -354,9 +360,10 @@ LIGHT_SCHEMA = ( cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( mqtt.MQTTJSONLightComponent ), - cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( - RESTORE_MODES, upper=True, space="_" + cv.Exclusive(CONF_RESTORE_MODE, "restore"): cv.one_of( + *LEGACY_RESTORE_MODES, upper=True, space="_" ), + cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA, cv.Optional(CONF_ON_TURN_ON): auto.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightTurnOnTrigger), @@ -435,6 +442,28 @@ class LightType(enum.IntEnum): ADDRESSABLE = 3 +def _apply_default_restore_mode( + default_restore_mode: str, +) -> Callable[[ConfigType], ConfigType]: + # cv.Exclusive has no default, so apply the default here if neither key is configured. + def validator(config: ConfigType) -> ConfigType: + if CONF_RESTORE_MODE not in config and CONF_RESTORE_STATE not in config: + config[CONF_RESTORE_MODE] = cv.one_of( + *LEGACY_RESTORE_MODES, upper=True, space="_" + )(default_restore_mode) + return config + + return validator + + +_BASE_SCHEMAS: dict[LightType, cv.Schema] = { + LightType.BINARY: BINARY_LIGHT_SCHEMA, + LightType.BRIGHTNESS_ONLY: BRIGHTNESS_ONLY_LIGHT_SCHEMA, + LightType.RGB: RGB_LIGHT_SCHEMA, + LightType.ADDRESSABLE: ADDRESSABLE_LIGHT_SCHEMA, +} + + def light_schema( class_: MockObjClass, type_: LightType, @@ -450,25 +479,14 @@ def light_schema( for key, default, validator in [ (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), (CONF_ICON, icon, cv.icon), - ( - CONF_RESTORE_MODE, - default_restore_mode, - cv.enum(RESTORE_MODES, upper=True, space="_"), - ), ]: if default is not cv.UNDEFINED: schema[cv.Optional(key, default=default)] = validator - if type_ == LightType.BINARY: - return BINARY_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.BRIGHTNESS_ONLY: - return BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.RGB: - return RGB_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.ADDRESSABLE: - return ADDRESSABLE_LIGHT_SCHEMA.extend(schema) - - raise ValueError(f"Invalid light type: {type_}") + result = _BASE_SCHEMAS[type_].extend(schema) + if default_restore_mode is not cv.UNDEFINED: + result.add_extra(_apply_default_restore_mode(default_restore_mode)) + return result def validate_color_temperature_channels(value): @@ -487,31 +505,43 @@ def validate_color_temperature_channels(value): @setup_entity("light") async def setup_light_core_(light_var, config, output_var): - cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE])) + # All 8 legacy restore_mode values, and the restore_state key, are just different + # ways to build the same state callback and save_enabled flag that LightState's + # runtime actually understands. + initial_state_config = config.get(CONF_INITIAL_STATE) + initial_statements = await _initial_state_statements(initial_state_config) - if (initial_state_config := config.get(CONF_INITIAL_STATE)) is not None: - # Emit a stateless lambda that constructs the initial state — values live - # in flash as code, not stored in the LightState object (~40 bytes saved). - initial_state = LightStateRTCState( - initial_state_config.get(CONF_COLOR_MODE, ColorMode.UNKNOWN), - initial_state_config.get(CONF_STATE, False), - initial_state_config.get(CONF_BRIGHTNESS, 1.0), - initial_state_config.get(CONF_COLOR_BRIGHTNESS, 1.0), - initial_state_config.get(CONF_RED, 1.0), - initial_state_config.get(CONF_GREEN, 1.0), - initial_state_config.get(CONF_BLUE, 1.0), - initial_state_config.get(CONF_WHITE, 1.0), - initial_state_config.get(CONF_COLOR_TEMPERATURE, 1.0), - initial_state_config.get(CONF_COLD_WHITE, 1.0), - initial_state_config.get(CONF_WARM_WHITE, 1.0), + restore_mode = config.get(CONF_RESTORE_MODE) + restore_state_config = config.get(CONF_RESTORE_STATE) + if restore_state_config == RESTORE_STATE_NONE: + # restore_state: none is explicit shorthand for "no restoring at all" -- + restore_state_config = None + + if restore_mode is not None: + legacy = LEGACY_RESTORE_MODES[restore_mode] + initial_statements.extend( + _legacy_cold_boot_statements(legacy, initial_state_config) ) - args = [(LightStateRTCState.operator("ref"), "s")] - lamb = await cg.process_lambda( - Lambda(f"s = {initial_state};"), - args, - return_type=cg.void, + restore_statements = _legacy_restore_statements(legacy) + save_enabled = legacy.save_enabled + elif restore_state_config is not None: + restore_statements = await _restore_state_statements( + restore_state_config, initial_state_config ) - cg.add(light_var.set_initial_state(lamb)) + save_enabled = True + else: + # Neither key configured: no persistence, and no cold-boot forcing either. + restore_statements = [] + save_enabled = False + + if ( + lamb := await _build_state_lambda( + initial_statements, restore_statements, save_enabled + ) + ) is not None: + cg.add(light_var.set_state_callback(lamb)) + if save_enabled: # matches LightState::save_enabled_'s own default of false + cg.add(light_var.set_save_enabled(save_enabled)) if ( default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 5ae26607746..c938742dac3 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any, NamedTuple + from esphome import automation import esphome.codegen as cg from esphome.config import path_context @@ -28,6 +31,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, EsphomeError, Lambda from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from .types import ( @@ -44,6 +48,28 @@ from .types import ( CONF_INCLUDE_NONE = "include_none" +_STATE_ON_OFF = cv.one_of("ON", "OFF", upper=True) + + +@schema_extractor("one_of") +def validate_light_state(value: Any) -> Any: + """Validate a light on/off state. + + Documented as 'ON'/'OFF', but accepts all boolean forms for backward compatibility. + """ + if value == SCHEMA_EXTRACT: + return ("ON", "OFF") + try: + return _STATE_ON_OFF(value) == "ON" + except cv.Invalid: + pass + try: + return cv.boolean(value) + except cv.Invalid as err: + raise cv.Invalid( + f"Expected 'ON', 'OFF', or a boolean value, got {value!r}" + ) from err + @automation.register_action( "light.toggle", @@ -71,19 +97,56 @@ async def light_toggle_to_code(config, action_id, template_arg, args): return var +class LightStateField(NamedTuple): + """One field of a light's state: the single source for the schemas and boot-time + codegen that deal with it.""" + + conf_key: str + # Member of LightStateRTCState holding this field. + member: str + validator: Callable[[Any], Any] + # The color mode providing this field, if it implies one. + color_mode: MockObj | None = None + templatable: bool = True + + +# In LightStateRTCState member order. +LIGHT_STATE_FIELDS: tuple[LightStateField, ...] = ( + LightStateField(CONF_STATE, "state", validate_light_state), + LightStateField( + CONF_COLOR_MODE, + "color_mode", + cv.enum(COLOR_MODES, upper=True, space="_"), + templatable=False, + ), + LightStateField(CONF_BRIGHTNESS, "brightness", cv.percentage, ColorMode.BRIGHTNESS), + LightStateField( + CONF_COLOR_BRIGHTNESS, "color_brightness", cv.percentage, ColorMode.RGB + ), + LightStateField(CONF_RED, "red", cv.percentage, ColorMode.RGB), + LightStateField(CONF_GREEN, "green", cv.percentage, ColorMode.RGB), + LightStateField(CONF_BLUE, "blue", cv.percentage, ColorMode.RGB), + LightStateField(CONF_WHITE, "white", cv.percentage, ColorMode.WHITE), + LightStateField( + CONF_COLOR_TEMPERATURE, + "color_temp", + cv.color_temperature, + ColorMode.COLOR_TEMPERATURE, + ), + LightStateField( + CONF_COLD_WHITE, "cold_white", cv.percentage, ColorMode.COLD_WARM_WHITE + ), + LightStateField( + CONF_WARM_WHITE, "warm_white", cv.percentage, ColorMode.COLD_WARM_WHITE + ), +) + LIGHT_STATE_SCHEMA = cv.Schema( { - cv.Optional(CONF_COLOR_MODE): cv.enum(COLOR_MODES, upper=True, space="_"), - cv.Optional(CONF_STATE): cv.templatable(cv.boolean), - cv.Optional(CONF_BRIGHTNESS): cv.templatable(cv.percentage), - cv.Optional(CONF_COLOR_BRIGHTNESS): cv.templatable(cv.percentage), - cv.Optional(CONF_RED): cv.templatable(cv.percentage), - cv.Optional(CONF_GREEN): cv.templatable(cv.percentage), - cv.Optional(CONF_BLUE): cv.templatable(cv.percentage), - cv.Optional(CONF_WHITE): cv.templatable(cv.percentage), - cv.Optional(CONF_COLOR_TEMPERATURE): cv.templatable(cv.color_temperature), - cv.Optional(CONF_COLD_WHITE): cv.templatable(cv.percentage), - cv.Optional(CONF_WARM_WHITE): cv.templatable(cv.percentage), + cv.Optional(field.conf_key): ( + cv.templatable(field.validator) if field.templatable else field.validator + ) + for field in LIGHT_STATE_FIELDS } ) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 3ae15f9ee5d..535def0ba02 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -33,6 +33,7 @@ from esphome.cpp_generator import MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry +from .automation import validate_light_state from .types import ( COLOR_MODES, AddressableColorWipeEffect, @@ -281,7 +282,7 @@ async def random_effect_to_code(config, effect_id): cv.ensure_list( cv.Schema( { - cv.Optional(CONF_STATE, default=True): cv.boolean, + cv.Optional(CONF_STATE, default=True): validate_light_state, cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, cv.Optional(CONF_COLOR_MODE): cv.enum( COLOR_MODES, upper=True, space="_" diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 63c311f5a8d..9cbe9639dbf 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -13,6 +13,20 @@ namespace esphome::light { static const char *const TAG = "light"; +// Colour modes are bitmasks of capabilities. A mode the light doesn't support may be a bare set of +// required capabilities (see restore_state.py's colour mode inference): use the first supported +// mode that provides all of them, or leave it unchanged if there is none. +static ColorMode resolve_color_mode(const LightTraits &traits, ColorMode requested) { + if (requested == ColorMode::UNKNOWN || traits.supports_color_mode(requested)) + return requested; + auto wanted = static_cast(requested); + for (ColorMode mode : traits.get_supported_color_modes()) { + if ((static_cast(mode) & wanted) == wanted) + return mode; + } + return requested; +} + LightState::LightState(LightOutput *output) : output_(output) {} LightTraits LightState::get_traits() { return this->output_->get_traits(); } @@ -40,38 +54,14 @@ void LightState::setup() { auto call = this->make_call(); LightStateRTCState recovered{}; - if (this->initial_state_callback_) { - this->initial_state_callback_(recovered); - this->initial_state_callback_ = nullptr; // One-shot — no longer needed + bool restored = false; + if (this->save_enabled_) { + this->rtc_ = this->make_entity_preference(); + restored = this->rtc_.load(&recovered); } - switch (this->restore_mode_) { - case LIGHT_RESTORE_DEFAULT_OFF: - case LIGHT_RESTORE_DEFAULT_ON: - case LIGHT_RESTORE_INVERTED_DEFAULT_OFF: - case LIGHT_RESTORE_INVERTED_DEFAULT_ON: - this->rtc_ = this->make_entity_preference(); - // Attempt to load from preferences, else fall back to default values - if (!this->rtc_.load(&recovered)) { - recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON); - } else if (this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_OFF || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) { - // Inverted restore state - recovered.state = !recovered.state; - } - break; - case LIGHT_RESTORE_AND_OFF: - case LIGHT_RESTORE_AND_ON: - this->rtc_ = this->make_entity_preference(); - this->rtc_.load(&recovered); - recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); - break; - case LIGHT_ALWAYS_OFF: - recovered.state = false; - break; - case LIGHT_ALWAYS_ON: - recovered.state = true; - break; + if (this->state_callback_) { + this->state_callback_(recovered, restored); + this->state_callback_ = nullptr; // One-shot — no longer needed } // A light coming up on boot must never end up on-but-invisible: if the resolved restore @@ -82,7 +72,7 @@ void LightState::setup() { recovered.brightness = 1.0f; } - call.set_color_mode_if_supported(recovered.color_mode); + call.set_color_mode_if_supported(resolve_color_mode(traits, recovered.color_mode)); call.set_state(recovered.state); call.set_brightness_if_supported(recovered.brightness); call.set_color_brightness_if_supported(recovered.color_brightness); @@ -428,17 +418,14 @@ void LightState::set_transformer_remote_values_(const LightColorValues &target, #endif void LightState::save_remote_values_() { + if (!this->save_enabled_) + return; LightStateRTCState saved; saved.color_mode = this->remote_values.get_color_mode(); - switch (this->restore_mode_) { - case LIGHT_RESTORE_AND_OFF: - case LIGHT_RESTORE_AND_ON: - saved.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); - break; - default: - saved.state = this->remote_values.is_on(); - break; - } + // Always the real on/off status (RESTORE_AND_ON/OFF used to persist a hardcoded + // true/false here instead; harmless, since those modes force `state` again on + // every load regardless of what was saved -- see _legacy_restore_statements). + saved.state = this->remote_values.is_on(); saved.brightness = this->remote_values.get_brightness(); saved.color_brightness = this->remote_values.get_color_brightness(); saved.red = this->remote_values.get_red(); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 22ce9bf198b..4f7d3c93302 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -44,33 +44,7 @@ class LightTargetStateReachedListener { virtual void on_light_target_state_reached() = 0; }; -enum LightRestoreMode : uint8_t { - LIGHT_RESTORE_DEFAULT_OFF, - LIGHT_RESTORE_DEFAULT_ON, - LIGHT_ALWAYS_OFF, - LIGHT_ALWAYS_ON, - LIGHT_RESTORE_INVERTED_DEFAULT_OFF, - LIGHT_RESTORE_INVERTED_DEFAULT_ON, - LIGHT_RESTORE_AND_OFF, - LIGHT_RESTORE_AND_ON, -}; - struct LightStateRTCState { - LightStateRTCState(ColorMode color_mode, bool state, float brightness, float color_brightness, float red, float green, - float blue, float white, float color_temp, float cold_white, float warm_white) - : brightness(brightness), - color_brightness(color_brightness), - red(red), - green(green), - blue(blue), - white(white), - color_temp(color_temp), - cold_white(cold_white), - warm_white(warm_white), - effect(0), - color_mode(color_mode), - state(state) {} - LightStateRTCState() = default; // Group 4-byte aligned members first float brightness{1.0f}; float color_brightness{1.0f}; @@ -211,12 +185,14 @@ class LightState : public EntityBase, public Component { float gamma_uncorrect_lut(float value) const { return value; } #endif // USE_LIGHT_GAMMA_LUT - /// Set the restore mode of this light - void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } + /// Set the callback that resolves the boot-time state, called once during setup then + /// cleared. `restored` is true only when a persisted state actually loaded, in which + /// case the state argument already holds the loaded values; otherwise it is freshly + /// default-constructed. Values live in flash as code. + void set_state_callback(void (*callback)(LightStateRTCState &, bool restored)) { this->state_callback_ = callback; } - /// Set a callback to populate the initial state defaults during setup. - /// The callback is called once, then cleared. Values live in flash as code. - void set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } + /// Set whether this light persists its state to preferences at all. + void set_save_enabled(bool save_enabled) { this->save_enabled_ = save_enabled; } /// Return whether the light has any effects that meet the trait requirements. bool supports_effects() const { return !this->effects_.empty(); } @@ -382,9 +358,9 @@ class LightState : public EntityBase, public Component { */ std::unique_ptr> target_state_reached_listeners_; - /// Callback to populate initial state defaults — called once during setup, then cleared. + /// Callback that resolves the boot-time state — called once during setup, then cleared. /// Values live in flash as function body; no per-instance data storage beyond this pointer. - void (*initial_state_callback_)(LightStateRTCState &){nullptr}; + void (*state_callback_)(LightStateRTCState &, bool restored){nullptr}; /// Value for storing the index of the currently active effect. 0 if no effect is active uint32_t active_effect_index_{}; @@ -406,12 +382,12 @@ class LightState : public EntityBase, public Component { bool next_write_{true}; // for effects, true if a transformer (transition) is active. bool is_transformer_active_{false}; + /// Whether this light persists its state to preferences at all. + bool save_enabled_{false}; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL /// True while the active transformer publishes current_values on an interval from loop(). bool transition_publish_enabled_{false}; #endif - /// Restore mode of the light. - LightRestoreMode restore_mode_; }; } // namespace esphome::light diff --git a/esphome/components/light/restore_state.py b/esphome/components/light/restore_state.py new file mode 100644 index 00000000000..712b4895478 --- /dev/null +++ b/esphome/components/light/restore_state.py @@ -0,0 +1,345 @@ +""" +Functions and classes to translate the `restore_mode:` and `restore_state:` config keys into +the single runtime state callback that `LightState` actually understands. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COLOR_MODE, CONF_STATE +from esphome.core import Lambda +from esphome.cpp_generator import call_lambda +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType + +from .automation import LIGHT_STATE_FIELDS, validate_light_state +from .types import ColorMode, LightStateRTCState + +RESTORE_STATE_KEEP = "KEEP" +RESTORE_STATE_INVERT = "INVERT" +RESTORE_STATE_INITIAL = "INITIAL" +RESTORE_STATE_ALL = "ALL" +RESTORE_STATE_NONE = "NONE" + + +@dataclass(frozen=True) +class LegacyRestoreMode: + cold_boot_state: bool + restore_action: bool | str | None # None = no override, "INVERT", or force-to-bool + save_enabled: bool + + +LEGACY_RESTORE_MODES: dict[str, LegacyRestoreMode] = { + "RESTORE_DEFAULT_OFF": LegacyRestoreMode(False, None, True), + "RESTORE_DEFAULT_ON": LegacyRestoreMode(True, None, True), + "ALWAYS_OFF": LegacyRestoreMode(False, None, False), + "ALWAYS_ON": LegacyRestoreMode(True, None, False), + "RESTORE_INVERTED_DEFAULT_OFF": LegacyRestoreMode( + False, RESTORE_STATE_INVERT, True + ), + "RESTORE_INVERTED_DEFAULT_ON": LegacyRestoreMode(True, RESTORE_STATE_INVERT, True), + "RESTORE_AND_OFF": LegacyRestoreMode(False, False, True), + "RESTORE_AND_ON": LegacyRestoreMode(True, True, True), +} + +# (config key, LightStateRTCState member) for every field, and for every field but `state`. +_ALL_STATE_FIELDS: tuple[tuple[str, str], ...] = tuple( + (field.conf_key, field.member) for field in LIGHT_STATE_FIELDS +) +_STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple( + (conf_key, member) + for conf_key, member in _ALL_STATE_FIELDS + if conf_key != CONF_STATE +) +# Canonical struct-member order, used only to make generated code deterministic -- +# these are independent field assignments, so the actual order never affects behavior. +_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS) + +# A pending `s. = ;` statement, tagged with the member it writes. +StateStatement = tuple[str, str] + + +def _inferred_color_mode(initial_state_config: ConfigType | None) -> str | None: + """A C++ expression for the capabilities needed by the colour fields `initial_state:` + sets, or None if `color_mode:` is given or no colour field is set. + + Colour modes are bitmasks of capabilities, so the result is a bare capability set, + not necessarily a real mode; LightState::setup() resolves it to a mode the light + actually supports. + """ + if not initial_state_config or CONF_COLOR_MODE in initial_state_config: + return None + modes = sorted( + { + str(field.color_mode) + for field in LIGHT_STATE_FIELDS + if field.color_mode is not None + and initial_state_config.get(field.conf_key) is not None + } + ) + if not modes: + return None + mask = " | ".join(f"static_cast({mode})" for mode in modes) + return f"static_cast<{ColorMode}>({mask})" + + +def _partition_state_statements( + initial_statements: list[StateStatement], + restore_statements: list[StateStatement], + save_enabled: bool, +) -> list[str]: + """Split initial/restore statements into what must run unconditionally versus what + depends on `restored`, and render the resulting lambda body lines. + + Fields whose statement is identical in both branches (e.g. RESTORE_AND_ON's + cold-boot and restore-time statements are both "s.state = true;") are hoisted out + of the `restored` branch entirely, so only the fields that actually depend on + `restored` end up inside it -- down to no branch at all when every field overlaps. + A member appearing more than once in the same list keeps only its last statement + (a plain, side-effect-free assignment): matches sequential-execution semantics, + since an earlier write to the same member is always fully overwritten by a later + one in the original code this replaces. + + `save_enabled` is false exactly when `restore_statements` is empty and `restored` + is unconditionally false at the call site (nothing is ever loaded), so the + initial-only branch can skip its `if (!restored)` guard entirely. + """ + # dict() over (member, statement) pairs keeps the *last* entry per member. + initial_map = dict(initial_statements) + restore_map = dict(restore_statements) + # A member outside _MEMBER_ORDER would be silently skipped below instead of + # raising -- catch that here so a typo doesn't turn into wrong state on a device. + assert set(initial_map) <= set(_MEMBER_ORDER) + assert set(restore_map) <= set(_MEMBER_ORDER) + + common: list[str] = [] + only_initial: list[str] = [] + only_restore: list[str] = [] + for member in _MEMBER_ORDER: + initial_stmt = initial_map.get(member) + restore_stmt = restore_map.get(member) + if initial_stmt is not None and initial_stmt == restore_stmt: + common.append(initial_stmt) + continue + if initial_stmt is not None: + only_initial.append(initial_stmt) + if restore_stmt is not None: + only_restore.append(restore_stmt) + + body = common + if only_restore and only_initial: + body += ["if (restored) {", *only_restore, "} else {", *only_initial, "}"] + elif only_restore: + body += ["if (restored) {", *only_restore, "}"] + elif only_initial: + if save_enabled: + body += ["if (!restored) {", *only_initial, "}"] + else: + body += only_initial + return body + + +async def _build_state_lambda( + initial_statements: list[StateStatement], + restore_statements: list[StateStatement], + save_enabled: bool, +) -> Lambda | None: + """ + Combine the initial and restore statements into a single lambda that applies the + correct values to a `LightStateRTCState &s` depending on whether a persisted state + actually loaded. + """ + if not initial_statements and not restore_statements: + return None + body = _partition_state_statements( + initial_statements, restore_statements, save_enabled + ) + args = [(LightStateRTCState.operator("ref"), "s"), (cg.bool_, "restored")] + return await cg.process_lambda(Lambda("\n".join(body)), args, return_type=cg.void) + + +async def _process_value(value: Any, member: str) -> str: + if isinstance(value, Lambda): + return_type = cg.bool_ if member == CONF_STATE else cg.float_ + lamb = await cg.process_lambda(value, [], return_type=return_type) + return call_lambda(lamb) + return cg.safe_exp(value) + + +async def _initial_state_statements( + initial_state_config: ConfigType | None, +) -> list[StateStatement]: + """ + Create assignments for every field the user set in `initial_state:`, in canonical + struct-member order. A field given as `!lambda` is resolved and called immediately, + the same way `light.control`'s own field lambdas are. + """ + if not initial_state_config: + return [] + statements: list[StateStatement] = [] + for conf_key, member in _ALL_STATE_FIELDS: + if (value := initial_state_config.get(conf_key)) is None: + continue + statements.append( + (member, f"s.{member} = {await _process_value(value, member)};") + ) + if (inferred := _inferred_color_mode(initial_state_config)) is not None: + statements.append(("color_mode", f"s.color_mode = {inferred};")) + return statements + + +async def _resolve_initial_value( + conf_key: str, member: str, initial_state_config: ConfigType | None +) -> str: + """ + Return the C++ expression to use for a `restore_state:` field whose value is INITIAL + """ + if ( + initial_state_config is not None + and (value := initial_state_config.get(conf_key)) is not None + ): + return await _process_value(value, member) + if conf_key == CONF_COLOR_MODE and ( + inferred := _inferred_color_mode(initial_state_config) + ): + return inferred + return f"LightStateRTCState{{}}.{member}" + + +async def _restore_state_statements( + restore_state_config: ConfigType, initial_state_config: ConfigType | None +) -> list[StateStatement]: + """ + Create a list of statements to apply the user's `restore_state:` config + """ + statements: list[StateStatement] = [] + state = restore_state_config[CONF_STATE] + if state == RESTORE_STATE_INVERT: + statements.append(("state", "s.state = !s.state;")) + elif state == RESTORE_STATE_INITIAL: + expr = await _resolve_initial_value(CONF_STATE, "state", initial_state_config) + statements.append(("state", f"s.state = {expr};")) + elif state != RESTORE_STATE_KEEP: + statements.append(("state", f"s.state = {cg.safe_exp(state)};")) + for conf_key, member in _STATE_STRUCT_FIELDS: + value = restore_state_config[conf_key] + if value == RESTORE_STATE_INITIAL: + expr = await _resolve_initial_value(conf_key, member, initial_state_config) + elif value == RESTORE_STATE_KEEP: + continue + else: + expr = cg.safe_exp(value) + statements.append((member, f"s.{member} = {expr};")) + return statements + + +def _legacy_restore_statements(mode: LegacyRestoreMode) -> list[StateStatement]: + """ + Create a list of statements to apply the legacy restore_mode: behavior. + """ + if mode.restore_action is None: + return [] + if mode.restore_action == RESTORE_STATE_INVERT: + return [("state", "s.state = !s.state;")] + return [("state", f"s.state = {str(mode.restore_action).lower()};")] + + +def _legacy_cold_boot_statements( + mode: LegacyRestoreMode, initial_state_config: ConfigType | None +) -> list[StateStatement]: + """ + Create a list of statements to apply the legacy restore_mode: cold-boot behavior. + """ + existing_state = ( + initial_state_config.get(CONF_STATE) if initial_state_config else None + ) + if existing_state is None: + if not mode.cold_boot_state: + return [] # already matches LightStateRTCState's own default + elif existing_state == mode.cold_boot_state: + return [] # initial_state: already set exactly this value + return [("state", f"s.state = {str(mode.cold_boot_state).lower()};")] + + +def _initial_state_overridden_by_legacy_mode( + mode: LegacyRestoreMode, initial_state_config: ConfigType | None +) -> bool: + """ + Is the user-config `initial_state:` value for `state` overridden by the legacy mode? + """ + if initial_state_config is None or CONF_STATE not in initial_state_config: + return False + return initial_state_config[CONF_STATE] != mode.cold_boot_state + + +def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: + """ + Extend a validator to also accept the `KEEP`/`INITIAL` options + """ + + @schema_extractor("one_of") + def validate(value: Any) -> Any: + if value == SCHEMA_EXTRACT: + # Editor completion: the sentinels plus the wrapped validator's own values, + # if it is an enum. Numeric validators have none to offer. + try: + inner = tuple(validator(SCHEMA_EXTRACT)) + except cv.Invalid: + inner = () + return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner) + if isinstance(value, str): + upper = value.strip().upper() + if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL): + return upper + return validator(value) + + return validate + + +@schema_extractor("one_of") +def _validate_restore_state_state(value: Any) -> str | bool: + if value == SCHEMA_EXTRACT: + return ( + RESTORE_STATE_KEEP, + RESTORE_STATE_INVERT, + RESTORE_STATE_INITIAL, + *validate_light_state(SCHEMA_EXTRACT), + ) + if isinstance(value, str): + upper = value.strip().upper() + if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL): + return upper + return validate_light_state(value) + + +_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema( + { + cv.Optional(field.conf_key, default=RESTORE_STATE_KEEP): ( + _validate_restore_state_state + if field.conf_key == CONF_STATE + else _keep_or(field.validator) + ) + for field in LIGHT_STATE_FIELDS + } +) + + +@schema_extractor("schema") +def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str: + """ + The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`. + """ + if value == SCHEMA_EXTRACT: + # The `all`/`none` string shorthands have no representation in the extracted + # docs schema; only the per-field mapping form is walked here. + return _RESTORE_STATE_FIELDS_SCHEMA + if isinstance(value, str): + upper = value.strip().upper() + if upper == RESTORE_STATE_ALL: + value = {} + elif upper == RESTORE_STATE_NONE: + return RESTORE_STATE_NONE + return _RESTORE_STATE_FIELDS_SCHEMA(value) diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp index c1ef0c425e0..cc6871654de 100644 --- a/tests/benchmarks/components/light/bench_light_call.cpp +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -39,7 +39,6 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { light.set_default_transition_length(0); light.set_gamma_correct(2.8f); light.set_gamma_table(bench_gamma_2_8_fwd); - light.set_restore_mode(light::LIGHT_ALWAYS_OFF); } // --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp index ed7e81b2797..75b44a3af6e 100644 --- a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -24,14 +24,17 @@ class CountingHoermannHcpLight : public HoermannHcpLight { int writes{0}; }; -// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +// Drives the platform against a real LightState. Boots off (no persistence) by default, which +// keeps setup() clear of preferences. struct LightFixture { TestableHoermannHcp door; CountingHoermannHcpLight output{&door}; light::LightState state{&output}; - explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { - this->state.set_restore_mode(restore_mode); + explicit LightFixture(bool boot_on = false) { + if (boot_on) { + this->state.set_state_callback([](light::LightStateRTCState &s, bool /*restored*/) { s.state = true; }); + } this->output.setup(); // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the // boot ordering tests need to be able to place around the bus controller coming up. @@ -621,10 +624,10 @@ TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarme EXPECT_EQ(door.light_toggle_released_at_, 0u); } -// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// Booting the entity on replays a lit state the door has never confirmed, so it has to be // adopted back to what is known rather than turned into a command. TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { - LightFixture fixture{light::LIGHT_ALWAYS_ON}; + LightFixture fixture{/*boot_on=*/true}; connect_controller(fixture.door); fixture.settle(); diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 5d78c411e7a..e1135098e4d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -173,15 +173,69 @@ number: id: test_monochromatic_light brightness: !lambda "return x / 100.0;" +output: + - platform: template + id: light_test_restore_state_keep + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_override + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_none + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_initial + type: binary + write_action: + - lambda: "" light: - platform: binary id: test_binary_light name: Binary Light output: light_test_binary + restore_mode: RESTORE_AND_ON effects: - strobe: on_state: - logger.log: Binary light state changed + # Test restore_state: all (shorthand for {} -- restore everything, no overrides) + - platform: binary + id: test_restore_state_keep + name: Restore State Keep + output: light_test_restore_state_keep + restore_state: all + # Test restore_state: with explicit overrides + - platform: binary + id: test_restore_state_override + name: Restore State Override + output: light_test_restore_state_override + restore_state: + state: INVERT + brightness: 50% + # Test restore_state: none (explicit shorthand for "no restoring at all") + - platform: binary + id: test_restore_state_none + name: Restore State None + output: light_test_restore_state_none + restore_state: none + initial_state: + state: true + # Test restore_state: with INITIAL fields (copy initial_state:'s value on restore) + - platform: binary + id: test_restore_state_initial + name: Restore State Initial + output: light_test_restore_state_initial + initial_state: + state: true + restore_state: + state: INITIAL + brightness: INITIAL - platform: monochromatic id: test_monochromatic_light name: Monochromatic Light @@ -282,6 +336,31 @@ light: red: 100% green: 50% blue: 50% + # Test initial_state: with every templatable field set via !lambda (color_mode is + # the only field that isn't templatable, so it's the only one left as a literal) + - platform: rgbww + id: test_rgbww_light_initial_state_lambda + name: RGBWW Light Initial State Lambda + red: test_ledc_1 + green: test_ledc_2 + blue: test_ledc_3 + cold_white: test_ledc_4 + warm_white: test_ledc_5 + cold_white_color_temperature: 153 mireds + warm_white_color_temperature: 500 mireds + color_interlock: true + initial_state: + state: !lambda "return true;" + color_mode: rgb_cold_warm_white + brightness: !lambda "return 0.8;" + color_brightness: !lambda "return 0.9;" + red: !lambda "return 1.0;" + green: !lambda "return 0.5;" + blue: !lambda "return 0.25;" + white: !lambda "return 0.3;" + color_temperature: !lambda "return 300.0;" + cold_white: !lambda "return 0.6;" + warm_white: !lambda "return 0.4;" # Test StrobeLightEffect with multiple colors - platform: monochromatic id: test_strobe_multiple diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 49e49b43187..14300244a73 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 1eb58eabc43..7f61b1ebd5a 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 16 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 08f5f398103..76224f9c06e 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 08f5f398103..76224f9c06e 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index 21d5cad7744..f6a5ce62950 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 5 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/integration/fixtures/light_initial_state.yaml b/tests/integration/fixtures/light_initial_state.yaml index 052de0a4e58..34b5be9fdcf 100644 --- a/tests/integration/fixtures/light_initial_state.yaml +++ b/tests/integration/fixtures/light_initial_state.yaml @@ -6,6 +6,21 @@ logger: level: DEBUG output: + - platform: template + id: test_inferred_red + type: float + write_action: + - lambda: "" + - platform: template + id: test_inferred_green + type: float + write_action: + - lambda: "" + - platform: template + id: test_inferred_blue + type: float + write_action: + - lambda: "" - platform: template id: test_red type: float @@ -26,6 +41,11 @@ output: type: float write_action: - lambda: "" + - platform: template + id: test_no_restore_key_output + type: float + write_action: + - lambda: "" light: - platform: rgb @@ -55,3 +75,29 @@ light: color_mode: BRIGHTNESS state: false brightness: 0% + + # With neither restore_mode nor restore_state configured, initial_state: must be + # honored as-is (no implicit forcing to OFF) -- unlike every explicit restore_mode: + # value, which always overrides `state` regardless of initial_state. + - platform: monochromatic + name: "Test No Restore Key Light" + id: test_no_restore_key_light + output: test_no_restore_key_output + initial_state: + color_mode: BRIGHTNESS + state: true + brightness: 60% + + # No color_mode: it must be inferred from the colour fields, not left unknown (which + # would drop the colours and leave the light full white). + - platform: rgb + name: "Test Inferred Color Mode Light" + id: test_inferred_color_mode_light + red: test_inferred_red + green: test_inferred_green + blue: test_inferred_blue + initial_state: + state: true + red: 30% + green: 0% + blue: 0% diff --git a/tests/integration/fixtures/light_restore_state.yaml b/tests/integration/fixtures/light_restore_state.yaml new file mode 100644 index 00000000000..ac928a76c60 --- /dev/null +++ b/tests/integration/fixtures/light_restore_state.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-light-restore-state-test +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: out_keep + type: float + write_action: + - lambda: "" + - platform: template + id: out_override + type: float + write_action: + - lambda: "" + +light: + # restore_state: {} -- restore everything exactly as saved, no overrides + - platform: monochromatic + name: "Test Light Keep" + id: test_light_keep + output: out_keep + restore_state: {} + + # restore_state: with explicit overrides -- these must win over whatever was saved + - platform: monochromatic + name: "Test Light Override" + id: test_light_override + output: out_override + restore_state: + state: INVERT + brightness: 100% diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index 12ebf7c4a1a..12d17bdee6c 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -43,3 +43,21 @@ async def test_light_initial_state( restore_and_on_state = helper.initial_states[restore_and_on_light.key] assert restore_and_on_state.state is True assert restore_and_on_state.brightness == pytest.approx(1.0) + + # With neither restore_mode nor restore_state configured, initial_state: must + # be honored as-is -- unlike every explicit restore_mode: value, omitting both + # keys entirely must not force the light off. + no_restore_key_light = require_entity(entities, "test_no_restore_key_light") + no_restore_key_state = helper.initial_states[no_restore_key_light.key] + assert no_restore_key_state.state is True + assert no_restore_key_state.brightness == pytest.approx(0.6, abs=0.01) + + # color_mode omitted: inferred from the colour fields, so they are applied (the + # light comes up red rather than the default white). RGB values are normalised to + # proportions, so 30% red on its own is pure red. + inferred_light = require_entity(entities, "test_inferred_color_mode_light") + inferred_state = helper.initial_states[inferred_light.key] + assert inferred_state.state is True + assert inferred_state.red == pytest.approx(1.0, abs=0.01) + assert inferred_state.green == pytest.approx(0.0, abs=0.01) + assert inferred_state.blue == pytest.approx(0.0, abs=0.01) diff --git a/tests/integration/test_light_restore_state.py b/tests/integration/test_light_restore_state.py new file mode 100644 index 00000000000..57727938643 --- /dev/null +++ b/tests/integration/test_light_restore_state.py @@ -0,0 +1,116 @@ +"""Integration test for the light `restore_state:` key. + +Tests that: +1. On first boot (nothing saved yet), lights come up at their hardware default (off) -- + restore_state overrides never apply before anything is ever saved. +2. After a state is saved and the device restarts, a `restore_state: {}` light comes + back exactly as saved, while a light with explicit overrides applies those overrides + on top of the loaded state regardless of what was saved. +""" + +from __future__ import annotations + +import asyncio +import socket +from typing import Any + +from aioesphomeapi import LightInfo, LightState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-light-restore-state-test" + + +@pytest.mark.asyncio +async def test_light_restore_state( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test restore_state: {} vs restore_state: with overrides, across a restart.""" + port, port_socket = reserved_tcp_port + + clear_host_prefs(DEVICE_NAME) + + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + port_socket.close() + + # --- First run: nothing saved yet, both lights must come up off --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + keep_light = require_entity(entities, "test_light_keep", LightInfo) + override_light = require_entity(entities, "test_light_override", LightInfo) + + # A single subscription serves both initial-state capture and later + # state-change waits -- a second subscribe_states call would restart the + # device's initial-state iterator and could resolve a wait on replayed data. + loop = asyncio.get_running_loop() + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + + keep_initial = helper.initial_states[keep_light.key] + assert isinstance(keep_initial, LightState) + assert keep_initial.state is False + + override_initial = helper.initial_states[override_light.key] + assert isinstance(override_initial, LightState) + assert override_initial.state is False + + # Set both lights to a known, saved state + async def wait_for_state(key: int, timeout: float = 2.0) -> Any: + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + client.light_command(key=keep_light.key, state=True, brightness=0.64) + await wait_for_state(keep_light.key) + + client.light_command(key=override_light.key, state=True, brightness=0.3) + await wait_for_state(override_light.key) + + # --- Second run: same binary, same prefs file --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + keep_light = require_entity(entities, "test_light_keep", LightInfo) + override_light = require_entity(entities, "test_light_override", LightInfo) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(lambda s: None)) + await helper.wait_for_initial_states() + + # restore_state: {} -- comes back exactly as saved + keep_state = helper.initial_states[keep_light.key] + assert isinstance(keep_state, LightState) + assert keep_state.state is True + assert keep_state.brightness == pytest.approx(0.64, abs=0.01) + + # restore_state: with overrides -- state inverted, brightness forced to 100% + # regardless of what was actually saved (0.3) + override_state = helper.initial_states[override_light.key] + assert isinstance(override_state, LightState) + assert override_state.state is False + assert override_state.brightness == pytest.approx(1.0, abs=0.01) + + clear_host_prefs(DEVICE_NAME) diff --git a/tests/unit_tests/components/light/test_automation.py b/tests/unit_tests/components/light/test_automation.py new file mode 100644 index 00000000000..6a2a05227d3 --- /dev/null +++ b/tests/unit_tests/components/light/test_automation.py @@ -0,0 +1,58 @@ +"""Tests for validate_light_state -- a light on/off value that prioritizes ON/OFF +string forms over generic boolean forms, while still accepting the latter.""" + +import pytest +import yaml + +from esphome.components.light.automation import validate_light_state +import esphome.config_validation as cv +from esphome.schema_extractors import SCHEMA_EXTRACT + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("ON", True), + ("on", True), + ("On", True), + ("OFF", False), + ("off", False), + ("Off", False), + (True, True), + (False, False), + ("true", True), + ("false", False), + ("yes", True), + ("no", False), + ], +) +def test_validate_light_state_accepts_on_off_and_booleans( + value: str | bool, expected: bool +) -> None: + assert validate_light_state(value) is expected + + +def test_validate_light_state_rejects_invalid_string() -> None: + # The error must mention both accepted forms (ON/OFF and boolean), not just + # whichever validator happened to run last. + with pytest.raises(cv.Invalid, match="ON.*OFF.*boolean"): + validate_light_state("maybe") + + +def test_validate_light_state_schema_extractor_reports_on_off() -> None: + assert validate_light_state(SCHEMA_EXTRACT) == ("ON", "OFF") + + +def test_validate_light_state_quoted_on_off_survive_yaml_parsing() -> None: + """The default PyYAML resolver treats bareword on/off/yes/no as booleans, so a real + YAML config must quote 'ON'/'OFF' for validate_light_state's string-matching branch + to ever see a string at all -- an unquoted `state: on` already arrives as a native + bool. Both forms must still validate to the same result. + """ + quoted = yaml.safe_load('state: "ON"')["state"] + assert quoted == "ON" + assert validate_light_state(quoted) is True + + unquoted = yaml.safe_load("state: on")["state"] + assert unquoted is True # PyYAML already converted it before validation runs + assert validate_light_state(unquoted) is True diff --git a/tests/unit_tests/components/light/test_restore_state.py b/tests/unit_tests/components/light/test_restore_state.py new file mode 100644 index 00000000000..aee3c5ec194 --- /dev/null +++ b/tests/unit_tests/components/light/test_restore_state.py @@ -0,0 +1,535 @@ +"""Tests for the restore_mode/restore_state -> runtime lambda translation layer.""" + +import logging + +import pytest + +import esphome.codegen as cg +from esphome.components.light import ( + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, + LIGHT_SCHEMA, + LightType, + _final_validate, + light_schema, +) +from esphome.components.light.restore_state import ( + _RESTORE_STATE_FIELDS_SCHEMA, + LEGACY_RESTORE_MODES, + RESTORE_STATE_INITIAL, + RESTORE_STATE_INVERT, + RESTORE_STATE_KEEP, + RESTORE_STATE_NONE, + RESTORE_STATE_SCHEMA, + StateStatement, + _initial_state_overridden_by_legacy_mode, + _initial_state_statements, + _legacy_cold_boot_statements, + _partition_state_statements, + _restore_state_statements, + _validate_restore_state_state, +) +from esphome.components.light.types import ColorMode +import esphome.config_validation as cv +from esphome.const import CONF_STATE +from esphome.core import Lambda +from esphome.schema_extractors import SCHEMA_EXTRACT + +# (mode name, expected cold_boot_state, expected restore_action, expected save_enabled) +LEGACY_MODE_TABLE = [ + ("RESTORE_DEFAULT_OFF", False, None, True), + ("RESTORE_DEFAULT_ON", True, None, True), + ("ALWAYS_OFF", False, None, False), + ("ALWAYS_ON", True, None, False), + ("RESTORE_INVERTED_DEFAULT_OFF", False, "INVERT", True), + ("RESTORE_INVERTED_DEFAULT_ON", True, "INVERT", True), + ("RESTORE_AND_OFF", False, False, True), + ("RESTORE_AND_ON", True, True, True), +] + + +@pytest.mark.parametrize( + ("mode", "cold_boot_state", "restore_action", "save_enabled"), LEGACY_MODE_TABLE +) +def test_legacy_restore_mode_translation( + mode: str, cold_boot_state: bool, restore_action, save_enabled: bool +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert legacy.cold_boot_state is cold_boot_state + assert legacy.restore_action == restore_action + assert legacy.save_enabled is save_enabled + + +def test_all_eight_legacy_modes_present() -> None: + assert set(LEGACY_RESTORE_MODES) == {mode for mode, *_ in LEGACY_MODE_TABLE} + + +def test_restore_mode_and_restore_state_are_exclusive() -> None: + with pytest.raises(cv.Invalid, match="restore"): + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: {}, + } + ) + + +def test_neither_restore_key_required_or_defaulted() -> None: + config = LIGHT_SCHEMA({"name": "test"}) + assert CONF_RESTORE_MODE not in config + assert CONF_RESTORE_STATE not in config + + +def test_restore_state_empty_config_keeps_everything() -> None: + config = RESTORE_STATE_SCHEMA({}) + assert all(value == RESTORE_STATE_KEEP for value in config.values()) + + +@pytest.mark.parametrize("value", ["all", "All", "ALL"]) +def test_restore_state_all_shorthand_is_case_insensitive(value: str) -> None: + assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_SCHEMA({}) + + +def test_restore_state_rejects_other_strings() -> None: + with pytest.raises(cv.Invalid): + RESTORE_STATE_SCHEMA("everything") + + +_DummyLight = cg.esphome_ns.class_("DummyLight") + + +def test_default_restore_mode_applies_when_neither_key_given() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + config = schema({"id": "light1"}) + assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON" + assert CONF_RESTORE_STATE not in config + + +def test_default_restore_mode_is_dropped_when_restore_state_given() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + config = schema({"id": "light1", "restore_state": {"state": "INVERT"}}) + assert CONF_RESTORE_MODE not in config + assert config[CONF_RESTORE_STATE][CONF_STATE] == "INVERT" + + +def test_default_restore_mode_still_exclusive_with_explicit_restore_state() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + with pytest.raises(cv.Invalid, match="restore"): + schema( + { + "id": "light1", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: {}, + } + ) + + +def test_default_restore_mode_result_still_extendable() -> None: + # light_schema() must keep returning a real cv.Schema (not e.g. cv.All) even when + # default_restore_mode is given, since every in-tree light platform chains + # .extend() on its result. + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + extended = schema.extend({}) + config = extended({"id": "light1"}) + assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON" + + +@pytest.mark.parametrize("value", ["none", "None", "NONE"]) +def test_restore_state_none_shorthand_is_case_insensitive(value: str) -> None: + assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_NONE + + +def test_restore_state_none_is_still_exclusive_with_restore_mode() -> None: + # cv.Exclusive checks which keys are present, regardless of their resolved + # value, so restore_state: none must still conflict with restore_mode:. + with pytest.raises(cv.Invalid, match="restore"): + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: "none", + } + ) + + +def test_restore_state_explicit_overrides_leave_others_keep() -> None: + config = RESTORE_STATE_SCHEMA({"state": "invert", "brightness": "100%"}) + assert config["state"] == "INVERT" + assert config["brightness"] == pytest.approx(1.0) + assert config["color_mode"] == RESTORE_STATE_KEEP + assert config["red"] == RESTORE_STATE_KEEP + + +@pytest.mark.parametrize("value", ["keep", "Keep", "KEEP"]) +def test_restore_state_state_accepts_keep_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == RESTORE_STATE_KEEP + + +@pytest.mark.parametrize("value", ["invert", "Invert", "INVERT"]) +def test_restore_state_state_accepts_invert_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INVERT" + + +@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"]) +def test_restore_state_state_accepts_initial_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INITIAL" + + +@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"]) +def test_restore_state_other_fields_accept_initial_case_insensitively( + value: str, +) -> None: + assert RESTORE_STATE_SCHEMA({"brightness": value})["brightness"] == "INITIAL" + + +@pytest.mark.parametrize( + ("value", "expected"), [("ON", True), ("OFF", False), (True, True), (False, False)] +) +def test_restore_state_state_prioritizes_on_off( + value: str | bool, expected: bool +) -> None: + # A quoted "ON"/"OFF" string, distinct from KEEP/INVERT, still resolves via + # validate_light_state -- matching initial_state:'s own state field. + assert RESTORE_STATE_SCHEMA({"state": value})["state"] is expected + + +def test_validate_restore_state_state_schema_extract_reports_all_options() -> None: + # Regression test: SCHEMA_EXTRACT is an object() sentinel, not a str, so a naive + # isinstance(value, str) check falls through to validate_light_state() and silently + # drops KEEP/INVERT/INITIAL from the extracted docs schema. + assert _validate_restore_state_state(SCHEMA_EXTRACT) == ( + RESTORE_STATE_KEEP, + RESTORE_STATE_INVERT, + RESTORE_STATE_INITIAL, + "ON", + "OFF", + ) + + +def test_restore_state_schema_extract_returns_fields_schema() -> None: + # The `all`/`none` shorthands aren't representable here; extraction only walks + # the per-field mapping form, so it must resolve to the real fields schema + # rather than falling through to the untyped/unknown bucket. + assert RESTORE_STATE_SCHEMA(SCHEMA_EXTRACT) is _RESTORE_STATE_FIELDS_SCHEMA + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("state", Lambda("return true;")), + ("brightness", Lambda("return 1.0;")), + ("color_mode", Lambda("return light::ColorMode::ON_OFF;")), + ], +) +def test_restore_state_fields_reject_lambda(field: str, value: Lambda) -> None: + with pytest.raises(cv.Invalid): + RESTORE_STATE_SCHEMA({field: value}) + + +@pytest.mark.parametrize( + ("mode", "initial_state_config", "expected"), + [ + # No initial_state:, cold_boot_state False -- already matches + # LightStateRTCState's own `state{false}` default, nothing to emit. + ("ALWAYS_OFF", None, []), + ("ALWAYS_OFF", {}, []), + # No initial_state:, cold_boot_state True -- differs from the default. + ("ALWAYS_ON", None, [("state", "s.state = true;")]), + # initial_state: already set exactly the cold-boot value -- redundant. + ("ALWAYS_OFF", {CONF_STATE: False}, []), + ("RESTORE_AND_ON", {CONF_STATE: True}, []), + # initial_state: set a different value -- must be overridden. + ("ALWAYS_OFF", {CONF_STATE: True}, [("state", "s.state = false;")]), + ("RESTORE_AND_ON", {CONF_STATE: False}, [("state", "s.state = true;")]), + ], +) +def test_legacy_cold_boot_statements_skips_redundant_defaults( + mode: str, initial_state_config: dict | None, expected: list[StateStatement] +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert _legacy_cold_boot_statements(legacy, initial_state_config) == expected + + +def test_partition_no_overlap_produces_full_if_else() -> None: + body = _partition_state_statements( + [("brightness", "s.brightness = 1.0f;")], + [("state", "s.state = false;")], + True, + ) + assert body == [ + "if (restored) {", + "s.state = false;", + "} else {", + "s.brightness = 1.0f;", + "}", + ] + + +def test_partition_full_overlap_drops_branch_entirely() -> None: + # Both branches want exactly the same thing (e.g. RESTORE_AND_ON): no `if` at all. + body = _partition_state_statements( + [("state", "s.state = true;")], + [("state", "s.state = true;")], + True, + ) + assert body == ["s.state = true;"] + + +def test_partition_partial_overlap_hoists_shared_field() -> None: + # `state` matches in both branches and is hoisted out; `red`/`color_mode` differ + # per branch and stay inside a single-sided `if` each. + body = _partition_state_statements( + [ + ("red", "s.red = 0.5f;"), + ("state", "s.state = true;"), + ], + [ + ("state", "s.state = true;"), + ("color_mode", "s.color_mode = light::ColorMode::ON_OFF;"), + ], + True, + ) + assert body == [ + "s.state = true;", + "if (restored) {", + "s.color_mode = light::ColorMode::ON_OFF;", + "} else {", + "s.red = 0.5f;", + "}", + ] + + +def test_partition_duplicate_member_in_one_list_keeps_last_write() -> None: + # initial_statements can legitimately contain two writes to `state`: the user's own + # initial_state: value, followed by a legacy mode's cold-boot override. The earlier + # one is dead code (immediately overwritten) and must not survive partitioning. + body = _partition_state_statements( + [ + ("state", "s.state = true;"), + ("state", "s.state = false;"), + ], + [("state", "s.state = false;")], + True, + ) + assert body == ["s.state = false;"] + + +def test_partition_initial_only_without_save_skips_guard() -> None: + # save_enabled False means restored is unconditionally false at the call site + # (e.g. ALWAYS_OFF, restore_state: none, or neither key configured), so guarding + # the initial-only statements behind `if (!restored)` would only waste flash. + body = _partition_state_statements( + [("brightness", "s.brightness = 1.0f;")], + [], + False, + ) + assert body == ["s.brightness = 1.0f;"] + + +@pytest.mark.parametrize( + ("mode", "initial_state_config", "expected"), + [ + # No initial_state: at all -- nothing to override. + ("ALWAYS_OFF", None, False), + ("ALWAYS_ON", None, False), + # initial_state: set, but doesn't include state -- nothing to override. + ("ALWAYS_OFF", {}, False), + # initial_state: state already matches the mode's cold-boot value -- no-op. + ("ALWAYS_OFF", {CONF_STATE: False}, False), + ("RESTORE_AND_ON", {CONF_STATE: True}, False), + # initial_state: state set to something the mode's cold-boot force overrides. + ("ALWAYS_OFF", {CONF_STATE: True}, True), + ("RESTORE_AND_ON", {CONF_STATE: False}, True), + ], +) +def test_initial_state_overridden_by_legacy_mode( + mode: str, initial_state_config: dict | None, expected: bool +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert ( + _initial_state_overridden_by_legacy_mode(legacy, initial_state_config) + == expected + ) + + +def test_final_validate_warns_when_restore_mode_overrides_initial_state( + caplog: pytest.LogCaptureFixture, +) -> None: + # Regression test: this warning used to fire from setup_light_core_() during + # codegen; it now runs as part of FINAL_VALIDATE_SCHEMA instead, so it also + # surfaces on a plain `esphome config`, not just a full compile. + # + # FINAL_VALIDATE_SCHEMA for the `light:` domain runs once for the whole list + # of configured lights, not once per light -- pass a one-element list, matching + # the real call shape, not the single light's own config dict. + config = [ + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_OFF", + "initial_state": {"state": True}, + } + ) + ] + with caplog.at_level(logging.WARNING): + _final_validate(config) + assert "'initial_state: state' is ignored" in caplog.text + assert "restore_mode: ALWAYS_OFF" in caplog.text + + +def test_final_validate_does_not_warn_without_conflict( + caplog: pytest.LogCaptureFixture, +) -> None: + config = [ + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_OFF", + "initial_state": {"state": False}, + } + ) + ] + with caplog.at_level(logging.WARNING): + _final_validate(config) + assert caplog.text == "" + + +@pytest.mark.asyncio +async def test_restore_state_initial_state_field_copies_initial_state_value() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"}) + statements = await _restore_state_statements( + restore_state_config, {CONF_STATE: True} + ) + assert statements == [("state", "s.state = true;")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_other_field_copies_initial_state_value() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"brightness": 0.5} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("brightness", "s.brightness = 0.5f;")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_falls_back_to_struct_default() -> None: + # No initial_state: at all -- INITIAL resolves to a read of LightStateRTCState's + # own member-initializer default, straight from the struct. + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + statements = await _restore_state_statements(restore_state_config, None) + assert statements == [ + ("brightness", "s.brightness = LightStateRTCState{}.brightness;") + ] + + +@pytest.mark.asyncio +async def test_restore_state_initial_falls_back_when_initial_state_omits_field() -> ( + None +): + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"state": True} # doesn't set brightness + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [ + ("brightness", "s.brightness = LightStateRTCState{}.brightness;") + ] + + +@pytest.mark.asyncio +async def test_restore_state_initial_resolves_templated_initial_state() -> None: + # initial_state: gave `state` as a lambda -- INITIAL must call it (and cast its + # result), not just copy a literal value. + restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"}) + initial_state_config = {CONF_STATE: Lambda("return true;")} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("state", "s.state = static_cast(true);")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_resolves_templated_non_boolean_field() -> None: + # Same as above, but for a float-valued field -- the lambda's return type must + # be float, not bool. + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"brightness": Lambda("return 0.75;")} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("brightness", "s.brightness = static_cast(0.75);")] + + +def _mask(*modes: str) -> str: + casts = " | ".join(f"static_cast(light::ColorMode::{m})" for m in modes) + return f"static_cast({casts})" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("initial_state_config", "expected"), + [ + ({CONF_STATE: True}, None), + ({"red": 0.3}, _mask("RGB")), + ({"red": 0.3, "green": 0.0, "color_brightness": 0.5}, _mask("RGB")), + ({"brightness": 0.5}, _mask("BRIGHTNESS")), + ({"white": 0.5}, _mask("WHITE")), + ({"cold_white": 0.5, "warm_white": 0.5}, _mask("COLD_WARM_WHITE")), + ( + {"red": 0.3, "white": 0.5}, + _mask("RGB", "WHITE"), + ), + ({"red": Lambda("return 0.3;")}, _mask("RGB")), + ], +) +async def test_initial_state_infers_color_mode_from_colour_fields( + initial_state_config: dict, expected: str | None +) -> None: + statements = dict(await _initial_state_statements(initial_state_config)) + if expected is None: + assert "color_mode" not in statements + else: + assert statements["color_mode"] == f"s.color_mode = {expected};" + + +@pytest.mark.asyncio +async def test_initial_state_explicit_color_mode_is_not_inferred() -> None: + statements = dict( + await _initial_state_statements({"color_mode": ColorMode.RGB, "red": 0.3}) + ) + assert statements["color_mode"] == "s.color_mode = light::ColorMode::RGB;" + + +@pytest.mark.asyncio +async def test_restore_state_initial_color_mode_uses_inferred_mode() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"color_mode": "initial"}) + statements = await _restore_state_statements(restore_state_config, {"red": 0.3}) + assert statements == [("color_mode", f"s.color_mode = {_mask('RGB')};")] + + +def test_every_restore_state_field_offers_completion() -> None: + # Regression test: each field's validator must answer SCHEMA_EXTRACT (instead of + # raising), or the editor gets no completion for it. + for key, validator in _RESTORE_STATE_FIELDS_SCHEMA.schema.items(): + values = validator(SCHEMA_EXTRACT) + assert RESTORE_STATE_KEEP in values, key + assert RESTORE_STATE_INITIAL in values, key + + +def test_restore_state_color_mode_completion_includes_color_modes() -> None: + validator = _RESTORE_STATE_FIELDS_SCHEMA.schema[ + next(k for k in _RESTORE_STATE_FIELDS_SCHEMA.schema if k == "color_mode") + ] + values = validator(SCHEMA_EXTRACT) + assert {"KEEP", "INITIAL", "RGB", "COLD_WARM_WHITE"} <= set(values) From d5d0901e2c3d96e06fc2b139d127ddbf675782a2 Mon Sep 17 00:00:00 2001 From: Craig Andrews Date: Fri, 25 Sep 2026 18:55:14 -0400 Subject: [PATCH 58/94] [tfluna] New component for the TF-Luna Low Cost LiDAR Distance Sensor (#9199) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tfluna/__init__.py | 34 ++++ esphome/components/tfluna/button/__init__.py | 72 ++++++++ .../components/tfluna/button/reset_button.cpp | 7 + .../components/tfluna/button/reset_button.h | 13 ++ .../tfluna/button/restart_button.cpp | 7 + .../components/tfluna/button/restart_button.h | 13 ++ esphome/components/tfluna/sensor.py | 75 +++++++++ esphome/components/tfluna/text_sensor.py | 23 +++ esphome/components/tfluna/tfluna.cpp | 158 ++++++++++++++++++ esphome/components/tfluna/tfluna.h | 57 +++++++ tests/components/tfluna/common.yaml | 24 +++ tests/components/tfluna/test.esp32-idf.yaml | 4 + tests/components/tfluna/test.esp8266-ard.yaml | 4 + tests/components/tfluna/test.rp2040-ard.yaml | 4 + 15 files changed, 496 insertions(+) create mode 100644 esphome/components/tfluna/__init__.py create mode 100644 esphome/components/tfluna/button/__init__.py create mode 100644 esphome/components/tfluna/button/reset_button.cpp create mode 100644 esphome/components/tfluna/button/reset_button.h create mode 100644 esphome/components/tfluna/button/restart_button.cpp create mode 100644 esphome/components/tfluna/button/restart_button.h create mode 100644 esphome/components/tfluna/sensor.py create mode 100644 esphome/components/tfluna/text_sensor.py create mode 100644 esphome/components/tfluna/tfluna.cpp create mode 100644 esphome/components/tfluna/tfluna.h create mode 100644 tests/components/tfluna/common.yaml create mode 100644 tests/components/tfluna/test.esp32-idf.yaml create mode 100644 tests/components/tfluna/test.esp8266-ard.yaml create mode 100644 tests/components/tfluna/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 4065e52c2c7..c582728e76b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -562,6 +562,7 @@ esphome/components/template/datetime/* @rfdarter esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse +esphome/components/tfluna/* @candrews esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py new file mode 100644 index 00000000000..6ba821b1ab8 --- /dev/null +++ b/esphome/components/tfluna/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@candrews"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True + +tfluna_ns = cg.esphome_ns.namespace("tfluna") + +TFLunaComponent = tfluna_ns.class_("TFLuna", cg.PollingComponent, i2c.I2CDevice) + +CONF_TFLUNA_ID = "tfluna_id" +CONF_TIMESTAMP = "timestamp" + +FACTORY_DEFAULT_ADDRESS = 0x10 + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TFLunaComponent), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(FACTORY_DEFAULT_ADDRESS)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tfluna/button/__init__.py b/esphome/components/tfluna/button/__init__.py new file mode 100644 index 00000000000..deb2ba8ffb3 --- /dev/null +++ b/esphome/components/tfluna/button/__init__.py @@ -0,0 +1,72 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_FACTORY_RESET, + CONF_RESTART, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_RESTART, + ICON_RESTART_ALERT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import CONF_TFLUNA_ID, FACTORY_DEFAULT_ADDRESS, TFLunaComponent, tfluna_ns + +DEPENDENCIES = ["tfluna"] + +ResetButton = tfluna_ns.class_("ResetButton", button.Button) +RestartButton = tfluna_ns.class_("RestartButton", button.Button) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_FACTORY_RESET): button.button_schema( + ResetButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ), + cv.Optional(CONF_RESTART): button.button_schema( + RestartButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ), +} + + +def _validate_factory_default_address(config: ConfigType) -> ConfigType: + if config.get(CONF_ADDRESS) != FACTORY_DEFAULT_ADDRESS: + raise cv.Invalid( + f"'{CONF_FACTORY_RESET}' requires the TF-Luna to use its factory default " + f"I2C address 0x{FACTORY_DEFAULT_ADDRESS:02X}, as a factory reset " + "restores that address", + path=[CONF_ADDRESS], + ) + return config + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_FACTORY_RESET in config: + fv.id_declaration_match_schema(_validate_factory_default_address)( + config[CONF_TFLUNA_ID] + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) + if factory_reset_config := config.get(CONF_FACTORY_RESET): + b = await button.new_button(factory_reset_config) + await cg.register_parented(b, config[CONF_TFLUNA_ID]) + cg.add(tfluna_component.set_reset_button(b)) + if restart_config := config.get(CONF_RESTART): + b = await button.new_button(restart_config) + await cg.register_parented(b, config[CONF_TFLUNA_ID]) + cg.add(tfluna_component.set_restart_button(b)) diff --git a/esphome/components/tfluna/button/reset_button.cpp b/esphome/components/tfluna/button/reset_button.cpp new file mode 100644 index 00000000000..b973f668642 --- /dev/null +++ b/esphome/components/tfluna/button/reset_button.cpp @@ -0,0 +1,7 @@ +#include "reset_button.h" + +namespace esphome::tfluna { + +void ResetButton::press_action() { this->parent_->factory_reset(); } + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/reset_button.h b/esphome/components/tfluna/button/reset_button.h new file mode 100644 index 00000000000..a52f479eea0 --- /dev/null +++ b/esphome/components/tfluna/button/reset_button.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../tfluna.h" + +namespace esphome::tfluna { + +class ResetButton : public button::Button, public Parented { + protected: + void press_action() override; +}; + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/restart_button.cpp b/esphome/components/tfluna/button/restart_button.cpp new file mode 100644 index 00000000000..546be2742d8 --- /dev/null +++ b/esphome/components/tfluna/button/restart_button.cpp @@ -0,0 +1,7 @@ +#include "restart_button.h" + +namespace esphome::tfluna { + +void RestartButton::press_action() { this->parent_->restart(); } + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/restart_button.h b/esphome/components/tfluna/button/restart_button.h new file mode 100644 index 00000000000..7caaebe6b38 --- /dev/null +++ b/esphome/components/tfluna/button/restart_button.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../tfluna.h" + +namespace esphome::tfluna { + +class RestartButton : public button::Button, public Parented { + protected: + void press_action() override; +}; + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py new file mode 100644 index 00000000000..d101d12ae01 --- /dev/null +++ b/esphome/components/tfluna/sensor.py @@ -0,0 +1,75 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DISTANCE, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_ARROW_EXPAND_VERTICAL, + ICON_SIGNAL, + ICON_THERMOMETER, + ICON_TIMER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_CENTIMETER, + UNIT_MILLISECOND, +) +from esphome.types import ConfigType + +from . import CONF_TFLUNA_ID, CONF_TIMESTAMP, TFLunaComponent + +DEPENDENCIES = ["tfluna"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_ARROW_EXPAND_VERTICAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + icon=ICON_SIGNAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIMESTAMP): sensor.sensor_schema( + icon=ICON_TIMER, + accuracy_decimals=0, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) + if distance_config := config.get(CONF_DISTANCE): + sens = await sensor.new_sensor(distance_config) + cg.add(tfluna_component.set_distance_sensor(sens)) + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(tfluna_component.set_temperature_sensor(sens)) + if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): + sens = await sensor.new_sensor(signal_strength_config) + cg.add(tfluna_component.set_signal_strength_sensor(sens)) + if timestamp_config := config.get(CONF_TIMESTAMP): + sens = await sensor.new_sensor(timestamp_config) + cg.add(tfluna_component.set_timestamp_sensor(sens)) diff --git a/esphome/components/tfluna/text_sensor.py b/esphome/components/tfluna/text_sensor.py new file mode 100644 index 00000000000..6bce14252b0 --- /dev/null +++ b/esphome/components/tfluna/text_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_TFLUNA_ID, TFLunaComponent + +DEPENDENCIES = ["tfluna"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) + if version_config := config.get(CONF_VERSION): + sens = await text_sensor.new_text_sensor(version_config) + cg.add(tfluna_component.set_version_text_sensor(sens)) diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp new file mode 100644 index 00000000000..1466ba89470 --- /dev/null +++ b/esphome/components/tfluna/tfluna.cpp @@ -0,0 +1,158 @@ +#include "tfluna.h" +#include +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome::tfluna { + +// see https://files.waveshare.com/upload/a/ac/SJ-PM-TF-Luna_A05_Product_Manual.pdf +static constexpr uint8_t SAVE_REGISTER = 0x20; +static constexpr uint8_t VERSION_REVISION_REGISTER = 0x0A; +static constexpr uint8_t DISTANCE_LOW_REGISTER = 0x00; +static constexpr uint8_t RESTORE_FACTORY_DEFAULTS_REGISTER = 0x29; +static constexpr uint8_t SHUTDOWN_REBOOT_REGISTER = 0x21; +static constexpr uint8_t MODE_REGISTER = 0x23; +static constexpr uint8_t MODE_TRIGGER = 0x01; +static constexpr uint8_t TRIGGER_ONESHOT_REGISTER = 0x24; +static constexpr uint8_t MAX_READ_ATTEMPTS = 5; +static constexpr uint8_t READ_RETRY_MS = 5; +static const char *const TAG = "tfluna"; +static const char *const READ_RETRY_TIMEOUT = "read_retry"; + +void TFLuna::dump_config() { + ESP_LOGCONFIG(TAG, "TF-Luna (i2c):"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Firmware: %s", this->version_); + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Distance:", this->distance_sensor_); + LOG_SENSOR(" ", "Temperature:", this->temperature_sensor_); + LOG_SENSOR(" ", "Signal Strength:", this->signal_strength_sensor_); + LOG_SENSOR(" ", "Timestamp:", this->timestamp_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Version:", this->version_text_sensor_); +#endif +#ifdef USE_BUTTON + LOG_BUTTON(" ", "Factory Reset:", this->reset_button_); + LOG_BUTTON(" ", "Restart:", this->restart_button_); +#endif +} + +void TFLuna::setup() { + uint8_t mode; + if (!this->read_byte(MODE_REGISTER, &mode)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + if (mode != MODE_TRIGGER) { + if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + + if (!this->write_byte(SAVE_REGISTER, 1)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + } + + uint8_t raw_version[3]; + if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } else { + snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]); +#ifdef USE_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version_); + } +#endif + } +} + +bool TFLuna::read_data_() { + uint8_t buf[8]; + if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return false; + } + // Layout: + // buf[0..1] = distance (LE), buf[2..3] = signal (LE), + // buf[4..5] = temperature (LE), buf[6..7] = timestamp (LE) + uint16_t distance = encode_uint16(buf[1], buf[0]); + uint16_t signal_strength = encode_uint16(buf[3], buf[2]); + int16_t temperature_raw = encode_uint16(buf[5], buf[4]); + uint16_t timestamp = encode_uint16(buf[7], buf[6]); + + if (timestamp == this->previous_timestamp_) { + return false; + } + +#ifdef USE_SENSOR + if (this->timestamp_sensor_ != nullptr) { + this->timestamp_sensor_->publish_state(timestamp); + } + if (this->distance_sensor_ != nullptr) { + // distance is unreliable when strength is below 100 or saturated at 0xFFFF + this->distance_sensor_->publish_state(signal_strength < 100 || signal_strength == 0xFFFF ? NAN : distance); + } + + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->publish_state(temperature_raw / (float) 100); + } + + if (this->signal_strength_sensor_ != nullptr) { + this->signal_strength_sensor_->publish_state(signal_strength); + } +#endif + this->status_clear_warning(); + this->previous_timestamp_ = timestamp; + return true; +} + +void TFLuna::read_data_timeout_() { + if (this->read_data_()) { + this->attempt_ = 0; + } else { + if (this->attempt_ < MAX_READ_ATTEMPTS) { + this->attempt_++; + this->set_timeout(READ_RETRY_TIMEOUT, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); + } else { + this->status_set_warning("Hung device, restarting..."); + this->restart(); + } + } +} + +void TFLuna::update() { + this->cancel_timeout(READ_RETRY_TIMEOUT); + this->attempt_ = 0; + if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + this->read_data_timeout_(); +} + +void TFLuna::factory_reset() { + if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + this->status_set_warning("Factory reset issued; waiting for device to become ready"); + this->set_timeout("_setup", 100, [this]() { this->setup(); }); +} + +void TFLuna::restart() { + if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/tfluna.h b/esphome/components/tfluna/tfluna.h new file mode 100644 index 00000000000..0cc866ef1df --- /dev/null +++ b/esphome/components/tfluna/tfluna.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#include "esphome/components/i2c/i2c.h" + +namespace esphome::tfluna { + +class TFLuna : public i2c::I2CDevice, public PollingComponent { +#ifdef USE_SENSOR + SUB_SENSOR(distance) + SUB_SENSOR(temperature) + SUB_SENSOR(signal_strength) + SUB_SENSOR(timestamp) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(version) +#endif +#ifdef USE_BUTTON + SUB_BUTTON(reset) + SUB_BUTTON(restart) +#endif + public: + void setup() override; + + float get_setup_priority() const override { return setup_priority::LATE; } + + void dump_config() override; + + void update() override; + + void factory_reset(); + + void restart(); + + protected: + char version_[12] = "unknown"; + + uint16_t previous_timestamp_ = 0; + + [[nodiscard]] bool read_data_(); + + void read_data_timeout_(); + + uint8_t attempt_ = 0; +}; + +} // namespace esphome::tfluna diff --git a/tests/components/tfluna/common.yaml b/tests/components/tfluna/common.yaml new file mode 100644 index 00000000000..5804ae6981b --- /dev/null +++ b/tests/components/tfluna/common.yaml @@ -0,0 +1,24 @@ +tfluna: + +sensor: + - platform: tfluna + distance: + name: "Distance" + signal_strength: + name: TF-Luna Signal Strength + temperature: + name: TF-Luna Temperature + timestamp: + name: TF-Luna Timestamp + +text_sensor: + - platform: tfluna + version: + name: "TF-Luna firmware version" + +button: + - platform: tfluna + factory_reset: + name: "factory reset" + restart: + name: "restart" diff --git a/tests/components/tfluna/test.esp32-idf.yaml b/tests/components/tfluna/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/tfluna/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/tfluna/test.esp8266-ard.yaml b/tests/components/tfluna/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/tfluna/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/tfluna/test.rp2040-ard.yaml b/tests/components/tfluna/test.rp2040-ard.yaml new file mode 100644 index 00000000000..319a7c71a65 --- /dev/null +++ b/tests/components/tfluna/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From bfe1f7bd171fa83d046e02142dc9e6408908c00f Mon Sep 17 00:00:00 2001 From: Chris Nussbaum Date: Fri, 25 Sep 2026 18:03:14 -0500 Subject: [PATCH 59/94] [pzem6l24] Add PZEM-6L24 three-phase energy monitor sensor (#17849) Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/pzem6l24/__init__.py | 0 esphome/components/pzem6l24/pzem6l24.cpp | 335 ++++++++++++++++++ esphome/components/pzem6l24/pzem6l24.h | 161 +++++++++ esphome/components/pzem6l24/sensor.py | 198 +++++++++++ tests/component_tests/pzem6l24/__init__.py | 0 .../component_tests/pzem6l24/test_address.py | 28 ++ tests/components/pzem6l24/common.h | 46 +++ tests/components/pzem6l24/common.yaml | 82 +++++ .../pzem6l24/sensor/pzem6l24_test.cpp | 315 ++++++++++++++++ tests/components/pzem6l24/test.esp32-idf.yaml | 8 + .../components/pzem6l24/test.esp8266-ard.yaml | 7 + .../components/pzem6l24/test.rp2040-ard.yaml | 7 + 13 files changed, 1188 insertions(+) create mode 100644 esphome/components/pzem6l24/__init__.py create mode 100644 esphome/components/pzem6l24/pzem6l24.cpp create mode 100644 esphome/components/pzem6l24/pzem6l24.h create mode 100644 esphome/components/pzem6l24/sensor.py create mode 100644 tests/component_tests/pzem6l24/__init__.py create mode 100644 tests/component_tests/pzem6l24/test_address.py create mode 100644 tests/components/pzem6l24/common.h create mode 100644 tests/components/pzem6l24/common.yaml create mode 100644 tests/components/pzem6l24/sensor/pzem6l24_test.cpp create mode 100644 tests/components/pzem6l24/test.esp32-idf.yaml create mode 100644 tests/components/pzem6l24/test.esp8266-ard.yaml create mode 100644 tests/components/pzem6l24/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c582728e76b..52fec0a7aca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -429,6 +429,7 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/pzem6l24/* @nuttytree esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje diff --git a/esphome/components/pzem6l24/__init__.py b/esphome/components/pzem6l24/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp new file mode 100644 index 00000000000..f9f97606a82 --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -0,0 +1,335 @@ +#include "pzem6l24.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::pzem6l24 { + +static const char *const TAG = "pzem6l24"; + +// ----------------------------------------------------------------------- +// Register map (input registers, starting address 0x0000): +// +// The PZEM-6L24 returns all register bytes in little-endian order, +// i.e. the low byte of each 16-bit register is transmitted first. +// 32-bit quantities occupy two consecutive registers with the low +// word at the lower address. +// +// NOTE: this is the opposite of standard Modbus, and of the single-phase +// pzemac component, which decodes big-endian. It is not an oversight: the +// byte order below was established against a live PZEM-6L24, so please do +// not "correct" it to big-endian without a device to verify against. +// +// Byte offset = register_address × 2 +// +// 0x0000 (byte 0) – Voltage A (uint16, ×0.1 V) +// 0x0001 (byte 2) – Voltage B (uint16, ×0.1 V) +// 0x0002 (byte 4) – Voltage C (uint16, ×0.1 V) +// 0x0003 (byte 6) – Current A (uint16, ×0.01 A) +// 0x0004 (byte 8) – Current B (uint16, ×0.01 A) +// 0x0005 (byte 10) – Current C (uint16, ×0.01 A) +// 0x0006 (byte 12) – Frequency A (uint16, ×0.01 Hz) +// 0x0007 (byte 14) – Frequency B (uint16, ×0.01 Hz) +// 0x0008 (byte 16) – Frequency C (uint16, ×0.01 Hz) +// 0x0009 (byte 18) – Voltage angle B (uint16, ×0.01 °) +// 0x000A (byte 20) – Voltage angle C (uint16, ×0.01 °) +// 0x000B (byte 22) – Current angle A (uint16, ×0.01 °) +// 0x000C (byte 24) – Current angle B (uint16, ×0.01 °) +// 0x000D (byte 26) – Current angle C (uint16, ×0.01 °) +// 0x000E (byte 28) – Active power A (int32 lo-word, ×0.1 W) +// 0x000F (byte 30) – Active power A (int32 hi-word) +// 0x0010 (byte 32) – Active power B (int32 lo-word, ×0.1 W) +// 0x0011 (byte 34) – Active power B (int32 hi-word) +// 0x0012 (byte 36) – Active power C (int32 lo-word, ×0.1 W) +// 0x0013 (byte 38) – Active power C (int32 hi-word) +// 0x0014 (byte 40) – Reactive power A (int32 lo-word, ×0.1 var) +// 0x0015 (byte 42) – Reactive power A (int32 hi-word) +// 0x0016 (byte 44) – Reactive power B (int32 lo-word, ×0.1 var) +// 0x0017 (byte 46) – Reactive power B (int32 hi-word) +// 0x0018 (byte 48) – Reactive power C (int32 lo-word, ×0.1 var) +// 0x0019 (byte 50) – Reactive power C (int32 hi-word) +// 0x001A (byte 52) – Apparent power A (int32 lo-word, ×0.1 VA) +// 0x001B (byte 54) – Apparent power A (int32 hi-word) +// 0x001C (byte 56) – Apparent power B (int32 lo-word, ×0.1 VA) +// 0x001D (byte 58) – Apparent power B (int32 hi-word) +// 0x001E (byte 60) – Apparent power C (int32 lo-word, ×0.1 VA) +// 0x001F (byte 62) – Apparent power C (int32 hi-word) +// 0x0020 (byte 64) – Total active pwr (int32 lo-word, ×0.1 W) +// 0x0021 (byte 66) – Total active pwr (int32 hi-word) +// 0x0022 (byte 68) – Total reactive pwr (int32 lo-word, ×0.1 var) +// 0x0023 (byte 70) – Total reactive pwr (int32 hi-word) +// 0x0024 (byte 72) – Total apparent pwr (int32 lo-word, ×0.1 VA) +// 0x0025 (byte 74) – Total apparent pwr (int32 hi-word) +// 0x0026 (byte 76) – Power factor A/B: hi-byte = A (×0.01), lo-byte = B (×0.01) +// 0x0027 (byte 78) – Power factor C/tot:hi-byte = C (×0.01), lo-byte = total (×0.01) +// 0x0028 (byte 80) – Active energy A (uint32 lo-word, ×0.1 kWh) +// 0x0029 (byte 82) – Active energy A (uint32 hi-word) +// 0x002A (byte 84) – Active energy B (uint32 lo-word, ×0.1 kWh) +// 0x002B (byte 86) – Active energy B (uint32 hi-word) +// 0x002C (byte 88) – Active energy C (uint32 lo-word, ×0.1 kWh) +// 0x002D (byte 90) – Active energy C (uint32 hi-word) +// 0x002E (byte 92) – Reactive energy A (uint32 lo-word, ×0.1 kvarh) +// 0x002F (byte 94) – Reactive energy A (uint32 hi-word) +// 0x0030 (byte 96) – Reactive energy B (uint32 lo-word, ×0.1 kvarh) +// 0x0031 (byte 98) – Reactive energy B (uint32 hi-word) +// 0x0032 (byte 100)– Reactive energy C (uint32 lo-word, ×0.1 kvarh) +// 0x0033 (byte 102)– Reactive energy C (uint32 hi-word) +// 0x0034 (byte 104)– Apparent energy A (uint32 lo-word, ×0.1 kVAh) +// 0x0035 (byte 106)– Apparent energy A (uint32 hi-word) +// 0x0036 (byte 108)– Apparent energy B (uint32 lo-word, ×0.1 kVAh) +// 0x0037 (byte 110)– Apparent energy B (uint32 hi-word) +// 0x0038 (byte 112)– Apparent energy C (uint32 lo-word, ×0.1 kVAh) +// 0x0039 (byte 114)– Apparent energy C (uint32 hi-word) +// 0x003A (byte 116)– Total active nrg (uint32 lo-word, ×0.1 kWh) +// 0x003B (byte 118)– Total active nrg (uint32 hi-word) +// 0x003C (byte 120)– Total reactive nrg (uint32 lo-word, ×0.1 kvarh) +// 0x003D (byte 122)– Total reactive nrg (uint32 hi-word) +// 0x003E (byte 124)– Total apparent nrg (uint32 lo-word, ×0.1 kVAh) +// 0x003F (byte 126)– Total apparent nrg (uint32 hi-word) +// ----------------------------------------------------------------------- + +// Width of a quantity in the register map above. +enum RegType : uint8_t { + REG_U8, // single byte (the packed power factors) + REG_U16, // one register, unsigned + REG_U32, // two registers, unsigned, low word first + REG_I32, // two registers, signed, low word first +}; + +// Scale factor of a quantity; the table has only these two, so a byte replaces a float per entry. +enum Scale : uint8_t { + SCALE_DECI, // ×0.1 + SCALE_CENTI, // ×0.01 +}; + +// One decodable quantity: where it lives in the payload, how to read it and which sensor it feeds. +// Copied out of flash with memcpy, so it must stay trivially copyable. +struct SensorEntry { + sensor::Sensor *PZEM6L24::*member; + uint8_t offset; + RegType type; + Scale scale; +}; +static_assert(std::is_trivially_copyable_v, "SENSORS is copied out of flash with memcpy"); + +// True for the periodic register read issued by update(); the only other request is the 0x42 reset. +static bool is_register_read(std::span request_pdu) { + return modbus::helpers::pdu_function_code(request_pdu) == + static_cast(modbus::FunctionCode::READ_INPUT_REGISTERS); +} + +void PZEM6L24::on_response(std::span request_pdu, std::span response_pdu) { + if (!is_register_read(request_pdu)) { + return; + } + this->read_finished_(); + const auto data = modbus::helpers::server_pdu_payload(response_pdu); + // Anything but exactly PZEM_PAYLOAD_SIZE bytes cannot be decoded by the table. + if (data.size() != PZEM_PAYLOAD_SIZE) { + ESP_LOGW(TAG, "Invalid data size for PZEM-6L24: expected %zu bytes, got %zu", PZEM_PAYLOAD_SIZE, data.size()); + this->read_failed_(); + return; + } + this->consecutive_failures_ = 0; + this->publish_(data.data()); +} + +void PZEM6L24::on_error(std::span request_pdu, modbus::ExceptionCode) { + this->request_failed_(request_pdu); +} + +bool PZEM6L24::on_no_response(std::span request_pdu) { + this->request_failed_(request_pdu); + return false; // no retry; the next update() polls again. +} + +void PZEM6L24::on_not_sent(std::span request_pdu) { this->request_failed_(request_pdu); } + +// A register read that produced no measurements counts toward blanking; a failed energy reset does +// not, but the user is told. The hub has already logged the cause. +void PZEM6L24::request_failed_(std::span request_pdu) { + if (is_register_read(request_pdu)) { + this->read_finished_(); + this->read_failed_(); + } else { + ESP_LOGW(TAG, "Energy reset failed; the counters were not cleared"); + } +} + +// `data` points at PZEM_PAYLOAD_SIZE validated bytes, or is nullptr to blank every sensor; both walk +// the same table. +void PZEM6L24::publish_(const uint8_t *data) { + const bool available = data != nullptr; + + // Byte offset, width and scale for every quantity, in register-map order. All three phases share the + // same grid frequency, so phase A's register is reported. + // + // PROGMEM: on ESP8266 .rodata is DRAM, so the table would otherwise cost ~280 bytes of RAM; the price + // is one 8-byte copy per sensor per poll. No name column for the same reason; dump_config() names + // every sensor from flash. + static constexpr SensorEntry SENSORS[] PROGMEM = { + // Voltages (×0.1 V) + {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + // Currents (×0.01 A) + {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + // Frequency (×0.01 Hz) + {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + // Active powers (×0.1 W, signed) + {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + // Reactive powers (×0.1 var, signed) + {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + // Apparent powers (×0.1 VA, signed) + {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + // Power factors (×0.01), packed two per register: + // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A + // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C + {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + // Active energies (×0.1 kWh, unsigned) + {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + // Reactive energies (×0.1 kvarh, unsigned) + {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + // Apparent energies (×0.1 kVAh, unsigned) + {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + }; + + for (const SensorEntry &flash_entry : SENSORS) { + SensorEntry entry; + progmem_memcpy(&entry, &flash_entry, sizeof(entry)); + sensor::Sensor *sens = this->*entry.member; + if (sens == nullptr) + continue; + if (!available) { + sens->publish_state(NAN); + continue; + } + // No default: an added RegType must fail to compile. The wire is little-endian, hence the reversed + // byte arguments. + const size_t o = entry.offset; + float raw = 0.0f; + switch (entry.type) { + case REG_U8: + raw = data[o]; + break; + case REG_U16: + raw = encode_uint16(data[o + 1], data[o]); + break; + case REG_U32: + raw = encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o]); + break; + case REG_I32: + raw = static_cast(encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o])); + break; + } + sens->publish_state(raw * (entry.scale == SCALE_CENTI ? 0.01f : 0.1f)); + } +} + +void PZEM6L24::update() { + if (this->read_input_registers(0x0000, PZEM_REGISTER_COUNT)) { + this->reads_outstanding_++; + } else if (this->reads_outstanding_ == 0) { + // Refused with nothing in flight: no callback is coming, and the hub has logged why. A refusal + // while a read is outstanding is a duplicate of it, which still resolves in that read's callback. + this->read_failed_(); + } +} + +void PZEM6L24::dump_config() { + ESP_LOGCONFIG(TAG, + "PZEM-6L24:\n" + " Address: 0x%02X", + this->address_); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_); + LOG_SENSOR(" ", "Current A", this->current_a_); + LOG_SENSOR(" ", "Current B", this->current_b_); + LOG_SENSOR(" ", "Current C", this->current_c_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); + LOG_SENSOR(" ", "Frequency", this->frequency_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); +} + +// Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. +void PZEM6L24::read_failed_() { + if (this->consecutive_failures_ >= MAX_CONSECUTIVE_READ_FAILURES) { + return; + } + if (++this->consecutive_failures_ == MAX_CONSECUTIVE_READ_FAILURES) { + ESP_LOGW(TAG, "No valid reading in %u consecutive polls; the readings are now unavailable", + MAX_CONSECUTIVE_READ_FAILURES); + this->publish_(nullptr); + } +} + +// One terminal has arrived for a register read, so that read is no longer in flight. +void PZEM6L24::read_finished_() { + if (this->reads_outstanding_ > 0) { + this->reads_outstanding_--; + } +} + +void PZEM6L24::reset_energy(ResetPhase phase_option) { + const auto pdu = build_reset_pdu(phase_option); + // A refused request gets no callback, so report it here. + if (!this->queue_pdu(pdu)) { + this->request_failed_(pdu); + } +} + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h new file mode 100644 index 00000000000..bf13f25053e --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -0,0 +1,161 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/modbus/modbus.h" + +#include +#include + +namespace esphome::pzem6l24 { + +// Options for the reset_energy action +enum ResetPhase : uint8_t { + RESET_PHASE_A = 0x00, // Reset phase A energy only + RESET_PHASE_B = 0x01, // Reset phase B energy only + RESET_PHASE_C = 0x02, // Reset phase C energy only + RESET_PHASE_COMBINED = 0x03, // Reset combined (sum) energy only + RESET_PHASE_ALL = 0x0F, // Reset all energy counters +}; + +// Reset energy function code (PZEM-6L24 specific, non-standard Modbus) +static constexpr uint8_t PZEM_CMD_RESET_ENERGY = 0x42; + +// Input registers read per poll (0x0000 - 0x003F inclusive) and the payload size that yields. +static constexpr uint8_t PZEM_REGISTER_COUNT = 64; +static constexpr size_t PZEM_PAYLOAD_SIZE = PZEM_REGISTER_COUNT * 2; + +// Failed polls tolerated before the readings are blanked; one collision on a shared RS-485 bus should +// not take every entity unavailable for a whole update interval. +static constexpr uint8_t MAX_CONSECUTIVE_READ_FAILURES = 3; + +// The energy reset command as it goes on the wire: function code, reserved byte, phase selector. The +// hub adds the address and CRC. Split out so the phase byte can be pinned by a test. +constexpr std::array build_reset_pdu(ResetPhase phase) { + return {PZEM_CMD_RESET_ENERGY, 0x00, static_cast(phase)}; +} + +class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + public: + // Per-phase sensors; each setter is named after its config key + void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } + void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } + void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } + + void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } + void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } + void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } + + void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } + void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } + void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } + + void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } + void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } + void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } + + void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } + void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } + void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } + + void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } + void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } + void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } + + void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } + void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } + void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } + + void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } + void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } + void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } + + void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } + void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } + void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } + + // Combined sensors + void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } + void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } + void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } + void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } + void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } + void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } + void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } + void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + + // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. + void reset_energy(ResetPhase phase_option); + + void update() override; + + void on_response(std::span request_pdu, std::span response_pdu) override; + + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + + bool on_no_response(std::span request_pdu) override; + + void on_not_sent(std::span request_pdu) override; + + void dump_config() override; + + protected: + void publish_(const uint8_t *data); + void request_failed_(std::span request_pdu); + void read_failed_(); + void read_finished_(); + + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + + // Per-phase sensors + sensor::Sensor *voltage_a_{nullptr}; + sensor::Sensor *voltage_b_{nullptr}; + sensor::Sensor *voltage_c_{nullptr}; + + sensor::Sensor *current_a_{nullptr}; + sensor::Sensor *current_b_{nullptr}; + sensor::Sensor *current_c_{nullptr}; + + sensor::Sensor *active_power_a_{nullptr}; + sensor::Sensor *active_power_b_{nullptr}; + sensor::Sensor *active_power_c_{nullptr}; + + sensor::Sensor *reactive_power_a_{nullptr}; + sensor::Sensor *reactive_power_b_{nullptr}; + sensor::Sensor *reactive_power_c_{nullptr}; + + sensor::Sensor *apparent_power_a_{nullptr}; + sensor::Sensor *apparent_power_b_{nullptr}; + sensor::Sensor *apparent_power_c_{nullptr}; + + sensor::Sensor *power_factor_a_{nullptr}; + sensor::Sensor *power_factor_b_{nullptr}; + sensor::Sensor *power_factor_c_{nullptr}; + + sensor::Sensor *active_energy_a_{nullptr}; + sensor::Sensor *active_energy_b_{nullptr}; + sensor::Sensor *active_energy_c_{nullptr}; + + sensor::Sensor *reactive_energy_a_{nullptr}; + sensor::Sensor *reactive_energy_b_{nullptr}; + sensor::Sensor *reactive_energy_c_{nullptr}; + + sensor::Sensor *apparent_energy_a_{nullptr}; + sensor::Sensor *apparent_energy_b_{nullptr}; + sensor::Sensor *apparent_energy_c_{nullptr}; + + // Combined sensors + sensor::Sensor *frequency_{nullptr}; + sensor::Sensor *total_active_power_{nullptr}; + sensor::Sensor *total_reactive_power_{nullptr}; + sensor::Sensor *total_apparent_power_{nullptr}; + sensor::Sensor *total_power_factor_{nullptr}; + sensor::Sensor *total_active_energy_{nullptr}; + sensor::Sensor *total_reactive_energy_{nullptr}; + sensor::Sensor *total_apparent_energy_{nullptr}; +}; + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py new file mode 100644 index 00000000000..fd0430d85be --- /dev/null +++ b/esphome/components/pzem6l24/sensor.py @@ -0,0 +1,198 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import modbus, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_FREQUENCY, + CONF_ID, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_REACTIVE_POWER, + DEVICE_CLASS_VOLTAGE, + ICON_CURRENT_AC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE, + UNIT_WATT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["modbus"] +CODEOWNERS = ["@nuttytree"] + +pzem6l24_ns = cg.esphome_ns.namespace("pzem6l24") +PZEM6L24 = pzem6l24_ns.class_( + "PZEM6L24", cg.PollingComponent, modbus.ModbusClientDevice +) + +ResetPhase = pzem6l24_ns.enum("ResetPhase") +RESET_PHASE_OPTIONS = { + "all": ResetPhase.RESET_PHASE_ALL, + "a": ResetPhase.RESET_PHASE_A, + "b": ResetPhase.RESET_PHASE_B, + "c": ResetPhase.RESET_PHASE_C, + "combined": ResetPhase.RESET_PHASE_COMBINED, +} + +# Combined config keys +CONF_TOTAL_ACTIVE_POWER = "total_active_power" +CONF_TOTAL_REACTIVE_POWER = "total_reactive_power" +CONF_TOTAL_APPARENT_POWER = "total_apparent_power" +CONF_TOTAL_POWER_FACTOR = "total_power_factor" +CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" +CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" +CONF_TOTAL_APPARENT_ENERGY = "total_apparent_energy" +CONF_PHASE = "phase" + + +_VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +_CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_REACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_APPARENT_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_POWER_FACTOR_SCHEMA = sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_REACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_APPARENT_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=1, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + icon=ICON_CURRENT_AC, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Quantities measured once per phase; the config key is "_". +_PHASE_QUANTITIES = { + "voltage": _VOLTAGE_SCHEMA, + "current": _CURRENT_SCHEMA, + "active_power": _ACTIVE_POWER_SCHEMA, + "reactive_power": _REACTIVE_POWER_SCHEMA, + "apparent_power": _APPARENT_POWER_SCHEMA, + "power_factor": _POWER_FACTOR_SCHEMA, + "active_energy": _ACTIVE_ENERGY_SCHEMA, + "reactive_energy": _REACTIVE_ENERGY_SCHEMA, + "apparent_energy": _APPARENT_ENERGY_SCHEMA, +} + +# Quantities the meter reports once, keyed by config key. +_COMBINED_QUANTITIES = { + CONF_FREQUENCY: _FREQUENCY_SCHEMA, + CONF_TOTAL_ACTIVE_POWER: _ACTIVE_POWER_SCHEMA, + CONF_TOTAL_REACTIVE_POWER: _REACTIVE_POWER_SCHEMA, + CONF_TOTAL_APPARENT_POWER: _APPARENT_POWER_SCHEMA, + CONF_TOTAL_POWER_FACTOR: _POWER_FACTOR_SCHEMA, + CONF_TOTAL_ACTIVE_ENERGY: _ACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_REACTIVE_ENERGY: _REACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, +} + +# Every sensor by config key; the C++ setter is "set__sensor". +_SENSORS = { + f"{name}_{phase}": schema + for name, schema in _PHASE_QUANTITIES.items() + for phase in "abc" +} | _COMBINED_QUANTITIES + +# The meter answers unit addresses 1 to 247; 0 is the Modbus broadcast address and 248 to 255 are reserved. +_ADDRESS_SCHEMA = cv.All( + cv.hex_uint8_t, + cv.Range(min=1, max=247, msg="The PZEM-6L24 answers unit addresses 1 to 247 only"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PZEM6L24), + **{cv.Optional(key): schema for key, schema in _SENSORS.items()}, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(modbus.modbus_device_schema(0x01)) + .extend({cv.Optional(CONF_ADDRESS, default=0x01): _ADDRESS_SCHEMA}) +) + + +automation.register_apply_action( + "pzem6l24.reset_energy", + maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PZEM6L24), + cv.Optional(CONF_PHASE, default="all"): cv.enum( + RESET_PHASE_OPTIONS, lower=True + ), + } + ), + automation.ApplyField(CONF_PHASE, "reset_energy", ResetPhase), +) + + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device("pzem6l24", role="client") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_client_device(var, config) + + for key in _SENSORS: + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(getattr(var, f"set_{key}_sensor")(sens)) diff --git a/tests/component_tests/pzem6l24/__init__.py b/tests/component_tests/pzem6l24/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/pzem6l24/test_address.py b/tests/component_tests/pzem6l24/test_address.py new file mode 100644 index 00000000000..a9e14361c0d --- /dev/null +++ b/tests/component_tests/pzem6l24/test_address.py @@ -0,0 +1,28 @@ +"""The PZEM-6L24 answers unit addresses 1 to 247 only.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.pzem6l24.sensor import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _sensor(**extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", **extra}) + + +def test_address_defaults_to_one() -> None: + assert _sensor()[CONF_ADDRESS] == 1 + + +@pytest.mark.parametrize("address", [1, 247]) +def test_address_in_unit_range_accepted(address: int) -> None: + assert _sensor(**{CONF_ADDRESS: address})[CONF_ADDRESS] == address + + +@pytest.mark.parametrize("address", [0, 248, 255]) +def test_address_outside_unit_range_rejected(address: int) -> None: + with pytest.raises(cv.Invalid): + _sensor(**{CONF_ADDRESS: address}) diff --git a/tests/components/pzem6l24/common.h b/tests/components/pzem6l24/common.h new file mode 100644 index 00000000000..5ceab795179 --- /dev/null +++ b/tests/components/pzem6l24/common.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include +#include "esphome/components/pzem6l24/pzem6l24.h" + +namespace esphome::pzem6l24::testing { + +// The request PDU update() puts on the wire: read 64 input registers from 0x0000. +static constexpr uint8_t READ_REQUEST_PDU[] = {0x04, 0x00, 0x00, 0x00, PZEM_REGISTER_COUNT}; +// The request PDU reset_energy() puts on the wire for every phase. +static constexpr auto RESET_REQUEST_PDU = build_reset_pdu(RESET_PHASE_ALL); + +// Builds the 128-byte register payload the meter returns, writing each quantity in the +// little-endian byte order documented in pzem6l24.cpp's register map. +class PayloadBuilder { + public: + PayloadBuilder &u8(size_t offset, uint8_t value) { + this->data_[offset] = value; + return *this; + } + PayloadBuilder &u16(size_t offset, uint16_t value) { + this->data_[offset] = static_cast(value & 0xFF); + this->data_[offset + 1] = static_cast(value >> 8); + return *this; + } + PayloadBuilder &u32(size_t offset, uint32_t value) { + this->u16(offset, static_cast(value & 0xFFFF)); + this->u16(offset + 2, static_cast(value >> 16)); + return *this; + } + PayloadBuilder &i32(size_t offset, int32_t value) { return this->u32(offset, static_cast(value)); } + + // Wraps the payload in a read-input-registers response PDU: function code, byte count, data. + std::vector response_pdu() const { + std::vector pdu{0x04, static_cast(PZEM_PAYLOAD_SIZE)}; + pdu.insert(pdu.end(), this->data_.begin(), this->data_.end()); + return pdu; + } + + protected: + std::array data_{}; +}; + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/common.yaml b/tests/components/pzem6l24/common.yaml new file mode 100644 index 00000000000..25cb86cc84f --- /dev/null +++ b/tests/components/pzem6l24/common.yaml @@ -0,0 +1,82 @@ +esphome: + on_boot: + then: + - pzem6l24.reset_energy: pzem6l24_1 + - pzem6l24.reset_energy: + id: pzem6l24_1 + phase: b + +sensor: + - platform: pzem6l24 + modbus_id: modbus_bus + id: pzem6l24_1 + voltage_a: + name: PZEM6L24 Voltage A + voltage_b: + name: PZEM6L24 Voltage B + voltage_c: + name: PZEM6L24 Voltage C + current_a: + name: PZEM6L24 Current A + current_b: + name: PZEM6L24 Current B + current_c: + name: PZEM6L24 Current C + active_power_a: + name: PZEM6L24 Active Power A + active_power_b: + name: PZEM6L24 Active Power B + active_power_c: + name: PZEM6L24 Active Power C + reactive_power_a: + name: PZEM6L24 Reactive Power A + reactive_power_b: + name: PZEM6L24 Reactive Power B + reactive_power_c: + name: PZEM6L24 Reactive Power C + apparent_power_a: + name: PZEM6L24 Apparent Power A + apparent_power_b: + name: PZEM6L24 Apparent Power B + apparent_power_c: + name: PZEM6L24 Apparent Power C + power_factor_a: + name: PZEM6L24 Power Factor A + power_factor_b: + name: PZEM6L24 Power Factor B + power_factor_c: + name: PZEM6L24 Power Factor C + active_energy_a: + name: PZEM6L24 Active Energy A + active_energy_b: + name: PZEM6L24 Active Energy B + active_energy_c: + name: PZEM6L24 Active Energy C + reactive_energy_a: + name: PZEM6L24 Reactive Energy A + reactive_energy_b: + name: PZEM6L24 Reactive Energy B + reactive_energy_c: + name: PZEM6L24 Reactive Energy C + apparent_energy_a: + name: PZEM6L24 Apparent Energy A + apparent_energy_b: + name: PZEM6L24 Apparent Energy B + apparent_energy_c: + name: PZEM6L24 Apparent Energy C + frequency: + name: PZEM6L24 Frequency + total_active_power: + name: PZEM6L24 Total Active Power + total_reactive_power: + name: PZEM6L24 Total Reactive Power + total_apparent_power: + name: PZEM6L24 Total Apparent Power + total_power_factor: + name: PZEM6L24 Total Power Factor + total_active_energy: + name: PZEM6L24 Total Active Energy + total_reactive_energy: + name: PZEM6L24 Total Reactive Energy + total_apparent_energy: + name: PZEM6L24 Total Apparent Energy diff --git a/tests/components/pzem6l24/sensor/pzem6l24_test.cpp b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp new file mode 100644 index 00000000000..6dbb4ebf02a --- /dev/null +++ b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp @@ -0,0 +1,315 @@ +#include "../common.h" + +#include +#include +#include + +namespace esphome::pzem6l24::testing { + +namespace { + +// A payload whose raw values are distinct per quantity, so a transposed offset shows up as a wrong +// value rather than a coincidental match. Registers the component does not read are filled with 0xEE. +PayloadBuilder make_reference_payload() { + PayloadBuilder p; + // Voltages (×0.1 V) + p.u16(0, 2301).u16(2, 2302).u16(4, 2303); + // Currents (×0.01 A) + p.u16(6, 1234).u16(8, 1235).u16(10, 1236); + // Frequency phase A (×0.01 Hz); phases B/C and the angle registers are not read. + p.u16(12, 5001); + for (size_t offset = 14; offset < 28; offset++) { + p.u8(offset, 0xEE); + } + // Active power (×0.1 W, signed); phase C exercises the sign extension. + p.i32(28, 15001).i32(32, 15002).i32(36, -15003).i32(64, 45006); + // Reactive power (×0.1 var, signed) + p.i32(40, 2001).i32(44, 2002).i32(48, 2003).i32(68, 6006); + // Apparent power (×0.1 VA, signed) + p.i32(52, 3001).i32(56, 3002).i32(60, 3003).i32(72, 9006); + // Power factors (×0.01), packed two per register: 77=A, 76=B, 79=C, 78=combined + p.u8(77, 98).u8(76, 97).u8(79, 96).u8(78, 95); + // Active energy (×0.1 kWh) + p.u32(80, 100001).u32(84, 100002).u32(88, 100003).u32(116, 300006); + // Reactive energy (×0.1 kvarh) + p.u32(92, 200001).u32(96, 200002).u32(100, 200003).u32(120, 600006); + // Apparent energy (×0.1 kVAh); phase A exceeds 16 bits to exercise the high word. + p.u32(104, 300001).u32(108, 300002).u32(112, 300003).u32(124, 900006); + return p; +} + +// One PZEM6L24 with every sensor it can drive attached, so a test can assert that each byte offset in +// the register map reaches the sensor it is documented to feed. +struct Harness { + PZEM6L24 pzem; + + sensor::Sensor voltage_a, voltage_b, voltage_c; + sensor::Sensor current_a, current_b, current_c; + sensor::Sensor active_power_a, active_power_b, active_power_c; + sensor::Sensor reactive_power_a, reactive_power_b, reactive_power_c; + sensor::Sensor apparent_power_a, apparent_power_b, apparent_power_c; + sensor::Sensor power_factor_a, power_factor_b, power_factor_c; + sensor::Sensor active_energy_a, active_energy_b, active_energy_c; + sensor::Sensor reactive_energy_a, reactive_energy_b, reactive_energy_c; + sensor::Sensor apparent_energy_a, apparent_energy_b, apparent_energy_c; + sensor::Sensor frequency; + sensor::Sensor total_active_power, total_reactive_power, total_apparent_power; + sensor::Sensor total_power_factor; + sensor::Sensor total_active_energy, total_reactive_energy, total_apparent_energy; + + Harness() { + this->pzem.set_voltage_a_sensor(&this->voltage_a); + this->pzem.set_voltage_b_sensor(&this->voltage_b); + this->pzem.set_voltage_c_sensor(&this->voltage_c); + this->pzem.set_current_a_sensor(&this->current_a); + this->pzem.set_current_b_sensor(&this->current_b); + this->pzem.set_current_c_sensor(&this->current_c); + this->pzem.set_active_power_a_sensor(&this->active_power_a); + this->pzem.set_active_power_b_sensor(&this->active_power_b); + this->pzem.set_active_power_c_sensor(&this->active_power_c); + this->pzem.set_reactive_power_a_sensor(&this->reactive_power_a); + this->pzem.set_reactive_power_b_sensor(&this->reactive_power_b); + this->pzem.set_reactive_power_c_sensor(&this->reactive_power_c); + this->pzem.set_apparent_power_a_sensor(&this->apparent_power_a); + this->pzem.set_apparent_power_b_sensor(&this->apparent_power_b); + this->pzem.set_apparent_power_c_sensor(&this->apparent_power_c); + this->pzem.set_power_factor_a_sensor(&this->power_factor_a); + this->pzem.set_power_factor_b_sensor(&this->power_factor_b); + this->pzem.set_power_factor_c_sensor(&this->power_factor_c); + this->pzem.set_active_energy_a_sensor(&this->active_energy_a); + this->pzem.set_active_energy_b_sensor(&this->active_energy_b); + this->pzem.set_active_energy_c_sensor(&this->active_energy_c); + this->pzem.set_reactive_energy_a_sensor(&this->reactive_energy_a); + this->pzem.set_reactive_energy_b_sensor(&this->reactive_energy_b); + this->pzem.set_reactive_energy_c_sensor(&this->reactive_energy_c); + this->pzem.set_apparent_energy_a_sensor(&this->apparent_energy_a); + this->pzem.set_apparent_energy_b_sensor(&this->apparent_energy_b); + this->pzem.set_apparent_energy_c_sensor(&this->apparent_energy_c); + this->pzem.set_frequency_sensor(&this->frequency); + this->pzem.set_total_active_power_sensor(&this->total_active_power); + this->pzem.set_total_reactive_power_sensor(&this->total_reactive_power); + this->pzem.set_total_apparent_power_sensor(&this->total_apparent_power); + this->pzem.set_total_power_factor_sensor(&this->total_power_factor); + this->pzem.set_total_active_energy_sensor(&this->total_active_energy); + this->pzem.set_total_reactive_energy_sensor(&this->total_reactive_energy); + this->pzem.set_total_apparent_energy_sensor(&this->total_apparent_energy); + } + + // A good poll, as the hub would deliver it. + void poll_ok() { this->pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); } +}; + +// A good poll followed by MAX_CONSECUTIVE_READ_FAILURES calls of `fail` must blank every reading. +template void expect_blanked_after_repeated(Harness &h, F &&fail) { + h.poll_ok(); + ASSERT_FALSE(std::isnan(h.voltage_a.state)); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + fail(); + } + EXPECT_TRUE(std::isnan(h.voltage_a.state)); + EXPECT_TRUE(std::isnan(h.total_active_energy.state)); +} + +} // namespace + +TEST(PZEM6L24Test, DecodesEveryRegisterToItsSensor) { + Harness h; + const auto response = make_reference_payload().response_pdu(); + + h.pzem.on_response(READ_REQUEST_PDU, response); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_b.state, 2302 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_c.state, 2303 * 0.1f); + + EXPECT_FLOAT_EQ(h.current_a.state, 1234 * 0.01f); + EXPECT_FLOAT_EQ(h.current_b.state, 1235 * 0.01f); + EXPECT_FLOAT_EQ(h.current_c.state, 1236 * 0.01f); + + EXPECT_FLOAT_EQ(h.frequency.state, 5001 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_power_a.state, 15001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_b.state, 15002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_c.state, -15003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_power.state, 45006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_power_a.state, 2001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_b.state, 2002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_c.state, 2003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_power.state, 6006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_power_a.state, 3001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_b.state, 3002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_c.state, 3003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_power.state, 9006 * 0.1f); + + EXPECT_FLOAT_EQ(h.power_factor_a.state, 98 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_b.state, 97 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_c.state, 96 * 0.01f); + EXPECT_FLOAT_EQ(h.total_power_factor.state, 95 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_energy_a.state, 100001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_b.state, 100002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_c.state, 100003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_energy_a.state, 200001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_b.state, 200002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_c.state, 200003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_energy.state, 600006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_energy_a.state, 300001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_b.state, 300002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_c.state, 300003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_energy.state, 900006 * 0.1f); +} + +// Unconfigured sensors must be skipped rather than dereferenced. +TEST(PZEM6L24Test, PublishesOnlyConfiguredSensors) { + PZEM6L24 pzem; + sensor::Sensor voltage_a; + pzem.set_voltage_a_sensor(&voltage_a); + + pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); + + EXPECT_TRUE(voltage_a.has_state()); + EXPECT_FLOAT_EQ(voltage_a.state, 2301 * 0.1f); +} + +// The acknowledgement of the 0x42 reset command carries no measurements and must not be decoded. +TEST(PZEM6L24Test, IgnoresResetAcknowledgement) { + Harness h; + const uint8_t ack_pdu[] = {0x42, 0x00, 0x0F}; + + h.pzem.on_response(RESET_REQUEST_PDU, ack_pdu); + + EXPECT_FALSE(h.voltage_a.has_state()); + EXPECT_FALSE(h.total_active_energy.has_state()); +} + +// A truncated response must be rejected rather than decoded from out-of-range bytes. +TEST(PZEM6L24Test, PublishesNanOnShortPayload) { + Harness h; + std::vector short_pdu{0x04, 10}; + short_pdu.resize(12, 0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, short_pdu); }); +} + +// A byte-count-0 reply the hub still dispatches is as undecodable as any other wrong size. +TEST(PZEM6L24Test, PublishesNanOnEmptyPayload) { + Harness h; + const uint8_t empty_pdu[] = {0x04, 0x00}; + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, empty_pdu); }); +} + +// A response longer than the register map did not come from the expected frame layout. +TEST(PZEM6L24Test, PublishesNanOnOversizedPayload) { + Harness h; + auto long_pdu = make_reference_payload().response_pdu(); + long_pdu.push_back(0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, long_pdu); }); +} + +TEST(PZEM6L24Test, PublishesNanWhenTheMeterDoesNotRespond) { + Harness h; + expect_blanked_after_repeated(h, [&] { EXPECT_FALSE(h.pzem.on_no_response(READ_REQUEST_PDU)); }); +} + +TEST(PZEM6L24Test, PublishesNanOnExceptionResponse) { + Harness h; + expect_blanked_after_repeated( + h, [&] { h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); }); +} + +// A read dropped from the transmit queue never reaches the meter. +TEST(PZEM6L24Test, PublishesNanWhenTheReadIsNotSent) { + Harness h; + expect_blanked_after_repeated(h, [&] { h.pzem.on_not_sent(READ_REQUEST_PDU); }); +} + +// Readings ride out isolated failures, and a good poll restarts the count. +TEST(PZEM6L24Test, KeepsReadingsUntilFailuresReachTheThreshold) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_no_response(READ_REQUEST_PDU); + } + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + + h.poll_ok(); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::SERVICE_DEVICE_FAILURE); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); +} + +// update()'s refusal branch is pinned against a real hub in both directions. +TEST(PZEM6L24Test, KeepsReadingsWhenAPollIsAbsorbedIntoAReadInFlight) { + // Declared before the harness so it outlives it: ~ModbusClientDevice clears its frames from the hub. + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + h.poll_ok(); + + // A read entry serves at most two requests; the third poll is refused while two callbacks are owed. + h.pzem.update(); + h.pzem.update(); + h.pzem.update(); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// A refusal with nothing in flight means no callback is coming, so it counts as a failed poll. +TEST(PZEM6L24Test, PublishesNanWhenThePollCannotBeQueued) { + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + + // Fill the transmit queue with frames for another address, so the poll is refused rather than absorbed. + for (uint16_t i = 0; i < modbus::MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t filler_pdu[] = {0x04, 0x00, static_cast(i), 0x00, 0x01}; + ASSERT_TRUE(hub.queue_pdu(0x02, filler_pdu)); + } + + expect_blanked_after_repeated(h, [&] { h.pzem.update(); }); +} + +// A failed reset command says nothing about the measurements, so it must not blank them. +TEST(PZEM6L24Test, KeepsReadingsWhenTheResetCommandFails) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + EXPECT_FALSE(h.pzem.on_no_response(RESET_REQUEST_PDU)); + h.pzem.on_error(RESET_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_FUNCTION); + h.pzem.on_not_sent(RESET_REQUEST_PDU); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// The reset is irreversible, so the phase selector byte is pinned here. +TEST(PZEM6L24Test, BuildsTheResetFrameForEveryPhase) { + const std::array, 5> cases{{ + {RESET_PHASE_A, 0x00}, + {RESET_PHASE_B, 0x01}, + {RESET_PHASE_C, 0x02}, + {RESET_PHASE_COMBINED, 0x03}, + {RESET_PHASE_ALL, 0x0F}, + }}; + + for (const auto &[phase, selector] : cases) { + const auto pdu = build_reset_pdu(phase); + EXPECT_EQ(pdu[0], 0x42) << "function code for selector " << static_cast(selector); + EXPECT_EQ(pdu[1], 0x00) << "reserved byte for selector " << static_cast(selector); + EXPECT_EQ(pdu[2], selector); + } +} + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/test.esp32-idf.yaml b/tests/components/pzem6l24/test.esp32-idf.yaml new file mode 100644 index 00000000000..9fb11cd095c --- /dev/null +++ b/tests/components/pzem6l24/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO13 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.esp8266-ard.yaml b/tests/components/pzem6l24/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e2b6a4d58f6 --- /dev/null +++ b/tests/components/pzem6l24/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.rp2040-ard.yaml b/tests/components/pzem6l24/test.rp2040-ard.yaml new file mode 100644 index 00000000000..e2b164efd25 --- /dev/null +++ b/tests/components/pzem6l24/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + pzem6l24: !include common.yaml From cfb7d8089e3a4fcbc1166bdcec2d95e1e9174712 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:42:21 +0000 Subject: [PATCH 60/94] [remote_base] Add AEG Hob2Hood Infrared Protocol (#4687) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 57 ++++++++++++- .../remote_base/hob2hood_protocol.cpp | 83 +++++++++++++++++++ .../remote_base/hob2hood_protocol.h | 39 +++++++++ tests/components/remote_base/__init__.py | 14 ++++ .../components/remote_base/hob2hood_test.cpp | 83 +++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_receiver/esp32-common.yaml | 4 + .../remote_transmitter/common-buttons.yaml | 5 ++ 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/hob2hood_protocol.cpp create mode 100644 esphome/components/remote_base/hob2hood_protocol.h create mode 100644 tests/components/remote_base/__init__.py create mode 100644 tests/components/remote_base/hob2hood_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e542ba3ed8f..befef872f0a 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -47,7 +47,7 @@ from esphome.const import ( from esphome.core import ID, coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType +from esphome.types import ConfigType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] @@ -2106,6 +2106,61 @@ async def aeha_action(var, config, args): cg.add(var.set_carrier_frequency(templ)) +# Hob2Hood +( + Hob2HoodData, + Hob2HoodBinarySensor, + Hob2HoodTrigger, + Hob2HoodAction, + Hob2HoodDumper, +) = declare_protocol("Hob2Hood") + +Hob2HoodCommand = remote_base_ns.enum("Hob2HoodCommand") +HOB2HOOD_COMMAND_OPTIONS = { + "light_off": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_OFF, + "light_on": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_ON, + "fan_off": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_OFF, + "fan_low": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_LOW, + "fan_medium": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MEDIUM, + "fan_high": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_HIGH, + "fan_max": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MAX, +} + +HOB2HOOD_SCHEMA = cv.Schema( + {cv.Required(CONF_COMMAND): cv.enum(HOB2HOOD_COMMAND_OPTIONS, lower=True)} +) + + +@register_binary_sensor("hob2hood", Hob2HoodBinarySensor, HOB2HOOD_SCHEMA) +def hob2hood_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + Hob2HoodData, + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("hob2hood", Hob2HoodTrigger, Hob2HoodData) +def hob2hood_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("hob2hood", Hob2HoodDumper) +def hob2hood_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +@register_action("hob2hood", Hob2HoodAction, HOB2HOOD_SCHEMA) +async def hob2hood_action( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: + template_ = await cg.templatable(config[CONF_COMMAND], args, Hob2HoodCommand) + cg.add(var.set_command(template_)) + + # Haier HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol( "Haier" diff --git a/esphome/components/remote_base/hob2hood_protocol.cpp b/esphome/components/remote_base/hob2hood_protocol.cpp new file mode 100644 index 00000000000..9e706b9fe66 --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.cpp @@ -0,0 +1,83 @@ +#include "hob2hood_protocol.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::remote_base { + +static const char *const TAG = "remote.hob2hood"; + +// A frame is 25 bits: a leading 0, then the command byte, command + 1 and command + 2. Zero bits are marks +// and one bits are spaces; equal neighbours merge into one run of n * BIT_TIME_US plus a fixed adjustment. +static constexpr uint8_t NBITS = 25; +static constexpr uint32_t BIT_TIME_US = 700; +static constexpr int32_t MARK_ADJUST_US = 300; +static constexpr int32_t SPACE_ADJUST_US = -200; +// The longest frame (light_off) has 18 runs +static constexpr uint8_t MAX_RUNS = 18; + +static constexpr std::array COMMANDS = { + HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW, + HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX, +}; +// Same order as COMMANDS; the last entry is the fallback +PROGMEM_STRING_TABLE(Hob2HoodCommandNames, "light_off", "light_on", "fan_off", "fan_low", "fan_medium", "fan_high", + "fan_max", "unknown"); + +// Walks the frame of `command` as runs of equal bits. emit(is_mark, length_us, is_last) returns false to stop. +template static bool walk_runs(Hob2HoodCommand command, F &&emit) { + // Shifted so the first of the 25 bits is the top bit + uint32_t bits = ((uint32_t(command) << 16) | (uint32_t(uint8_t(command + 1)) << 8) | uint8_t(command + 2)) + << (32 - NBITS); + int8_t run = 0; + for (uint8_t i = 0; i < NBITS; i++, bits <<= 1) { + const bool bit = (bits & 0x80000000) != 0; + run += bit ? 1 : -1; + const bool last = i == NBITS - 1; + if (last || (((bits << 1) & 0x80000000) != 0) != bit) { + const uint32_t length = BIT_TIME_US * std::abs(run) + (run < 0 ? MARK_ADJUST_US : SPACE_ADJUST_US); + if (!emit(run < 0, length, last)) + return false; + run = 0; + } + } + return true; +} + +void Hob2HoodProtocol::encode(RemoteTransmitData *dst, const Hob2HoodData &data) { + dst->set_carrier_frequency(38000); + dst->reserve(MAX_RUNS); + walk_runs(data.command, [dst](bool is_mark, uint32_t length, bool) { + if (is_mark) { + dst->mark(length); + } else { + dst->space(length); + } + return true; + }); +} + +optional Hob2HoodProtocol::decode(RemoteReceiveData src) { + for (auto command : COMMANDS) { + src.reset(); + // The receiver does not capture a trailing space, so the last run only has to match when it is a mark + const bool matched = walk_runs(command, [&src](bool is_mark, uint32_t length, bool last) { + return is_mark ? src.expect_mark(length) : (last || src.expect_space(length)); + }); + if (matched) + return Hob2HoodData{command}; + } + return {}; +} + +void Hob2HoodProtocol::dump(const Hob2HoodData &data) { + uint8_t index = 0; + while (index < COMMANDS.size() && COMMANDS[index] != data.command) + index++; + ESP_LOGI(TAG, "Received Hob2Hood: %s", + LOG_STR_ARG(Hob2HoodCommandNames::get_log_str(index, Hob2HoodCommandNames::LAST_INDEX))); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/hob2hood_protocol.h b/esphome/components/remote_base/hob2hood_protocol.h new file mode 100644 index 00000000000..44bd873a65b --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +enum Hob2HoodCommand : uint8_t { + HOB2HOOD_COMMAND_LIGHT_OFF = 0xd5, + HOB2HOOD_COMMAND_LIGHT_ON = 0xd2, + HOB2HOOD_COMMAND_FAN_OFF = 0xd8, + HOB2HOOD_COMMAND_FAN_LOW = 0x6c, + HOB2HOOD_COMMAND_FAN_MEDIUM = 0x6f, + HOB2HOOD_COMMAND_FAN_HIGH = 0xe1, + HOB2HOOD_COMMAND_FAN_MAX = 0x72, +}; + +struct Hob2HoodData { + Hob2HoodCommand command; + bool operator==(const Hob2HoodData &rhs) const { return this->command == rhs.command; } +}; + +class Hob2HoodProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const Hob2HoodData &data); + optional decode(RemoteReceiveData src); + void dump(const Hob2HoodData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Hob2Hood) + +template class Hob2HoodAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(Hob2HoodCommand, command) + void encode(RemoteTransmitData *dst, Ts... x) override { + Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); + } +}; + +} // namespace esphome::remote_base diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py new file mode 100644 index 00000000000..714f8ab571a --- /dev/null +++ b/tests/components/remote_base/__init__.py @@ -0,0 +1,14 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + async def to_code_testing(config: ConfigType) -> None: + # Protocol sources are compiled only behind their define; keep the ones under test. + request_protocol("hob2hood") + + manifest.to_code = to_code_testing + # AUTO_LOAD is not resolved by the unit test build. + manifest.dependencies = manifest.dependencies + ["binary_sensor"] diff --git a/tests/components/remote_base/hob2hood_test.cpp b/tests/components/remote_base/hob2hood_test.cpp new file mode 100644 index 00000000000..223e15fe495 --- /dev/null +++ b/tests/components/remote_base/hob2hood_test.cpp @@ -0,0 +1,83 @@ +#include +#include "esphome/components/remote_base/hob2hood_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr std::array ALL_COMMANDS = { + HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW, + HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX, +}; + +RawTimings encode(Hob2HoodCommand command) { + RemoteTransmitData data; + Hob2HoodProtocol().encode(&data, Hob2HoodData{command}); + return data.get_data(); +} + +optional decode(const RawTimings &timings, uint32_t tolerance = 25, + ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) { + return Hob2HoodProtocol().decode(RemoteReceiveData(timings, tolerance, mode)); +} + +void expect_decodes_to(const RawTimings &timings, Hob2HoodCommand command, uint32_t tolerance = 25, + ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) { + auto decoded = decode(timings, tolerance, mode); + ASSERT_TRUE(decoded.has_value()) << "command 0x" << std::hex << int(command); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->command, command); + } +} + +} // namespace + +// light_on is 0xd2: the bits 0 11010010 11010011 11010100 form 17 runs. A run of n zero bits is a mark of +// n * 700 + 300 us; a run of n one bits is a space of n * 700 - 200 us. +TEST(Hob2HoodProtocolTest, EncodesTheDocumentedTimings) { + const RawTimings expected = {1000, -1200, 1000, -500, 1700, -500, 1000, -1200, 1000, + -500, 1700, -2600, 1000, -500, 1000, -500, 1700}; + EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_ON), expected); +} + +TEST(Hob2HoodProtocolTest, LongestFrameFitsTheReservedLength) { + for (auto command : ALL_COMMANDS) { + EXPECT_LE(encode(command).size(), 18u) << "command 0x" << std::hex << int(command); + } + EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_OFF).size(), 18u); +} + +TEST(Hob2HoodProtocolTest, RoundTripsEveryCommand) { + for (auto command : ALL_COMMANDS) { + expect_decodes_to(encode(command), command); + } +} + +// A receiver never captures the trailing space, so a frame that ends in one must still decode without it. +TEST(Hob2HoodProtocolTest, DecodesWithoutTheTrailingSpace) { + for (auto command : ALL_COMMANDS) { + auto timings = encode(command); + if (timings.back() < 0) + timings.pop_back(); + expect_decodes_to(timings, command); + } +} + +// Real receivers shorten marks and lengthen spaces by a couple of hundred microseconds, which is why the +// documentation recommends a 350us tolerance. +TEST(Hob2HoodProtocolTest, DecodesSkewedTimingsWithinTheRecommendedTolerance) { + auto timings = encode(HOB2HOOD_COMMAND_FAN_HIGH); + for (auto &t : timings) { + t += t > 0 ? -240 : -230; + } + expect_decodes_to(timings, HOB2HOOD_COMMAND_FAN_HIGH, 350, TOLERANCE_MODE_TIME); +} + +TEST(Hob2HoodProtocolTest, RejectsAForeignFrame) { + const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560}; + EXPECT_FALSE(decode(nec_like).has_value()); + EXPECT_FALSE(decode({}).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 26a02d4dabc..5d12dc68d43 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -172,3 +172,8 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_hob2hood: + then: + - logger.log: + format: "on_hob2hood: %u" + args: ["x.command"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 14effcbd2c5..956cfdc3233 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -15,3 +15,7 @@ binary_sensor: panasonic: address: 0x4004 command: 0x100BCBD + - platform: remote_receiver + name: Hob2Hood Light On + hob2hood: + command: light_on diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 1f98646d931..a41ba346aa1 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -278,3 +278,8 @@ button: - remote_transmitter.digital_write: transmitter_id: xmitr value: !lambda return id(remote_transmitter_test_number).state > 127; + - platform: template + name: Hob2Hood + on_press: + - remote_transmitter.transmit_hob2hood: + command: light_on From 87b10d70a52ecd85b872515cd1c1f666df776f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:05:06 +0200 Subject: [PATCH 61/94] [mk2pvrouter] Add tag-based sensor defaults (#18870) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mk2pvrouter/mk2pvrouter.h | 2 +- .../components/mk2pvrouter/sensor/__init__.py | 109 ++++++++++++++++- .../mk2pvrouter/sensor/mk2pvrouter_sensor.cpp | 9 +- .../mk2pvrouter/sensor/mk2pvrouter_sensor.h | 8 +- tests/component_tests/mk2pvrouter/__init__.py | 0 .../mk2pvrouter/test_sensor_defaults.py | 84 +++++++++++++ tests/components/mk2pvrouter/__init__.py | 11 ++ tests/components/mk2pvrouter/common.h | 55 +++++++++ tests/components/mk2pvrouter/common.yaml | 71 ++++++----- .../mk2pvrouter/mk2pvrouter_test.cpp | 112 ++++++++++++++++++ .../sensor/mk2pvrouter_sensor_test.cpp | 25 ++++ 11 files changed, 449 insertions(+), 37 deletions(-) create mode 100644 tests/component_tests/mk2pvrouter/__init__.py create mode 100644 tests/component_tests/mk2pvrouter/test_sensor_defaults.py create mode 100644 tests/components/mk2pvrouter/__init__.py create mode 100644 tests/components/mk2pvrouter/common.h create mode 100644 tests/components/mk2pvrouter/mk2pvrouter_test.cpp create mode 100644 tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index fc23cf49e80..1a6af4b1b39 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -33,7 +33,7 @@ class Mk2PVRouterListener { }; // Reads frames via UART, validates their CRC, and publishes tag/value pairs to listeners. -class Mk2PVRouter final : public Component, public uart::UARTDevice { +class Mk2PVRouter : public Component, public uart::UARTDevice { public: #ifdef MK2PVROUTER_LISTENER_COUNT void register_mk2pvrouter_listener(Mk2PVRouterListener *listener); diff --git a/esphome/components/mk2pvrouter/sensor/__init__.py b/esphome/components/mk2pvrouter/sensor/__init__.py index 14fc48a6260..92ca6ea898a 100644 --- a/esphome/components/mk2pvrouter/sensor/__init__.py +++ b/esphome/components/mk2pvrouter/sensor/__init__.py @@ -1,6 +1,31 @@ +from dataclasses import dataclass +from typing import Any + import esphome.codegen as cg from esphome.components import sensor -from esphome.const import CONF_ID, CONF_TAG +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_STATE_CLASS, + CONF_TAG, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_EMPTY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_NONE, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PERCENT, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) from esphome.types import ConfigType from .. import ( @@ -14,13 +39,89 @@ Mk2PVRouterSensor = mk2pvrouter_ns.class_( "Mk2PVRouterSensor", sensor.Sensor, cg.Component ) -CONFIG_SCHEMA = sensor.sensor_schema(Mk2PVRouterSensor).extend( - MK2PVROUTER_LISTENER_SCHEMA + +@dataclass(frozen=True) +class TagKind: + """Sensor defaults for one kind of Mk2PVRouter output.""" + + unit_of_measurement: str + device_class: str + state_class: str + accuracy_decimals: int + # The device sends the value * 100; Mk2PVRouterSensor::publish_val() corrects it. + scale_centi: bool = False + + def defaults(self) -> dict[str, Any]: + return { + CONF_UNIT_OF_MEASUREMENT: self.unit_of_measurement, + CONF_DEVICE_CLASS: self.device_class, + CONF_STATE_CLASS: self.state_class, + CONF_ACCURACY_DECIMALS: self.accuracy_decimals, + } + + +POWER = TagKind(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT, 0) +VOLTAGE = TagKind( + UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, 2, scale_centi=True +) +ENERGY = TagKind(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING, 0) +TEMPERATURE = TagKind( + UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, 2, scale_centi=True +) +RELAY_STATE = TagKind(UNIT_EMPTY, DEVICE_CLASS_EMPTY, STATE_CLASS_NONE, 0) +DIVERSION_RATE = TagKind(UNIT_PERCENT, DEVICE_CLASS_EMPTY, STATE_CLASS_MEASUREMENT, 0) + +# Keyed by (letter, indexed). Per the Mk2PVRouter firmware protocol, D and R mean different +# things bare and indexed: D is diverted power (W) but D1, D2, ... are diversion rates (%); +# R is mean relay power (W) but R1, R2, ... are relay states. T is always indexed, E never. +TAG_KINDS = { + ("P", False): POWER, + ("P", True): POWER, + ("D", False): POWER, + ("D", True): DIVERSION_RATE, + ("V", False): VOLTAGE, + ("V", True): VOLTAGE, + ("E", False): ENERGY, + ("T", True): TEMPERATURE, + ("R", False): POWER, + ("R", True): RELAY_STATE, +} + + +def tag_kind(tag: str) -> TagKind | None: + """The kind of a tag such as P, V1 or R10, or None for a tag that is not a known output.""" + tag = tag.upper() + index = tag[1:] + if index and not index.isdigit(): + return None + return TAG_KINDS.get((tag[:1], bool(index))) + + +def _inject_tag_defaults(config: ConfigType) -> ConfigType: + """Fill in the tag's defaults for keys the user did not set; the sensor schema validates them.""" + if ( + isinstance(config, dict) + and isinstance(config.get(CONF_TAG), str) + and (kind := tag_kind(config[CONF_TAG])) is not None + ): + config = {**kind.defaults(), **config} + return config + + +CONFIG_SCHEMA = cv.All( + _inject_tag_defaults, + sensor.sensor_schema( + Mk2PVRouterSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(MK2PVROUTER_LISTENER_SCHEMA), ) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + tag = config[CONF_TAG] + kind = tag_kind(tag) + var = cg.new_Pvariable(config[CONF_ID], tag, kind is not None and kind.scale_centi) await cg.register_component(var, config) await sensor.register_sensor(var, config) mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp index 96f1ff5954e..5ea07307e49 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -5,7 +5,8 @@ namespace esphome::mk2pvrouter { static const char *const TAG = "mk2pvrouter_sensor"; -Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag) : Mk2PVRouterListener(tag) {} +Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag, bool scale_centi) + : Mk2PVRouterListener(tag), scale_centi_(scale_centi) {} void Mk2PVRouterSensor::publish_val(const char *val) { auto result = parse_number(val); @@ -13,7 +14,11 @@ void Mk2PVRouterSensor::publish_val(const char *val) { ESP_LOGW(TAG, "Failed to parse value '%s' for tag '%s'", val, this->get_tag()); return; } - this->publish_state(result.value()); + float value = result.value(); + if (this->scale_centi_) { + value *= 0.01f; + } + this->publish_state(value); } void Mk2PVRouterSensor::dump_config() { diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h index e4da41e384b..6ae4f2f0ad8 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h @@ -5,11 +5,15 @@ namespace esphome::mk2pvrouter { -class Mk2PVRouterSensor final : public Mk2PVRouterListener, public sensor::Sensor, public Component { +// Component before Sensor so the flag lands in Sensor's tail padding +class Mk2PVRouterSensor final : public Mk2PVRouterListener, public Component, public sensor::Sensor { public: - explicit Mk2PVRouterSensor(const char *tag); + Mk2PVRouterSensor(const char *tag, bool scale_centi); void publish_val(const char *val) override; void dump_config() override; + + protected: + bool scale_centi_; }; } // namespace esphome::mk2pvrouter diff --git a/tests/component_tests/mk2pvrouter/__init__.py b/tests/component_tests/mk2pvrouter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mk2pvrouter/test_sensor_defaults.py b/tests/component_tests/mk2pvrouter/test_sensor_defaults.py new file mode 100644 index 00000000000..165adb676a7 --- /dev/null +++ b/tests/component_tests/mk2pvrouter/test_sensor_defaults.py @@ -0,0 +1,84 @@ +"""Tag-based sensor defaults, driven through the real CONFIG_SCHEMA.""" + +import pytest + +from esphome.components import sensor +from esphome.components.mk2pvrouter.sensor import CONFIG_SCHEMA, tag_kind +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_EMPTY, + STATE_CLASS_MEASUREMENT, +) +from esphome.types import ConfigType + + +def _sensor(tag: str, **extra: object) -> ConfigType: + return CONFIG_SCHEMA( + {"tag": tag, "mk2pvrouter_id": "hub", "name": f"{tag} sensor", **extra} + ) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class", "decimals"), + [ + ("P", "W", "power", "measurement", 0), + ("P1", "W", "power", "measurement", 0), + ("D", "W", "power", "measurement", 0), + ("D1", "%", "", "measurement", 0), + ("V", "V", "voltage", "measurement", 2), + ("v1", "V", "voltage", "measurement", 2), + ("E", "Wh", "energy", "total_increasing", 0), + ("T1", "°C", "temperature", "measurement", 2), + ("R", "W", "power", "measurement", 0), + ("R1", "", "", "", 0), + ("R10", "", "", "", 0), + ], +) +def test_tag_defaults( + tag: str, unit: str, device_class: str, state_class: str, decimals: int +) -> None: + config = _sensor(tag) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == sensor.validate_state_class(state_class) + assert config[CONF_ACCURACY_DECIMALS] == decimals + + +@pytest.mark.parametrize("tag", ["S_MC", "STATUS", "X9", "Z", "T", "E1"]) +def test_unknown_tag_gets_only_the_schema_defaults(tag: str) -> None: + config = _sensor(tag) + assert CONF_UNIT_OF_MEASUREMENT not in config + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == sensor.validate_state_class( + STATE_CLASS_MEASUREMENT + ) + assert config[CONF_ACCURACY_DECIMALS] == 0 + + +def test_explicit_values_win_over_tag_defaults() -> None: + config = _sensor("P", device_class=DEVICE_CLASS_EMPTY, accuracy_decimals=3) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_EMPTY + assert config[CONF_ACCURACY_DECIMALS] == 3 + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + + +@pytest.mark.parametrize( + ("tag", "scale_centi"), + [ + ("V", True), + ("V1", True), + ("v2", True), + ("T1", True), + ("P", False), + ("D1", False), + ("E", False), + ("T", False), + ("X9", False), + ], +) +def test_centi_scale_follows_the_tag(tag: str, scale_centi: bool) -> None: + kind = tag_kind(tag) + assert (kind is not None and kind.scale_centi) is scale_centi diff --git a/tests/components/mk2pvrouter/__init__.py b/tests/components/mk2pvrouter/__init__.py new file mode 100644 index 00000000000..a750a4217c9 --- /dev/null +++ b/tests/components/mk2pvrouter/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Listener storage is sized by code generation; the gtests register one listener per hub. + cg.add_define("MK2PVROUTER_LISTENER_COUNT", 1) + + manifest.to_code = to_code_testing diff --git a/tests/components/mk2pvrouter/common.h b/tests/components/mk2pvrouter/common.h new file mode 100644 index 00000000000..c954c8bb698 --- /dev/null +++ b/tests/components/mk2pvrouter/common.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" + +namespace esphome::mk2pvrouter::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(std::initializer_list data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +class TestableMk2PVRouter : public Mk2PVRouter { + public: + using Mk2PVRouter::Mk2PVRouter; + using Mk2PVRouter::State; + using Mk2PVRouter::buf_index_; + using Mk2PVRouter::calculate_crc_; + using Mk2PVRouter::check_crc_; + using Mk2PVRouter::process_group_; + using Mk2PVRouter::state_; +}; + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/common.yaml b/tests/components/mk2pvrouter/common.yaml index 4421c098545..cdd4a70f817 100644 --- a/tests/components/mk2pvrouter/common.yaml +++ b/tests/components/mk2pvrouter/common.yaml @@ -3,44 +3,59 @@ mk2pvrouter: uart_id: uart_bus sensor: + # Exact tags - platform: mk2pvrouter name: Power tag: P mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: W - device_class: power - state_class: measurement - accuracy_decimals: 0 - - - platform: mk2pvrouter - name: Voltage - tag: V - mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: V - device_class: voltage - state_class: measurement - accuracy_decimals: 2 - filters: - # Device sends voltage * 100 - - multiply: 0.01 + # An explicit value must win over the tag default + device_class: "" - platform: mk2pvrouter name: Energy tag: E mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - accuracy_decimals: 0 + + # Indexed tags + - platform: mk2pvrouter + name: Power Phase 1 + tag: P1 + mk2pvrouter_id: test_mk2pvrouter - platform: mk2pvrouter - name: Temperature + name: Power Phase 2 + tag: P2 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Voltage Phase 1 + tag: V1 + mk2pvrouter_id: test_mk2pvrouter + + # The x0.01 correction is applied before filters, so a user filter stacks on the corrected value + - platform: mk2pvrouter + name: Voltage Phase 2 Scaled + tag: V2 + mk2pvrouter_id: test_mk2pvrouter + filters: + - multiply: 1.0 + + - platform: mk2pvrouter + name: Temperature 1 tag: T1 mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: "°C" - device_class: temperature - state_class: measurement - accuracy_decimals: 2 - filters: - # Device sends temperature * 100 - - multiply: 0.01 + + - platform: mk2pvrouter + name: Diversion Rate 1 + tag: D1 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Relay 1 State + tag: R1 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Relay 10 State + tag: R10 + mk2pvrouter_id: test_mk2pvrouter diff --git a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp new file mode 100644 index 00000000000..84106eb0b77 --- /dev/null +++ b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp @@ -0,0 +1,112 @@ +#include + +#include + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "common.h" + +namespace esphome::mk2pvrouter::testing { + +namespace { +class TestListener final : public Mk2PVRouterListener { + public: + explicit TestListener(const char *tag) : Mk2PVRouterListener(tag) {} + void publish_val(const char *val) override { + this->published_ = true; + this->last_val_ = val; + } + + bool published_{false}; + std::string last_val_; +}; + +class Mk2PVRouterTest : public ::testing::Test { + protected: + void SetUp() override { this->sut_.register_mk2pvrouter_listener(&this->listener_); } + + // Feeds one "tagvaluecrc" literal, as the frame parser does after END_FRAME. + template void process_(const char (&group)[N]) { this->sut_.process_group_(group, group + N - 1); } + + TestableMk2PVRouter sut_; + TestListener listener_{"P1"}; +}; +} // namespace + +TEST(Mk2PVRouterListenerTest, GetTagReturnsConstructorTag) { + TestListener listener("P1"); + EXPECT_STREQ(listener.get_tag(), "P1"); +} + +TEST_F(Mk2PVRouterTest, CalculateCrcMatchesKnownGroup) { + // "P1\t1234\t" sums to 0x3D ('=') per the mk2pvrouter CRC algorithm. + const char grp[] = "P1\t1234\t="; + EXPECT_EQ(this->sut_.calculate_crc_(grp, sizeof(grp) - 1), '='); +} + +TEST_F(Mk2PVRouterTest, CheckCrcAcceptsMatchingCrc) { + const char grp[] = "P1\t1234\t="; + EXPECT_TRUE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); +} + +TEST_F(Mk2PVRouterTest, CheckCrcRejectsMismatchedCrc) { + const char grp[] = "P1\t1234\t!"; + EXPECT_FALSE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); +} + +TEST_F(Mk2PVRouterTest, CheckCrcRejectsEmptyGroup) { + const char grp[] = ""; + EXPECT_FALSE(this->sut_.check_crc_(grp, grp)); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupPublishesValidGroupToMatchingListener) { + this->process_("P1\t1234\t="); + EXPECT_TRUE(this->listener_.published_); + EXPECT_EQ(this->listener_.last_val_, "1234"); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupDropsGroupWithBadCrc) { + this->process_("P1\t1234\t!"); + EXPECT_FALSE(this->listener_.published_); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupDropsGroupMissingValue) { + // "P1\t" sums to 0x2A ('*'), so the CRC passes, but there is no second TAB for the value. + this->process_("P1\t*"); + EXPECT_FALSE(this->listener_.published_); +} + +TEST_F(Mk2PVRouterTest, LoopParsesFullFrameAndPublishesToListener) { + MockUARTComponent uart; + this->sut_.set_uart_parent(&uart); + + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::WAITING_FOR_START); + + // STX, LF "P1\t1234\t=" CR, ETX + uart.push_rx({0x02, 0x0a, 'P', '1', '\t', '1', '2', '3', '4', '\t', '=', 0x0d, 0x03}); + + this->sut_.loop(); // Consume bytes up to and including START_FRAME. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::START_FRAME_RECEIVED); + + this->sut_.loop(); // Buffer the frame body up to END_FRAME. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::END_FRAME_RECEIVED); + + this->sut_.loop(); // Parse the buffered groups and publish them. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::WAITING_FOR_START); + EXPECT_EQ(this->sut_.buf_index_, 0); + EXPECT_TRUE(this->listener_.published_); + EXPECT_EQ(this->listener_.last_val_, "1234"); +} + +TEST_F(Mk2PVRouterTest, LoopIgnoresBytesBeforeStartFrame) { + MockUARTComponent uart; + this->sut_.set_uart_parent(&uart); + + uart.push_rx({'g', 'a', 'r', 'b', 'a', 'g', 'e', 0x02}); + + this->sut_.loop(); + + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::START_FRAME_RECEIVED); + EXPECT_EQ(this->sut_.buf_index_, 0); +} + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp b/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp new file mode 100644 index 00000000000..cf70a32a650 --- /dev/null +++ b/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp @@ -0,0 +1,25 @@ +#include + +#include "esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h" + +namespace esphome::mk2pvrouter::testing { + +TEST(Mk2PVRouterSensorTest, ScaleCentiTrueScalesByOneHundredth) { + Mk2PVRouterSensor sensor("V", true); + sensor.publish_val("23042"); + EXPECT_FLOAT_EQ(sensor.get_raw_state(), 230.42f); +} + +TEST(Mk2PVRouterSensorTest, ScaleCentiFalseDoesNotScale) { + Mk2PVRouterSensor sensor("P1", false); + sensor.publish_val("1234"); + EXPECT_FLOAT_EQ(sensor.get_raw_state(), 1234.0f); +} + +TEST(Mk2PVRouterSensorTest, UnparseableValueIsNotPublished) { + Mk2PVRouterSensor sensor("V1", true); + sensor.publish_val("not-a-number"); + EXPECT_FALSE(sensor.has_state()); +} + +} // namespace esphome::mk2pvrouter::testing From bcae1f28ef8342cdc1d2fb1cd68b05071b545a77 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sat, 26 Sep 2026 02:11:25 +0200 Subject: [PATCH 62/94] [web_server] Expose temperature units for climate and water heater entities (#19572) --- esphome/components/web_server/web_server.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6e48e2700f7..fef484cd669 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1600,6 +1600,7 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json root[ESPHOME_F("min_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_min_temperature(), target_accuracy), temp_buf); root[ESPHOME_F("step")] = traits.get_visual_target_temperature_step(); + root[ESPHOME_F("temperature_unit")] = static_cast(traits.get_temperature_unit()); this->add_sorting_info_(root, obj); } @@ -1981,6 +1982,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); + root[ESPHOME_F("temperature_unit")] = static_cast(traits.get_temperature_unit()); this->add_sorting_info_(root, obj); } From 2f4eb3e1eea425d415b47e2f7054a2e21652588c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:28:40 +0100 Subject: [PATCH 63/94] [audio_adc] Use register_apply_action for audio_adc.set_mic_gain (#19679) --- esphome/components/audio_adc/__init__.py | 25 ++++------------------- esphome/components/audio_adc/automation.h | 21 ------------------- 2 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 esphome/components/audio_adc/automation.h diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index c2bdfb6cb0b..7d4b51db047 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -1,9 +1,8 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MIC_GAIN -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.const import CONF_MIC_GAIN +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -12,8 +11,6 @@ IS_PLATFORM_COMPONENT = True audio_adc_ns = cg.esphome_ns.namespace("audio_adc") AudioAdc = audio_adc_ns.class_("AudioAdc") -SetMicGainAction = audio_adc_ns.class_("SetMicGainAction", automation.Action) - SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( { @@ -24,25 +21,11 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "audio_adc.set_mic_gain", - SetMicGainAction, SET_MIC_GAIN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MIC_GAIN, "set_mic_gain", cg.float_), ) -async def audio_adc_set_mic_gain_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config.get(CONF_MIC_GAIN), args, cg.float_) - cg.add(var.set_mic_gain(template_)) - - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h deleted file mode 100644 index fc7af256228..00000000000 --- a/esphome/components/audio_adc/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "audio_adc.h" - -namespace esphome::audio_adc { - -template class SetMicGainAction final : public Action { - public: - explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} - - TEMPLATABLE_VALUE(float, mic_gain) - - void play(const Ts &...x) override { this->audio_adc_->set_mic_gain(this->mic_gain_.value(x...)); } - - protected: - AudioAdc *audio_adc_; -}; - -} // namespace esphome::audio_adc From 1889f6c9b55d828c3a615a39f106b52d32bff797 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:28:58 +0100 Subject: [PATCH 64/94] [max17043] Use register_apply_action for max17043.sleep_mode (#19682) --- esphome/components/max17043/automation.h | 18 ------------------ esphome/components/max17043/sensor.py | 10 ++-------- 2 files changed, 2 insertions(+), 26 deletions(-) delete mode 100644 esphome/components/max17043/automation.h diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h deleted file mode 100644 index 6b19e5bd5e9..00000000000 --- a/esphome/components/max17043/automation.h +++ /dev/null @@ -1,18 +0,0 @@ - -#pragma once -#include "esphome/core/automation.h" -#include "max17043.h" - -namespace esphome::max17043 { - -template class SleepAction final : public Action { - public: - explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {} - - void play(const Ts &...x) override { this->max17043_->sleep_mode(); } - - protected: - MAX17043Component *max17043_; -}; - -} // namespace esphome::max17043 diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 7de1424a96d..0b72955c74e 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -23,9 +23,6 @@ MAX17043Component = max17043_ns.class_( "MAX17043Component", cg.PollingComponent, i2c.I2CDevice ) -# Actions -SleepAction = max17043_ns.class_("SleepAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -72,9 +69,6 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "max17043.sleep_mode", - SleepAction, - MAX17043_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "max17043.sleep_mode", MAX17043_ACTION_SCHEMA, automation.ApplyCall("sleep_mode()") ) From 3e85af29c60e8067d02ac565bbd6b00188a7cd07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:29:04 +0100 Subject: [PATCH 65/94] [hc8] Use register_apply_action for hc8.calibrate (#19681) --- esphome/components/hc8/hc8.h | 8 -------- esphome/components/hc8/sensor.py | 19 ++----------------- tests/components/hc8/common-actions.yaml | 7 +++++++ .../hc8/test-actions.esp8266-ard.yaml | 8 ++++++++ 4 files changed, 17 insertions(+), 25 deletions(-) create mode 100644 tests/components/hc8/common-actions.yaml create mode 100644 tests/components/hc8/test-actions.esp8266-ard.yaml diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h index 681dffe4f6c..d3399c8a2d1 100644 --- a/esphome/components/hc8/hc8.h +++ b/esphome/components/hc8/hc8.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -26,11 +25,4 @@ class HC8Component final : public PollingComponent, public uart::UARTDevice { bool warmup_complete_{false}; }; -template class HC8CalibrateAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, baseline) - - void play(const Ts &...x) override { this->parent_->calibrate(this->baseline_.value(x...)); } -}; - } // namespace esphome::hc8 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 8a19cce8d18..05d8e233ef3 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -12,15 +12,12 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["uart"] hc8_ns = cg.esphome_ns.namespace("hc8") HC8Component = hc8_ns.class_("HC8Component", cg.PollingComponent, uart.UARTDevice) -HC8CalibrateAction = hc8_ns.class_("HC8CalibrateAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -73,20 +70,8 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "hc8.calibrate", - HC8CalibrateAction, CALIBRATION_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_BASELINE, "calibrate", cg.uint16), ) -async def hc8_calibration_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) - cg.add(var.set_baseline(template_)) - return var diff --git a/tests/components/hc8/common-actions.yaml b/tests/components/hc8/common-actions.yaml new file mode 100644 index 00000000000..798ec4cbe6b --- /dev/null +++ b/tests/components/hc8/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: HC8 Calibrate + on_press: + - hc8.calibrate: + id: hc8_sensor + baseline: !lambda return 400; diff --git a/tests/components/hc8/test-actions.esp8266-ard.yaml b/tests/components/hc8/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c19ee22eb39 --- /dev/null +++ b/tests/components/hc8/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + hc8: !include common.yaml + actions: !include common-actions.yaml From 5043e12690ed8323f9180c1831c19762a90388e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:29:14 +0100 Subject: [PATCH 66/94] [cs5460a] Use register_apply_action for cs5460a.restart (#19683) --- esphome/components/cs5460a/cs5460a.h | 11 ----------- esphome/components/cs5460a/sensor.py | 6 ++---- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index 87ea858c70c..80a63d808fb 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/spi/spi.h" @@ -108,14 +107,4 @@ class CS5460AComponent final : public Component, uint32_t prev_raw_energy_{0}; }; -template class CS5460ARestartAction final : public Action { - public: - CS5460ARestartAction(CS5460AComponent *cs5460a) : cs5460a_(cs5460a) {} - - void play(const Ts &...x) override { cs5460a_->restart(); } - - protected: - CS5460AComponent *cs5460a_; -}; - } // namespace esphome::cs5460a diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 946037fc34d..17ea41ed04d 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -30,7 +30,6 @@ PGA_GAIN_OPTIONS = { } CS5460AComponent = cs5460a_ns.class_("CS5460AComponent", spi.SPIDevice, cg.Component) -CS5460ARestartAction = cs5460a_ns.class_("CS5460ARestartAction", automation.Action) CONF_SAMPLES = "samples" CONF_PHASE_OFFSET = "phase_offset" @@ -129,13 +128,12 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_power_sensor(sens)) -automation.register_simple_action( +automation.register_apply_action( "cs5460a.restart", - CS5460ARestartAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(CS5460AComponent), } ), - synchronous=True, + automation.ApplyCall("restart()"), ) From d34b21cb7efe1cec1a1325dbf7b62f35a11ae6e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:30:12 +0100 Subject: [PATCH 67/94] [pipsolar] Use register_apply_action for output.pipsolar.set_level (#19685) --- .../components/pipsolar/output/__init__.py | 19 ++----------------- .../pipsolar/output/pipsolar_output.h | 12 ------------ tests/components/pipsolar/common-actions.yaml | 7 +++++++ .../pipsolar/test-actions.esp8266-ard.yaml | 8 ++++++++ 4 files changed, 17 insertions(+), 29 deletions(-) create mode 100644 tests/components/pipsolar/common-actions.yaml create mode 100644 tests/components/pipsolar/test-actions.esp8266-ard.yaml diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 62e6d0f113a..e3c841d1dde 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VALUE -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -12,7 +10,6 @@ from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns DEPENDENCIES = ["pipsolar"] PipsolarOutput = pipsolar_ns.class_("PipsolarOutput", output.FloatOutput) -SetOutputAction = pipsolar_ns.class_("SetOutputAction", automation.Action) CONF_POSSIBLE_VALUES = "possible_values" @@ -92,25 +89,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_possible_values(conf[CONF_POSSIBLE_VALUES])) -@automation.register_action( +automation.register_apply_action( "output.pipsolar.set_level", - SetOutputAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PipsolarOutput), cv.Required(CONF_VALUE): cv.templatable(cv.positive_float), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), ) -async def output_pipsolar_set_level_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_level(template_)) - return var diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 6fc013c2765..bf82fa63a81 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -27,16 +27,4 @@ class PipsolarOutput final : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction final : public Action { - public: - SetOutputAction(PipsolarOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_value(this->level_.value(x...)); } - - protected: - PipsolarOutput *output_; -}; - } // namespace esphome::pipsolar diff --git a/tests/components/pipsolar/common-actions.yaml b/tests/components/pipsolar/common-actions.yaml new file mode 100644 index 00000000000..960c512a1dc --- /dev/null +++ b/tests/components/pipsolar/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Pipsolar Set Level + on_press: + - output.pipsolar.set_level: + id: inverter0_battery_recharge_voltage_out + value: !lambda return 49.0; diff --git a/tests/components/pipsolar/test-actions.esp8266-ard.yaml b/tests/components/pipsolar/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..38154131053 --- /dev/null +++ b/tests/components/pipsolar/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + pipsolar: !include common.yaml + actions: !include common-actions.yaml From 3a9048cc62c898e59a2de81a74264350a858949b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:30:16 +0100 Subject: [PATCH 68/94] [speaker_source] Use register_apply_action for speaker_source.set_playlist_delay (#19686) --- .../components/speaker_source/automation.h | 29 ------------------ .../components/speaker_source/media_player.py | 30 ++++--------------- 2 files changed, 5 insertions(+), 54 deletions(-) delete mode 100644 esphome/components/speaker_source/automation.h diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h deleted file mode 100644 index a03fa424777..00000000000 --- a/esphome/components/speaker_source/automation.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/core/automation.h" -#include "speaker_source_media_player.h" - -namespace esphome::speaker_source { - -template class SetPlaylistDelayAction final : public Action { - public: - explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(uint8_t, pipeline) - TEMPLATABLE_VALUE(uint32_t, delay) - - void play(const Ts &...x) override { - this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...)); - } - - protected: - SpeakerSourceMediaPlayer *parent_; -}; - -} // namespace esphome::speaker_source - -#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index b6653fe5433..4f150e87fff 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -16,8 +16,6 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_SPEAKER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -59,10 +57,6 @@ _PIPELINE_INFO = { ), } -SetPlaylistDelayAction = speaker_source_ns.class_( - "SetPlaylistDelayAction", automation.Action -) - _validate_pipeline = media_player.validate_preferred_format( "speaker_source media_player", CONF_SPEAKER @@ -232,25 +226,11 @@ SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "speaker_source.set_playlist_delay", - SetPlaylistDelayAction, SET_PLAYLIST_DELAY_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_playlist_delay_ms({}, {})", + ((CONF_PIPELINE, cg.uint8), (CONF_DELAY, cg.uint32)), + ), ) -async def set_playlist_delay_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - - template_ = await cg.templatable(config[CONF_PIPELINE], args, cg.uint8) - cg.add(var.set_pipeline(template_)) - - template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) - cg.add(var.set_delay(template_)) - - return var From 532a6ed2391b41dd36d53bed44ac7782bf12c5e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:30:56 +0100 Subject: [PATCH 69/94] [mixer] Use register_apply_action for mixer_speaker.apply_ducking (#19689) --- esphome/components/mixer/speaker/__init__.py | 29 ++++--------------- esphome/components/mixer/speaker/automation.h | 18 ------------ tests/components/mixer/common-actions.yaml | 8 +++++ .../mixer/test-actions.esp32-idf.yaml | 8 +++++ 4 files changed, 21 insertions(+), 42 deletions(-) delete mode 100644 esphome/components/mixer/speaker/automation.h create mode 100644 tests/components/mixer/common-actions.yaml create mode 100644 tests/components/mixer/test-actions.esp32-idf.yaml diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 26619f35a76..44473b12c32 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -15,9 +15,7 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, ) -from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -32,10 +30,6 @@ CONF_DECIBEL_REDUCTION = "decibel_reduction" CONF_QUEUE_MODE = "queue_mode" CONF_SOURCE_SPEAKERS = "source_speakers" -DuckingApplyAction = mixer_speaker_ns.class_( - "DuckingApplyAction", automation.Action, cg.Parented.template(SourceSpeaker) -) - SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( { @@ -148,9 +142,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_source_speaker(source_speaker)) -@automation.register_action( +automation.register_apply_action( "mixer_speaker.apply_ducking", - DuckingApplyAction, cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), @@ -162,20 +155,8 @@ async def to_code(config: ConfigType) -> None: ), } ), - synchronous=True, + automation.ApplyCall( + "apply_ducking({}, {})", + ((CONF_DECIBEL_REDUCTION, cg.uint8), (CONF_DURATION, cg.uint32)), + ), ) -async def ducking_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - decibel_reduction = await cg.templatable( - config[CONF_DECIBEL_REDUCTION], args, cg.uint8 - ) - cg.add(var.set_decibel_reduction(decibel_reduction)) - duration = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(duration)) - return var diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h deleted file mode 100644 index ea51b6b8892..00000000000 --- a/esphome/components/mixer/speaker/automation.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mixer_speaker.h" - -#ifdef USE_ESP32 - -namespace esphome::mixer_speaker { -template class DuckingApplyAction final : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, decibel_reduction); - TEMPLATABLE_VALUE(uint32_t, duration); - void play(const Ts &...x) override { - this->parent_->apply_ducking(this->decibel_reduction_.value(x...), this->duration_.value(x...)); - } -}; -} // namespace esphome::mixer_speaker - -#endif diff --git a/tests/components/mixer/common-actions.yaml b/tests/components/mixer/common-actions.yaml new file mode 100644 index 00000000000..f86009e6284 --- /dev/null +++ b/tests/components/mixer/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: Duck media + on_press: + - mixer_speaker.apply_ducking: + id: source_speaker_1_id + decibel_reduction: !lambda return 20; + duration: !lambda return 250; diff --git a/tests/components/mixer/test-actions.esp32-idf.yaml b/tests/components/mixer/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..7c214bf8641 --- /dev/null +++ b/tests/components/mixer/test-actions.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + dout_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + mixer: !include common.yaml + actions: !include common-actions.yaml From c0c1643efec8dbfa7e2e76ec5db53a0ca936b2ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:31:05 +0100 Subject: [PATCH 70/94] [zigbee] Use register_apply_action for zigbee.factory_reset (#19690) --- esphome/components/zigbee/__init__.py | 11 ++--------- esphome/components/zigbee/automation.h | 19 ------------------- 2 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 esphome/components/zigbee/automation.h diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 8e9824d42e7..1fa6110f04b 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -30,7 +30,6 @@ from .const import ( POWER_SOURCE, REPORT, ZigbeeComponent, - zigbee_ns, ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, @@ -293,14 +292,8 @@ ZIGBEE_ACTION_SCHEMA = automation.maybe_simple_id( ) ) -FactoryResetAction = zigbee_ns.class_( - "FactoryResetAction", automation.Action, cg.Parented.template(ZigbeeComponent) -) - - -automation.register_parented_action( +automation.register_apply_action( "zigbee.factory_reset", - FactoryResetAction, ZIGBEE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("factory_reset()"), ) diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h deleted file mode 100644 index 1f953100d90..00000000000 --- a/esphome/components/zigbee/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#ifdef USE_ZIGBEE -#ifdef USE_ESP32 -#include "zigbee_esp32.h" -#endif -#ifdef USE_NRF52 -#include "zigbee_zephyr.h" -#endif -namespace esphome::zigbee { - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::zigbee - -#endif From d1dd4e53ce989fc6ed88edafc89970a74cde47ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:31:41 +0100 Subject: [PATCH 71/94] [zephyr_ble_server] Use register_apply_action for ble_server.numeric_comparison_reply (#19692) --- .../components/zephyr_ble_server/__init__.py | 25 +++---------------- .../components/zephyr_ble_server/ble_server.h | 12 --------- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 463b9c08870..59e5bc556bf 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -3,8 +3,7 @@ import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE from esphome.types import ConfigType zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") @@ -49,10 +48,6 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -BLENumericComparisonReplyAction = zephyr_ble_server_ns.class_( - "BLENumericComparisonReplyAction", automation.Action -) - BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEServer), @@ -61,22 +56,8 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ble_server.numeric_comparison_reply", - BLENumericComparisonReplyAction, BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ACCEPT, "numeric_comparison_reply", cg.bool_), ) -async def numeric_comparison_reply_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - - templ = await cg.templatable(config[CONF_ACCEPT], args, cg.bool_) - cg.add(var.set_accept(templ)) - - return var diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index 223dbf7ac98..ef3daff9164 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -21,17 +21,5 @@ class BLEServer final : public Component { CallbackManager passkey_cb_; }; -template class BLENumericComparisonReplyAction final : public Action { - public: - explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(bool, accept) - - void play(const Ts &...x) override { this->parent_->numeric_comparison_reply(this->accept_.value(x...)); } - - protected: - BLEServer *parent_; -}; - } // namespace esphome::zephyr_ble_server #endif From c902083ef35937eb32f22f228e2c5e3e21321c3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:31:49 +0100 Subject: [PATCH 72/94] [dfrobot_sen0395] Use register_apply_action for dfrobot_sen0395.reset (#19693) --- esphome/components/dfrobot_sen0395/__init__.py | 8 ++------ esphome/components/dfrobot_sen0395/automation.h | 6 ------ esphome/components/dfrobot_sen0395/dfrobot_sen0395.h | 2 ++ 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 81d57026cc9..42627a30f6f 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -20,9 +20,6 @@ DfrobotSen0395Component = dfrobot_sen0395_ns.class_( ) # Actions -DfrobotSen0395ResetAction = dfrobot_sen0395_ns.class_( - "DfrobotSen0395ResetAction", automation.Action -) DfrobotSen0395SettingsAction = dfrobot_sen0395_ns.class_( "DfrobotSen0395SettingsAction", automation.Action ) @@ -49,15 +46,14 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) -automation.register_parented_action( +automation.register_apply_action( "dfrobot_sen0395.reset", - DfrobotSen0395ResetAction, maybe_simple_id( { cv.GenerateID(): cv.use_id(DfrobotSen0395Component), } ), - synchronous=True, + automation.ApplyCall("reset_system()"), ) diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index a5f4c99014d..c3632085310 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -7,12 +7,6 @@ namespace esphome::dfrobot_sen0395 { -template -class DfrobotSen0395ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) { this->parent_->enqueue(make_unique()); } -}; - template class DfrobotSen0395SettingsAction final : public Action, public Parented { public: diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h index 448a18a4775..500f20d8dab 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h @@ -94,6 +94,8 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component #endif int8_t enqueue(std::unique_ptr cmd); + /// Queue a sensor reset + void reset_system() { this->enqueue(make_unique()); } protected: #ifdef USE_BINARY_SENSOR From 447348d617a7ebba7a8ead5683b26ccb1a4cd8de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:32:53 +0100 Subject: [PATCH 73/94] [lightwaverf] Use register_apply_action for lightwaverf.send_raw (#19698) --- esphome/components/lightwaverf/__init__.py | 68 ++++++++------------ esphome/components/lightwaverf/lightwaverf.h | 23 ------- 2 files changed, 26 insertions(+), 65 deletions(-) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 0f42083cb52..a1138c7de82 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,8 +11,6 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType @@ -24,7 +22,6 @@ lightwaverf_ns = cg.esphome_ns.namespace("lightwaverf") LIGHTWAVERFComponent = lightwaverf_ns.class_( "LightWaveRF", cg.Component, cg.PollingComponent ) -LightwaveRawAction = lightwaverf_ns.class_("SendRawAction", automation.Action) CONFIG_SCHEMA = cv.Schema( @@ -36,49 +33,36 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -LIGHTWAVE_SEND_SCHEMA = cv.Any( - cv.int_range(min=1), - cv.Schema( - { - cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent), - cv.Required(CONF_NAME): cv.string, - cv.Required(CONF_CODE): cv.All( - [cv.Any(cv.hex_uint8_t)], - cv.Length(min=10), - ), - cv.Optional(CONF_REPEAT, default=10): cv.int_, - cv.Optional(CONF_INVERTED, default=False): cv.boolean, - cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_, - } +LIGHTWAVE_SEND_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent), + cv.Required(CONF_NAME): cv.string, + cv.Required(CONF_CODE): cv.All( + [cv.Any(cv.hex_uint8_t)], + cv.Length(min=10), + ), + cv.Optional(CONF_REPEAT, default=10): cv.int_, + cv.Optional(CONF_INVERTED, default=False): cv.boolean, + cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_, + } +) + + +automation.register_apply_action( + "lightwaverf.send_raw", + LIGHTWAVE_SEND_SCHEMA, + automation.ApplyCall( + "send_rx({}, {}, {}, {})", + ( + (CONF_CODE, cg.std_vector.template(cg.uint8)), + (CONF_REPEAT, cg.uint8), + (CONF_INVERTED, cg.bool_), + (CONF_PULSE_LENGTH, cg.int_), + ), ), ) -@automation.register_action( - "lightwaverf.send_raw", - LightwaveRawAction, - LIGHTWAVE_SEND_SCHEMA, - synchronous=True, -) -async def send_raw_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_REPEAT], args, cg.int_) - cg.add(var.set_repeat(template_)) - template_ = await cg.templatable(config[CONF_INVERTED], args, cg.int_) - cg.add(var.set_inverted(template_)) - template_ = await cg.templatable(config[CONF_PULSE_LENGTH], args, cg.int_) - cg.add(var.set_pulse_length(template_)) - cg.add(var.set_code(config[CONF_CODE])) - return var - - async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 36dac3c86fc..d14f57e31e9 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include @@ -37,28 +36,6 @@ class LightWaveRF final : public PollingComponent { LwTx lwtx_; }; -template class SendRawAction final : public Action { - public: - SendRawAction(LightWaveRF *parent) : parent_(parent){}; - TEMPLATABLE_VALUE(int, repeat); - TEMPLATABLE_VALUE(int, inverted); - TEMPLATABLE_VALUE(int, pulse_length); - TEMPLATABLE_VALUE(std::vector, code); - void set_code(std::initializer_list data) { this->code_ = std::vector(data); } - - void play(const Ts &...x) { - int repeats = this->repeat_.value(x...); - int inverted = this->inverted_.value(x...); - int pulse_length = this->pulse_length_.value(x...); - std::vector msg = this->code_.value(x...); - - this->parent_->send_rx(msg, repeats, inverted, pulse_length); - } - - protected: - LightWaveRF *parent_; -}; - #endif } // namespace esphome::lightwaverf #endif From 01a2aaef0811e1cd4b270ac3c44729fd9911ad89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:32:57 +0100 Subject: [PATCH 74/94] [motion] Use register_apply_action for motion.clear_calibration (#19700) --- esphome/components/motion/__init__.py | 12 +----- .../components/motion/motion_component.cpp | 5 ++- esphome/components/motion/motion_component.h | 21 ++-------- tests/component_tests/motion/test_motion.py | 39 ------------------- tests/components/qmi8658/common-actions.yaml | 7 ++++ .../qmi8658/test-actions.esp8266-ard.yaml | 4 ++ 6 files changed, 20 insertions(+), 68 deletions(-) create mode 100644 tests/components/qmi8658/common-actions.yaml create mode 100644 tests/components/qmi8658/test-actions.esp8266-ard.yaml diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py index aea052fa2f1..63a104f1a7e 100644 --- a/esphome/components/motion/__init__.py +++ b/esphome/components/motion/__init__.py @@ -25,7 +25,6 @@ CONF_TRANSFORM_MATRIX = "transform_matrix" CalibrateLevelAction = motion_ns.class_("CalibrateLevelAction", automation.Action) CalibrateHeadingAction = motion_ns.class_("CalibrateHeadingAction", automation.Action) -ClearCalibrationAction = motion_ns.class_("ClearCalibrationAction", automation.Action) KEY_ACCELEROMETER = "accelerometer" KEY_GYROSCOPE = "gyroscope" @@ -207,15 +206,8 @@ CLEAR_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "motion.clear_calibration", - ClearCalibrationAction, CLEAR_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SAVE, "clear_calibration", cg.bool_), ) -async def clear_calibration_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - if config.get(CONF_SAVE): - cg.add(var.set_save(True)) - return var diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp index 8715c8385c9..226dc94e638 100644 --- a/esphome/components/motion/motion_component.cpp +++ b/esphome/components/motion/motion_component.cpp @@ -56,10 +56,13 @@ bool MotionComponent::save_calibration() { ESP_LOGW(TAG, "Calibration save failed"); return false; } -void MotionComponent::clear_calibration() { +void MotionComponent::clear_calibration(bool save) { memcpy(this->matrix_, this->base_matrix_, sizeof(this->matrix_)); ESP_LOGI(TAG, "Calibration reset to build-time matrix"); log_matrix(this->matrix_); + if (save) { + this->save_calibration(); + } } void MotionComponent::update() { if (this->is_failed()) diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index b0a074a17c2..d117ca00320 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -52,8 +52,9 @@ class MotionComponent : public PollingComponent { bool calibrate_heading(); /// Save the current matrix to NVS. bool save_calibration(); - /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration. - void clear_calibration(); + /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration, + /// and persist that base when `save` is set. + void clear_calibration(bool save = false); template void add_listener(F &&cb) { this->motion_data_callback_.add(std::forward(cb)); } @@ -135,20 +136,4 @@ template class CalibrateHeadingAction final : public Action class ClearCalibrationAction final : public Action { - public: - explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} - void set_save(bool save) { this->save_ = save; } - - protected: - void play(const Ts &...) override { - this->parent_->clear_calibration(); - if (this->save_) - this->parent_->save_calibration(); - } - - MotionComponent *parent_; - bool save_{false}; -}; - } // namespace esphome::motion diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py index f2c0f263442..c502e06dab4 100644 --- a/tests/component_tests/motion/test_motion.py +++ b/tests/component_tests/motion/test_motion.py @@ -18,7 +18,6 @@ from esphome.components.motion import ( _build_calibrate_action, _transform_matrix, _validate_matrix_options, - clear_calibration_to_code, ) from esphome.components.motion.sensor import ( _ACCELERATIONS, @@ -785,44 +784,6 @@ class TestClearActionSchema: assert result[CONF_SAVE] is False -@pytest.fixture -def mock_clear_codegen(): - """Mock cg functions used by clear_calibration_to_code.""" - mock_var = MagicMock() - mock_parent = MagicMock() - with ( - patch( - "esphome.components.motion.cg.get_variable", - new_callable=AsyncMock, - return_value=mock_parent, - ), - patch( - "esphome.components.motion.cg.new_Pvariable", - return_value=mock_var, - ) as mock_new_pvar, - patch("esphome.components.motion.cg.add") as mock_add, - ): - yield {"new_Pvariable": mock_new_pvar, "add": mock_add, "var": mock_var} - - -@pytest.mark.asyncio -async def test_clear_action_without_save(mock_clear_codegen): - """With save=False, set_save should not be emitted.""" - config = {CONF_ID: MagicMock(), CONF_SAVE: False} - result = await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) - assert result is mock_clear_codegen["var"] - mock_clear_codegen["add"].assert_not_called() - - -@pytest.mark.asyncio -async def test_clear_action_with_save(mock_clear_codegen): - """With save=True, set_save(True) should be emitted exactly once.""" - config = {CONF_ID: MagicMock(), CONF_SAVE: True} - await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) - mock_clear_codegen["var"].set_save.assert_called_once_with(True) - mock_clear_codegen["add"].assert_called_once() - - # --- Calibration persistence invalidation --- # # The C++ side stores a hash of the build-time base matrix alongside the saved diff --git a/tests/components/qmi8658/common-actions.yaml b/tests/components/qmi8658/common-actions.yaml new file mode 100644 index 00000000000..b8ab845ef11 --- /dev/null +++ b/tests/components/qmi8658/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Clear motion calibration + on_press: + - motion.clear_calibration: + - motion.clear_calibration: + save: true diff --git a/tests/components/qmi8658/test-actions.esp8266-ard.yaml b/tests/components/qmi8658/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..28421ac7e69 --- /dev/null +++ b/tests/components/qmi8658/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + qmi8658: !include common.yaml + actions: !include common-actions.yaml From c8d3f437127b9e54682c50957a1821743b4a36a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:17 +0100 Subject: [PATCH 75/94] [dfplayer] Use register_apply_condition for dfplayer.is_playing (#19701) --- esphome/components/dfplayer/__init__.py | 7 ++----- esphome/components/dfplayer/dfplayer.h | 5 ----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 0516b27ca21..296afcd3183 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -12,9 +12,6 @@ CODEOWNERS = ["@glmnet"] dfplayer_ns = cg.esphome_ns.namespace("dfplayer") DFPlayer = dfplayer_ns.class_("DFPlayer", cg.Component) -DFPlayerIsPlayingCondition = dfplayer_ns.class_( - "DFPlayerIsPlayingCondition", automation.Condition -) MULTI_CONF = True CONF_FOLDER = "folder" @@ -199,12 +196,12 @@ automation.register_apply_action( ) -automation.register_parented_condition( +automation.register_apply_condition( "dfplayer.is_playing", - DFPlayerIsPlayingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(DFPlayer), } ), + "is_playing()", ) diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index ad91dcfc94e..957583e4ffb 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -77,9 +77,4 @@ class DFPlayer final : public uart::UARTDevice, public Component { CallbackManager on_finished_playback_callback_; }; -template class DFPlayerIsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_playing(); } -}; - } // namespace esphome::dfplayer From d71a6e662b53ca758ddf8e7ffb7bb8888cbf2ce6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:21 +0100 Subject: [PATCH 76/94] [snapshot] Use register_apply_action for snapshot.take (#19696) --- esphome/components/snapshot/__init__.py | 23 +++++------------------ esphome/components/snapshot/snapshot.cpp | 7 +++++-- esphome/components/snapshot/snapshot.h | 24 ++++-------------------- 3 files changed, 14 insertions(+), 40 deletions(-) diff --git a/esphome/components/snapshot/__init__.py b/esphome/components/snapshot/__init__.py index bf561a0e0da..068af830aad 100644 --- a/esphome/components/snapshot/__init__.py +++ b/esphome/components/snapshot/__init__.py @@ -12,9 +12,9 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -24,31 +24,18 @@ CONF_FILENAME = "filename" snapshot_ns = cg.esphome_ns.namespace("snapshot") Snapshot = snapshot_ns.class_("Snapshot") -SnapshotAction = snapshot_ns.class_("SnapshotAction", automation.Action) -@automation.register_action( +automation.register_apply_action( "snapshot.take", - SnapshotAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(Snapshot), - cv.Optional(CONF_FILENAME): cv.templatable(cv.string), + cv.Optional(CONF_FILENAME, default=""): cv.templatable(cv.string), } ), - synchronous=True, + automation.ApplyField(CONF_FILENAME, "take_snapshot_or_log", cg.std_string), ) -async def snapshot_take_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - if (filename := config.get(CONF_FILENAME)) is not None: - cg.add(var.set_filename(await cg.templatable(filename, args, cg.std_string))) - return var @dataclass diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 5aa0bcd0e13..1bf63f80b00 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -192,8 +192,11 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro } // namespace -// helper function since ESP_LOGW is disallowed in a header file -void Snapshot::log_action_failed() { ESP_LOGW(TAG, "snapshot.take did not write a file"); } +void Snapshot::take_snapshot_or_log(const char *filename) { + if (!this->take_snapshot(filename[0] != '\0' ? filename : nullptr)) { + ESP_LOGW(TAG, "snapshot.take did not write a file"); + } +} bool Snapshot::take_snapshot(const char *filename) { const int width = this->snapshot_width(); diff --git a/esphome/components/snapshot/snapshot.h b/esphome/components/snapshot/snapshot.h index bb670e639f0..359d16536d8 100644 --- a/esphome/components/snapshot/snapshot.h +++ b/esphome/components/snapshot/snapshot.h @@ -1,7 +1,6 @@ #pragma once #ifdef USE_HOST -#include "esphome/core/automation.h" #include #include @@ -34,8 +33,10 @@ class Snapshot { /// already there is never written over. Returns true if a file was written. bool take_snapshot(const char *filename); - /// Log that an action-triggered snapshot did not write a file. - static void log_action_failed(); + /// Take a snapshot for an automation: an empty name means a generated one, and a snapshot + /// that wrote nothing is logged. + void take_snapshot_or_log(const char *filename); + void take_snapshot_or_log(const std::string &filename) { this->take_snapshot_or_log(filename.c_str()); } protected: /// Width of the picture in pixels. @@ -50,23 +51,6 @@ class Snapshot { const char *snapshot_prefix_{"snapshot"}; }; -template class SnapshotAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, filename) - - protected: - void play(const Ts &...x) override { - bool ok; - if (this->filename_.has_value()) { - ok = this->parent_->take_snapshot(this->filename_.value(x...).c_str()); - } else { - ok = this->parent_->take_snapshot(nullptr); - } - if (!ok) - this->parent_->log_action_failed(); - } -}; - } // namespace esphome::snapshot #endif From 0b5a1748f7d143fdce636aa5794db04028d457b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:25 +0100 Subject: [PATCH 77/94] [tinyusb] Use register_apply_condition for tinyusb.is_mounted (#19702) --- esphome/components/tinyusb/__init__.py | 5 ++--- esphome/components/tinyusb/tinyusb_component.h | 10 ---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 422a9942380..1b819088cad 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -36,7 +36,6 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) -IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( @@ -134,8 +133,8 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) -automation.register_simple_condition( +automation.register_apply_condition( "tinyusb.is_mounted", - IsMountedCondition, cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), + "is_mounted()", ) diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index f7f574ec6d5..030356bc03e 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,7 +1,6 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -87,15 +86,6 @@ class TinyUSB final : public Component { }; }; -template class IsMountedCondition final : public Condition { - public: - explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_mounted(); } - - protected: - TinyUSB *parent_; -}; - } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 From 0569a1262ff52316740d66e22cad5307cebf8901 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:36 +0100 Subject: [PATCH 78/94] [voice_assistant] Use register_apply_condition for is_running and connected (#19703) --- esphome/components/voice_assistant/__init__.py | 15 ++++----------- .../components/voice_assistant/voice_assistant.h | 12 ++---------- .../voice_assistant/common-actions.yaml | 14 ++++++++++++++ .../voice_assistant/test-actions.esp32-idf.yaml | 9 +++++++++ 4 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 tests/components/voice_assistant/common-actions.yaml create mode 100644 tests/components/voice_assistant/test-actions.esp32-idf.yaml diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 2f193ebb4e5..14c036de8ee 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -59,12 +59,6 @@ MAX_MICROPHONE_SOURCES = 2 voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant") VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component) -IsRunningCondition = voice_assistant_ns.class_( - "IsRunningCondition", automation.Condition, cg.Parented.template(VoiceAssistant) -) -ConnectedCondition = voice_assistant_ns.class_( - "ConnectedCondition", automation.Condition, cg.Parented.template(VoiceAssistant) -) Timer = voice_assistant_ns.struct("Timer") @@ -421,15 +415,14 @@ automation.register_apply_action( ) -automation.register_parented_condition( +automation.register_apply_condition( "voice_assistant.is_running", - IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA, + "is_running_or_continuous()", ) - -automation.register_parented_condition( +automation.register_apply_condition( "voice_assistant.connected", - ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA, + "get_api_connection() != nullptr", ) diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 88ddb2066a0..e99ee07099f 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -182,6 +182,8 @@ class VoiceAssistant final : public Component { bool is_running() const { return this->state_ != State::IDLE; } void set_continuous(bool continuous) { this->continuous_ = continuous; } bool is_continuous() const { return this->continuous_; } + /// The voice_assistant.is_running condition: a pipeline is running or continuous mode keeps one coming. + bool is_running_or_continuous() const { return this->is_running() || this->is_continuous(); } void set_use_wake_word(bool use_wake_word) { this->use_wake_word_ = use_wake_word; } @@ -347,16 +349,6 @@ class VoiceAssistant final : public Component { #endif }; -template class IsRunningCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } -}; - -template class ConnectedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->get_api_connection() != nullptr; } -}; - extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::voice_assistant diff --git a/tests/components/voice_assistant/common-actions.yaml b/tests/components/voice_assistant/common-actions.yaml new file mode 100644 index 00000000000..e529b62ca7d --- /dev/null +++ b/tests/components/voice_assistant/common-actions.yaml @@ -0,0 +1,14 @@ +button: + - platform: template + name: Voice assistant state + on_press: + - if: + condition: + voice_assistant.is_running: + then: + - logger.log: Running + - if: + condition: + voice_assistant.connected: + then: + - logger.log: Connected diff --git a/tests/components/voice_assistant/test-actions.esp32-idf.yaml b/tests/components/voice_assistant/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..7cd9a5c3553 --- /dev/null +++ b/tests/components/voice_assistant/test-actions.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_din_pin: GPIO34 + i2s_din_pin2: GPIO35 + i2s_dout_pin: GPIO32 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + voice_assistant: !include common-idf.yaml + actions: !include common-actions.yaml From 1a158e5ba01b0d3a585673e7c000dd4ef7ccffe5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:47 +0100 Subject: [PATCH 79/94] [mitsubishi_cn105] Use register_apply_action for set_remote_temperature and clear_remote_temperature (#19706) --- .../components/mitsubishi_cn105/__init__.py | 37 +------- .../components/mitsubishi_cn105/automation.h | 23 ----- .../components/mitsubishi_cn105/climate.py | 93 +++++++------------ .../mitsubishi_cn105_climate.h | 17 ---- tests/components/mitsubishi_cn105/common.h | 1 - 5 files changed, 36 insertions(+), 135 deletions(-) delete mode 100644 esphome/components/mitsubishi_cn105/automation.h diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 8f1c6ef648b..442869eec53 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -10,9 +10,8 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_USE_FAHRENHEIT, ) -from esphome.core import ID from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@crnjan"] DEPENDENCIES = ["uart"] @@ -46,18 +45,6 @@ VERTICAL_VANE_DIRECTIONS = { "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, } -SetRemoteTemperatureAction = mitsubishi_ns.class_( - "SetRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Component), -) - -ClearRemoteTemperatureAction = mitsubishi_ns.class_( - "ClearRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Component), -) - CONFIG_SCHEMA = ( cv.Schema( @@ -143,30 +130,16 @@ CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( f"{DOMAIN}.set_remote_temperature", - SetRemoteTemperatureAction, REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_TEMPERATURE, "set_remote_temperature", cg.float_), ) -async def remote_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) - cg.add(var.set_temperature(temperature)) - return var - -automation.register_parented_action( +automation.register_apply_action( f"{DOMAIN}.clear_remote_temperature", - ClearRemoteTemperatureAction, CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("clear_remote_temperature()"), ) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h deleted file mode 100644 index f0bd771fd5c..00000000000 --- a/esphome/components/mitsubishi_cn105/automation.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "mitsubishi_cn105_component.h" - -#include "esphome/core/automation.h" - -namespace esphome::mitsubishi_cn105 { - -template -class SetRemoteTemperatureAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } -}; - -template -class ClearRemoteTemperatureAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } -}; - -} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 05a29b36654..756c8142c20 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import logging from esphome import automation @@ -12,10 +13,9 @@ from esphome.const import ( CONF_UART_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj +from esphome.core import CORE from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType from . import ( CONF_MITSUBISHI_CN105_ID, @@ -44,20 +44,6 @@ MitsubishiCN105Climate = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) -# Legacy climate action compatibility. Remove in 2027.2.0. -LegacySetRemoteTemperatureAction = mitsubishi_ns.class_( - "LegacySetRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Climate), -) - -# Legacy climate action compatibility. Remove in 2027.2.0. -LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_( - "LegacyClearRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Climate), -) - # Legacy climate-owned hub compatibility. Remove in 2027.2.0. def _has_top_level_hub_config() -> bool: @@ -204,53 +190,36 @@ LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( ) -# Legacy climate action compatibility. Remove in 2027.2.0. -@automation.register_action( +def _warn_legacy_action(name: str) -> Callable[[ConfigType], ConfigType]: + # Legacy climate action compatibility. Remove in 2027.2.0. + def validator(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "The 'climate.%s.%s' action is deprecated. Use '%s.%s' instead. " + "It will be removed in ESPHome 2027.2.0.", + DOMAIN, + name, + DOMAIN, + name, + ) + return config + + return validator + + +automation.register_apply_action( f"climate.{DOMAIN}.set_remote_temperature", - LegacySetRemoteTemperatureAction, - LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + cv.All( + LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, + _warn_legacy_action("set_remote_temperature"), + ), + automation.ApplyField(CONF_TEMPERATURE, "set_remote_temperature", cg.float_), ) -async def legacy_remote_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - _LOGGER.warning( - "The 'climate.%s.set_remote_temperature' action is deprecated. Use " - "'%s.set_remote_temperature' instead. It will be removed in ESPHome " - "2027.2.0.", - DOMAIN, - DOMAIN, - ) - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) - cg.add(var.set_temperature(temperature)) - return var - -# Legacy climate action compatibility. Remove in 2027.2.0. -@automation.register_action( +automation.register_apply_action( f"climate.{DOMAIN}.clear_remote_temperature", - LegacyClearRemoteTemperatureAction, - LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + cv.All( + LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, + _warn_legacy_action("clear_remote_temperature"), + ), + automation.ApplyCall("clear_remote_temperature()"), ) -async def legacy_clear_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - _LOGGER.warning( - "The 'climate.%s.clear_remote_temperature' action is deprecated. Use " - "'%s.clear_remote_temperature' instead. It will be removed in ESPHome " - "2027.2.0.", - DOMAIN, - DOMAIN, - ) - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index cea76278abd..fc665b079af 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -3,7 +3,6 @@ #include "mitsubishi_cn105_component.h" #include "mitsubishi_cn105.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" #include "mitsubishi_cn105_swing_mode_manager.h" @@ -31,20 +30,4 @@ class MitsubishiCN105Climate final : public climate::Climate, SwingModeManager swing_mode_manager_; }; -// Legacy climate action compatibility. Remove in 2027.2.0. -template -class LegacySetRemoteTemperatureAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } -}; - -// Legacy climate action compatibility. Remove in 2027.2.0. -template -class LegacyClearRemoteTemperatureAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } -}; - } // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 6029327e9ab..b643500234d 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,7 +8,6 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" -#include "esphome/components/mitsubishi_cn105/automation.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" From 2fc48a3d9e28c80d872b65fab77fff1074a28de8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:50 +0100 Subject: [PATCH 80/94] [sun] Use register_apply_condition for is_above_horizon and is_below_horizon (#19704) --- esphome/components/sun/__init__.py | 63 +++++-------------- esphome/components/sun/sun.h | 19 ------ tests/components/sun/common-actions.yaml | 21 +++++++ .../sun/test-actions.esp8266-ard.yaml | 3 + 4 files changed, 39 insertions(+), 67 deletions(-) create mode 100644 tests/components/sun/common-actions.yaml create mode 100644 tests/components/sun/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 33a5c677bdf..73ddefe9729 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -13,8 +13,6 @@ from esphome.const import ( CONF_TIME_ID, CONF_TRIGGER_ID, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] @@ -24,7 +22,6 @@ Sun = sun_ns.class_("Sun") SunTrigger = sun_ns.class_( "SunTrigger", cg.PollingComponent, automation.Trigger.template() ) -SunCondition = sun_ns.class_("SunCondition", automation.Condition) CONF_SUN_ID = "sun_id" CONF_ELEVATION = "elevation" @@ -142,53 +139,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) -@automation.register_condition( +SUN_HORIZON_CONDITION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(Sun), + cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( + elevation + ), + } +) + +automation.register_apply_condition( "sun.is_above_horizon", - SunCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(Sun), - cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( - elevation - ), - } - ), + SUN_HORIZON_CONDITION_SCHEMA, + automation.ApplyCall("elevation() > {}", ((CONF_ELEVATION, cg.double),)), ) -async def sun_above_horizon_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) - cg.add(var.set_elevation(templ)) - cg.add(var.set_above(True)) - return var - -@automation.register_condition( +automation.register_apply_condition( "sun.is_below_horizon", - SunCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(Sun), - cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( - elevation - ), - } - ), + SUN_HORIZON_CONDITION_SCHEMA, + automation.ApplyCall("elevation() < {}", ((CONF_ELEVATION, cg.double),)), ) -async def sun_below_horizon_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) - cg.add(var.set_elevation(templ)) - cg.add(var.set_above(False)) - return var diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index ea9e05042d9..af2af15dd29 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -109,23 +109,4 @@ class SunTrigger final : public Trigger<>, public PollingComponent, public Paren double elevation_; }; -template class SunCondition final : public Condition, public Parented { - public: - TEMPLATABLE_VALUE(double, elevation); - void set_above(bool above) { above_ = above; } - - bool check(const Ts &...x) override { - double elevation = this->elevation_.value(x...); - double current = this->parent_->elevation(); - if (this->above_) { - return current > elevation; - } else { - return current < elevation; - } - } - - protected: - bool above_; -}; - } // namespace esphome::sun diff --git a/tests/components/sun/common-actions.yaml b/tests/components/sun/common-actions.yaml new file mode 100644 index 00000000000..460be698791 --- /dev/null +++ b/tests/components/sun/common-actions.yaml @@ -0,0 +1,21 @@ +button: + - platform: template + name: Sun position + on_press: + - if: + condition: + sun.is_above_horizon: + then: + - logger.log: Day + - if: + condition: + sun.is_below_horizon: + elevation: -6° + then: + - logger.log: Night + - if: + condition: + sun.is_above_horizon: + elevation: !lambda return 10.0; + then: + - logger.log: High sun diff --git a/tests/components/sun/test-actions.esp8266-ard.yaml b/tests/components/sun/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..82c874ac33c --- /dev/null +++ b/tests/components/sun/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + sun: !include common.yaml + actions: !include common-actions.yaml From 93fabe8b84f2c959426ae0067364d1c63a465d73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:57 +0100 Subject: [PATCH 81/94] [alarm_control_panel] Use register_apply_condition for is_armed and ready (#19707) --- .../alarm_control_panel/__init__.py | 20 ++++--------------- .../alarm_control_panel/alarm_control_panel.h | 6 ++++++ .../alarm_control_panel/automation.h | 12 ----------- .../alarm_control_panel/common-actions.yaml | 10 ++++++++++ 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 37b27b5391e..78206ff3f8a 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -42,10 +42,6 @@ StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -AlarmControlPanelCondition = alarm_control_panel_ns.class_( - "AlarmControlPanelCondition", automation.Condition -) - _ALARM_CONTROL_PANEL_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -216,18 +212,10 @@ for _name, _call in ( ) -automation.register_simple_condition( - "alarm_control_panel.ready", - AlarmControlPanelCondition, - ALARM_CONTROL_PANEL_CONDITION_SCHEMA, -) - - -automation.register_simple_condition( - "alarm_control_panel.is_armed", - AlarmControlPanelCondition, - ALARM_CONTROL_PANEL_CONDITION_SCHEMA, -) +for _name in ("alarm_control_panel.ready", "alarm_control_panel.is_armed"): + automation.register_apply_condition( + _name, ALARM_CONTROL_PANEL_CONDITION_SCHEMA, "is_armed_pending_or_triggered()" + ) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index aced89b7ffc..1215044ca10 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -130,6 +130,12 @@ class AlarmControlPanel : public EntityBase { // is the state one of the armed states bool is_state_armed(AlarmControlPanelState state); + /// Armed, pending (entry delay) or triggered; not ARMING (exit delay). Backs the is_armed and ready + /// conditions. + bool is_armed_pending_or_triggered() { + auto state = this->get_state(); + return this->is_state_armed(state) || state == ACP_STATE_PENDING || state == ACP_STATE_TRIGGERED; + } protected: friend AlarmControlPanelCall; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 7a683086a46..86bdac37ec8 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,16 +27,4 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class AlarmControlPanelCondition final : public Condition { - public: - AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} - bool check(const Ts &...x) override { - return this->parent_->is_state_armed(this->parent_->get_state()) || - this->parent_->get_state() == ACP_STATE_PENDING || this->parent_->get_state() == ACP_STATE_TRIGGERED; - } - - protected: - AlarmControlPanel *parent_; -}; - } // namespace esphome::alarm_control_panel diff --git a/tests/components/alarm_control_panel/common-actions.yaml b/tests/components/alarm_control_panel/common-actions.yaml index 1ba7342c891..d757fa631a3 100644 --- a/tests/components/alarm_control_panel/common-actions.yaml +++ b/tests/components/alarm_control_panel/common-actions.yaml @@ -4,3 +4,13 @@ button: on_press: - alarm_control_panel.pending: alarmcontrolpanel1 - alarm_control_panel.triggered: alarmcontrolpanel1 + - if: + condition: + alarm_control_panel.is_armed: alarmcontrolpanel1 + then: + - logger.log: Armed + - if: + condition: + alarm_control_panel.ready: alarmcontrolpanel1 + then: + - logger.log: Ready From a1d8ea6072556dc8ed27eb9a5c0b11d260679cf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:06 +0100 Subject: [PATCH 82/94] [aic3204] Use register_apply_action for aic3204.set_auto_mute_mode (#19684) --- esphome/components/aic3204/audio_dac.py | 21 ++----------------- esphome/components/aic3204/automation.h | 21 ------------------- tests/components/aic3204/common-actions.yaml | 8 +++++++ .../aic3204/test-actions.esp8266-ard.yaml | 4 ++++ 4 files changed, 14 insertions(+), 40 deletions(-) delete mode 100644 esphome/components/aic3204/automation.h create mode 100644 tests/components/aic3204/common-actions.yaml create mode 100644 tests/components/aic3204/test-actions.esp8266-ard.yaml diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index 50e2f81f1b6..b4942fe3908 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -4,8 +4,6 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -14,7 +12,6 @@ DEPENDENCIES = ["i2c"] aic3204_ns = cg.esphome_ns.namespace("aic3204") AIC3204 = aic3204_ns.class_("AIC3204", AudioDac, cg.Component, i2c.I2CDevice) -SetAutoMuteAction = aic3204_ns.class_("SetAutoMuteAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -36,25 +33,11 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "aic3204.set_auto_mute_mode", - SetAutoMuteAction, SET_AUTO_MUTE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MODE, "set_auto_mute_mode", cg.uint8), ) -async def aic3204_set_volume_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config.get(CONF_MODE), args, cg.uint8) - cg.add(var.set_auto_mute_mode(template_)) - - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h deleted file mode 100644 index f0f88566145..00000000000 --- a/esphome/components/aic3204/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "aic3204.h" - -namespace esphome::aic3204 { - -template class SetAutoMuteAction final : public Action { - public: - explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} - - TEMPLATABLE_VALUE(uint8_t, auto_mute_mode) - - void play(const Ts &...x) override { this->aic3204_->set_auto_mute_mode(this->auto_mute_mode_.value(x...)); } - - protected: - AIC3204 *aic3204_; -}; - -} // namespace esphome::aic3204 diff --git a/tests/components/aic3204/common-actions.yaml b/tests/components/aic3204/common-actions.yaml new file mode 100644 index 00000000000..7981fcc1b07 --- /dev/null +++ b/tests/components/aic3204/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: AIC3204 Auto Mute + on_press: + - aic3204.set_auto_mute_mode: 3 + - aic3204.set_auto_mute_mode: + id: aic3204_dac + mode: !lambda return 7; diff --git a/tests/components/aic3204/test-actions.esp8266-ard.yaml b/tests/components/aic3204/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c5431f65a3e --- /dev/null +++ b/tests/components/aic3204/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + aic3204: !include common.yaml + actions: !include common-actions.yaml From 0b2e5bfbbe3e5711c622527b41d285953355029d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:16 +0100 Subject: [PATCH 83/94] [pid] Use register_apply_action for climate.pid.autotune (#19708) --- esphome/components/pid/climate.py | 28 +++++++++----------------- esphome/components/pid/pid_climate.cpp | 8 ++++++++ esphome/components/pid/pid_climate.h | 25 ++--------------------- 3 files changed, 20 insertions(+), 41 deletions(-) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 11a6fd5e6bd..1527fa6f354 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,13 +3,11 @@ import esphome.codegen as cg from esphome.components import climate, output, sensor import esphome.config_validation as cv from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR -from esphome.core import ID, Lambda -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import Lambda from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) -PIDAutotuneAction = pid_ns.class_("PIDAutotuneAction", automation.Action) CONF_DEFAULT_TARGET_TEMPERATURE = "default_target_temperature" @@ -158,9 +156,8 @@ automation.register_apply_action( ) -@automation.register_action( +automation.register_apply_action( "climate.pid.autotune", - PIDAutotuneAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), @@ -173,20 +170,15 @@ automation.register_apply_action( ): cv.possibly_negative_percentage, } ), - synchronous=True, + automation.ApplyCall( + "start_autotune({}, {}, {})", + ( + (CONF_NOISEBAND, cg.float_), + (CONF_POSITIVE_OUTPUT, cg.float_), + (CONF_NEGATIVE_OUTPUT, cg.float_), + ), + ), ) -async def esp8266_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - cg.add(var.set_noiseband(config[CONF_NOISEBAND])) - cg.add(var.set_positive_output(config[CONF_POSITIVE_OUTPUT])) - cg.add(var.set_negative_output(config[CONF_NEGATIVE_OUTPUT])) - return var automation.register_apply_action( diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index b386716780e..1f0d45b0847 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -169,6 +169,14 @@ void PIDClimate::update_pid_() { if (this->do_publish_) this->publish_state(); } +void PIDClimate::start_autotune(float noiseband, float positive_output, float negative_output) { + auto tuner = make_unique(); + tuner->set_noiseband(noiseband); + tuner->set_output_positive(positive_output); + tuner->set_output_negative(negative_output); + this->start_autotune(std::move(tuner)); +} + void PIDClimate::start_autotune(std::unique_ptr &&autotune) { this->autotuner_ = std::move(autotune); float min_value = this->supports_cool_() ? -1.0f : 0.0f; diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index e8701c99a49..6fea2bedf30 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -79,6 +79,8 @@ class PIDClimate final : public climate::Climate, public Component { default_target_temperature_ = default_target_temperature; } void start_autotune(std::unique_ptr &&autotune); + /// Build a tuner from the climate.pid.autotune settings and start it. + void start_autotune(float noiseband, float positive_output, float negative_output); void reset_integral_term(); protected: @@ -109,27 +111,4 @@ class PIDClimate final : public climate::Climate, public Component { bool do_publish_ = false; }; -template class PIDAutotuneAction final : public Action { - public: - PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {} - - void set_noiseband(float noiseband) { noiseband_ = noiseband; } - void set_positive_output(float positive_output) { positive_output_ = positive_output; } - void set_negative_output(float negative_output) { negative_output_ = negative_output; } - - void play(const Ts &...x) { - auto tuner = make_unique(); - tuner->set_noiseband(this->noiseband_); - tuner->set_output_negative(this->negative_output_); - tuner->set_output_positive(this->positive_output_); - this->parent_->start_autotune(std::move(tuner)); - } - - protected: - float noiseband_; - float positive_output_; - float negative_output_; - PIDClimate *parent_; -}; - } // namespace esphome::pid From b8c32854edf2e36849b3ba5cd0366048149c1802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:22 +0100 Subject: [PATCH 84/94] [bl0906] Use register_apply_action for bl0906.reset_energy (#19688) --- esphome/components/bl0906/bl0906.h | 13 +++---------- esphome/components/bl0906/sensor.py | 6 ++---- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 54de9f9b0cc..93ccf09521b 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -2,7 +2,6 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/datatypes.h" @@ -47,8 +46,6 @@ struct sbe24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-ali int8_t h{0}; } __attribute__((packed)); -template class ResetEnergyAction; - class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); @@ -85,9 +82,10 @@ class BL0906 final : public PollingComponent, public uart::UARTDevice { void setup() override; void dump_config() override; - protected: - template friend class ResetEnergyAction; + /// Queue an energy counter reset for the next poll + void reset_energy() { this->enqueue_action_(&BL0906::reset_energy_); } + protected: void reset_energy_(); void read_data_(uint8_t address, float reference, sensor::Sensor *sensor); @@ -103,9 +101,4 @@ class BL0906 final : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } -}; - } // namespace esphome::bl0906 diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index e24cd316f4e..67ff903fbf1 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -43,7 +43,6 @@ CONF_TOTAL_ENERGY = "total_energy" bl0906_ns = cg.esphome_ns.namespace("bl0906") BL0906 = bl0906_ns.class_("BL0906", cg.PollingComponent, uart.UARTDevice) -ResetEnergyAction = bl0906_ns.class_("ResetEnergyAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -136,15 +135,14 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -automation.register_parented_action( +automation.register_apply_action( "bl0906.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(BL0906), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) From 3f2de92e5d842f1030d32c2f398174e921d3cea4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:38 +0100 Subject: [PATCH 85/94] [openthread] Use register_apply_action for openthread.set_poll_period (#19697) --- esphome/components/openthread/__init__.py | 24 +------- esphome/components/openthread/automation.cpp | 37 ------------ esphome/components/openthread/automation.h | 61 -------------------- esphome/components/openthread/openthread.cpp | 19 ++++++ esphome/components/openthread/openthread.h | 8 +-- 5 files changed, 23 insertions(+), 126 deletions(-) delete mode 100644 esphome/components/openthread/automation.cpp delete mode 100644 esphome/components/openthread/automation.h diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index a71151f3ffb..907200d5ec2 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -35,12 +35,10 @@ from esphome.const import ( ) from esphome.core import ( CORE, - ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -328,12 +326,6 @@ async def to_code(config: ConfigType) -> None: # Actions -OpenThreadComponentPollPeriodAction = openthread_ns.class_( - "OpenThreadComponentPollPeriodAction", - automation.Action, - cg.Parented.template(OpenThreadComponent), -) - POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( CONF_POLL_PERIOD, cv.Schema( @@ -347,20 +339,8 @@ POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( ) -@automation.register_action( +automation.register_apply_action( "openthread.set_poll_period", - OpenThreadComponentPollPeriodAction, POLL_PERIOD_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_POLL_PERIOD, "apply_poll_period", cg.uint32), ) -async def openthread_poll_period_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) - cg.add(var.set_poll_period(template_)) - return var diff --git a/esphome/components/openthread/automation.cpp b/esphome/components/openthread/automation.cpp deleted file mode 100644 index 770bf124c55..00000000000 --- a/esphome/components/openthread/automation.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "esphome/core/defines.h" - -#ifdef USE_OPENTHREAD - -#include "automation.h" -#include "esphome/core/log.h" - -namespace esphome::openthread { - -static const char *const TAG = "openthread.automation"; - -void OpenThreadComponentBaseAction::warn_ftd_no_op_() { - ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); -} - -void OpenThreadComponentBaseAction::lock_and_apply_() { - if (this->parent_->is_ready()) { - if (auto lock = InstanceLock::try_acquire(LOCK_ACQUIRE_TIMEOUT_MS); lock) { - if (auto *instance = lock.get_instance(); instance != nullptr) { - this->apply_locked(instance); - } - } else { - ESP_LOGW(TAG, "Failed to acquire lock in action"); - } - } else { - // Action may trigger early before setup, e.g. due to enabled "restore mode". - // Trying to acquire lock would fail! - // - // But default component values already have been overwritten. - // It is sufficient to let component apply those later during setup. - ESP_LOGD(TAG, "Not (yet) ready to apply"); - } -} - -} // namespace esphome::openthread - -#endif diff --git a/esphome/components/openthread/automation.h b/esphome/components/openthread/automation.h deleted file mode 100644 index 3706499fda0..00000000000 --- a/esphome/components/openthread/automation.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#ifdef USE_OPENTHREAD -#include "openthread.h" - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -namespace esphome::openthread { - -/** Base class allowing to fetch OpenThread lock from parent component - * while applying action - * - * - Nontemplate aspects belong here to avoid template bloat. - * - Subclasses implement virtual action method that is called under lock. - * - Seal leaf subclasses via @a final to support devirtualization. - */ -class OpenThreadComponentBaseAction : public Parented { - public: - // Enforce ctor with parent argument (not without args) - explicit OpenThreadComponentBaseAction(OpenThreadComponent *ot) : Parented(ot) {} - - protected: - /** Handler to implement in subclass for applying action parts that need lock */ - virtual void apply_locked(otInstance *instance) = 0; - - /** Fetch OT lock and then call @a apply_locked */ - void lock_and_apply_(); - - /** Log a warning that this action has no effect on FTD devices */ - void warn_ftd_no_op_(); - - /** Timeout (ms) for acquiring OT lock */ - static constexpr uint32_t LOCK_ACQUIRE_TIMEOUT_MS = 100; -}; - -/** Action to set single poll period parameter */ -template -class OpenThreadComponentPollPeriodAction final : public Action, public OpenThreadComponentBaseAction { - TEMPLATABLE_VALUE(uint32_t, poll_period) - - public: - /* Passthrough ctor */ - using OpenThreadComponentBaseAction::OpenThreadComponentBaseAction; - - protected: - void play(const Ts &...x) override { -#if CONFIG_OPENTHREAD_MTD - this->parent_->set_poll_period(this->poll_period_.value(x...)); - - this->lock_and_apply_(); -#else - this->warn_ftd_no_op_(); -#endif - } - - void apply_locked(otInstance *instance) override { this->parent_->apply_linkmode_(instance); } -}; - -} // namespace esphome::openthread -#endif diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index ae896fcfeea..c788b1f968d 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -282,6 +282,25 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { +#if CONFIG_OPENTHREAD_MTD + this->set_poll_period(poll_period); + if (!this->is_lock_initialized()) { + // The action may run before the stack is up, e.g. from a restore mode; ot_main applies the stored value. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + return; + } + auto lock = InstanceLock::try_acquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + return; + } + this->apply_linkmode_(lock.get_instance()); +#else + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +#endif +} + void OpenThreadComponent::apply_linkmode_(otInstance *instance) { otLinkModeConfig link_mode_config{}; #if CONFIG_OPENTHREAD_FTD diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index b83ffdb6af6..8bccae7f85e 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -25,8 +25,6 @@ enum class TeardownStage : uint8_t { TEARDOWN_STAGE_COMPLETED, }; -template class OpenThreadComponentPollPeriodAction; - class OpenThreadComponent final : public Component { public: OpenThreadComponent(); @@ -53,15 +51,13 @@ class OpenThreadComponent final : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } uint32_t get_poll_period() const { return this->poll_period_; } #endif + /// Set the poll period and re-apply the link mode under the OT lock; a warning only on FTD builds. + void apply_poll_period(uint32_t poll_period); void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); protected: - // Actions re-apply link mode under the OT lock; allow them to call apply_linkmode_() - // without exposing this lock-sensitive, raw-instance method on the public API. - template friend class OpenThreadComponentPollPeriodAction; - /** Apply Link Mode settings (incl poll period). * Callers running outside the OpenThread task must hold InstanceLock. * ot_main() runs on the OpenThread task itself and must not acquire the lock. From a35299af0b73c186ca4ae375281e20156c7c02e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:52 +0100 Subject: [PATCH 86/94] [emontx] Use register_apply_action for emontx.send_command (#19687) --- esphome/components/emontx/__init__.py | 30 +++++++------------ esphome/components/emontx/emontx.cpp | 6 ++-- esphome/components/emontx/emontx.h | 12 ++------ tests/components/emontx/common-actions.yaml | 7 +++++ .../emontx/test-actions.esp8266-ard.yaml | 4 +++ 5 files changed, 27 insertions(+), 32 deletions(-) create mode 100644 tests/components/emontx/common-actions.yaml create mode 100644 tests/components/emontx/test-actions.esp8266-ard.yaml diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 3821f3e10e2..7eae375cd6f 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -11,8 +11,7 @@ from esphome.const import ( CONF_RX_BUFFER_SIZE, CONF_UART_ID, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -23,9 +22,6 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) -# Action to send command to emonTx -EmonTxSendCommandAction = emontx_ns.class_("EmonTxSendCommandAction", automation.Action) - CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" @@ -139,20 +135,16 @@ EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +def _plain_literal(config: ConfigType, value: str) -> str: + return str(cg.safe_exp(value)) + + +automation.register_apply_action( "emontx.send_command", - EmonTxSendCommandAction, EMONTX_SEND_COMMAND_ACTION_SCHEMA, - synchronous=True, + # A constant is a plain literal for the const char * overload; a lambda returns a + # std::string and takes the inline overload. + automation.ApplyField( + CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal + ), ) -async def emontx_send_command_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) - cg.add(var.set_command(template_)) - return var diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 7a1b084fe08..8efc2de7538 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -94,9 +94,9 @@ void EmonTx::dump_config() { * * @param command The command string to send (LF will be appended automatically). */ -void EmonTx::send_command(const std::string &command) { - ESP_LOGD(TAG, "Sending command to emonTx: %s", command.c_str()); - this->write_str(command.c_str()); +void EmonTx::send_command(const char *command) { + ESP_LOGD(TAG, "Sending command to emonTx: %s", command); + this->write_str(command); this->write_byte('\n'); } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 6db197a78c5..c56005e7ad8 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/automation.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" #include "esphome/components/uart/uart.h" @@ -39,7 +38,8 @@ class EmonTx final : public Component, public uart::UARTDevice { template void add_on_data_callback(F &&callback) { this->data_callbacks_.add(std::forward(callback)); } // Send command to emonTx via UART - void send_command(const std::string &command); + void send_command(const char *command); + void send_command(const std::string &command) { this->send_command(command.c_str()); } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -58,12 +58,4 @@ class EmonTx final : public Component, public uart::UARTDevice { std::array buffer_{}; }; -// Action to send command to emonTx -template class EmonTxSendCommandAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, command) - - void play(const Ts &...x) override { this->parent_->send_command(this->command_.value(x...)); } -}; - } // namespace esphome::emontx diff --git a/tests/components/emontx/common-actions.yaml b/tests/components/emontx/common-actions.yaml new file mode 100644 index 00000000000..af8d44896be --- /dev/null +++ b/tests/components/emontx/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Send lambda command + on_press: + - emontx.send_command: + id: test_emontx + command: !lambda return "l"; diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..4be79a40dac --- /dev/null +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + emontx: !include common.yaml + actions: !include common-actions.yaml From a61746045b4af4ec817a269e08085b97be295763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:51:49 +0100 Subject: [PATCH 87/94] [core] Pass the copy flag to JsonString explicitly in the StringRef conversion (#19709) --- esphome/core/string_ref.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index da071287048..4c0ce28a5ac 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -279,7 +279,9 @@ inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(""); return; } - dst.set(JsonString(src.c_str(), src.size())); + // The explicit isStatic is load bearing: without it clang-tidy reads size() as the bool of the two argument + // constructor and asks for empty() instead + dst.set(JsonString(src.c_str(), src.size(), /* isStatic= */ false)); } #endif // USE_JSON From 2dc7a64e526732ca11cfc7102a554c4b4e35e2ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:10:10 +0100 Subject: [PATCH 88/94] [it8951] Use register_apply_action for it8951.update (#19691) --- esphome/components/it8951/display.py | 24 ++++--------------- esphome/components/it8951/it8951.cpp | 6 ++--- esphome/components/it8951/it8951.h | 21 ---------------- .../components/it8951/test.esp32-s3-idf.yaml | 1 + 4 files changed, 9 insertions(+), 43 deletions(-) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 57bf86c4c6c..b5f3019a126 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -36,8 +36,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.cpp_generator import RawExpression from esphome.final_validate import full_config from esphome.types import ConfigType @@ -61,7 +60,6 @@ VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT) it8951_ns = cg.esphome_ns.namespace("it8951") IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice) -IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action) # Hardware waveform modes exposed to YAML. Strings are mapped to the C++ # UpdateMode enum so the runtime can store the mode as a uint16_t rather @@ -80,6 +78,7 @@ UPDATE_MODE_OPTIONS = { "A2": UpdateMode.UPDATE_MODE_A2, "FAST": UpdateMode.UPDATE_MODE_DU, "FULL": UpdateMode.UPDATE_MODE_GC16, + "DEFAULT": UpdateMode.UPDATE_MODE_NONE, } # Maps the YAML mode string directly to the C++ UpdateMode enum value, so the # config option and the it8951.update action share one validator. @@ -419,26 +418,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transform(RawExpression(str(transform_value)))) -@automation.register_action( +automation.register_apply_action( "it8951.update", - IT8951UpdateAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(IT8951Display), - cv.Optional(CONF_MODE): cv.templatable(update_mode), + cv.Optional(CONF_MODE, default="DEFAULT"): cv.templatable(update_mode), } ), - synchronous=True, + automation.ApplyField(CONF_MODE, "update_mode", UpdateMode), ) -async def it8951_update_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - display_var = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, display_var) - if mode := config.get(CONF_MODE): - mode = await cg.templatable(mode, args, UpdateMode) - cg.add(var.set_mode(mode)) - return var diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 237f1c3c8bd..a6511b63c41 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -777,12 +777,12 @@ void IT8951Display::update() { } void IT8951Display::update_mode(UpdateMode mode) { - if (!this->is_ready()) - return; if (mode == UPDATE_MODE_NONE) { - ESP_LOGW(TAG, "Unknown update mode"); + this->update(); return; } + if (!this->is_ready()) + return; this->start_update_(mode); } diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index a5ed03e8c4b..fcdc6d1d196 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -6,7 +6,6 @@ #include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -350,24 +349,4 @@ class IT8951Display : public Display, uint8_t dev_info_attempts_{0}; }; -// --- Automation action --- -template class IT8951UpdateAction : public Action { - public: - explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {} - TEMPLATABLE_VALUE(UpdateMode, mode) - - protected: - void play(const Ts &...x) override { - if (!this->display_->is_ready()) - return; - if (this->mode_.has_value()) { - this->display_->update_mode(this->mode_.value(x...)); - } else { - this->display_->update(); - } - } - - IT8951Display *display_; -}; - } // namespace esphome::it8951 diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml index c362f7f28c4..922e7d0ed3c 100644 --- a/tests/components/it8951/test.esp32-s3-idf.yaml +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -59,6 +59,7 @@ display: - platform: it8951 spi_id: spi_bus model: seeed-reterminal-e1003 + update_mode: DEFAULT cs_pin: allow_other_uses: true number: GPIO5 From 76de6a672c3cc6b0b8a6b6f50d6bb16b9465caa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:16:01 +0100 Subject: [PATCH 89/94] [core] Render an id constant in an apply field as the object it names (#19694) --- esphome/automation.py | 14 +++++++++----- tests/unit_tests/test_automation.py | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 4a67cd31baf..2f970b915be 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -239,9 +239,9 @@ class ApplyCall: Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use - ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda bypasses - it. The statement is skipped when none of its keys is set, always emitted when it has no - keys, and a partial set is a config error. + ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an + id bypasses it. The statement is skipped when none of its keys is set, always emitted when it + has no keys, and a partial set is a config error. """ target: str @@ -280,8 +280,8 @@ class ApplyField: Double a literal brace in a template. ``conf_key`` may be a path into nested sections. ``type_`` may be a C++ type string using ``{parent}`` when the type is only known per instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp`` - is not the right spelling (unit conversion belongs in the validator); a lambda bypasses it, - so the target must also take a plain ``type_``. An absent key emits nothing. + is not the right spelling (unit conversion belongs in the validator); a lambda or an id + bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing. """ conf_key: str | tuple[str, ...] @@ -380,6 +380,9 @@ async def _render_values( expr = call_lambda(inner) bare = compare and isinstance(expr, cg.RawExpression) exprs.append(f"({expr})" if bare else str(expr)) + elif isinstance(value, ID): + # Qualified like the parent, so a trigger arg named like the id cannot shadow it. + exprs.append(f"::{await cg.get_variable(value)}") elif const_fn is not None: exprs.append(const_fn(config, value)) else: @@ -402,6 +405,7 @@ def register_apply_action( Generates one stateless function for ``ApplyAction``: the parent (read from ``id_key``) and constants are baked in, lambdas are called inline with the trigger args. + A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. """ diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 1e1d2e714ab..001756d72fc 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -705,6 +705,32 @@ async def test_apply_constants( assert "set_ki" not in text +@pytest.mark.asyncio +async def test_apply_id_constant_is_the_named_object( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A templatable use_id given as a plain id renders the object it names.""" + target = MockObj("speaker_b", "->") + mock_cg.get_variable.side_effect = [PARENT_OBJ, target] + fields = (ApplyField("target", "switch_to_output", cg.RawExpression("Speaker *")),) + await _run_apply_action(registries, fields, {"target": ID("speaker_b")}) + mock_cg.get_variable.assert_any_await(ID("speaker_b")) + assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in _apply_lambda(mock_cg) + + +@pytest.mark.asyncio +async def test_apply_condition_id_constant_is_the_named_object( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """The condition path resolves and qualifies an id constant the same way.""" + target = MockObj("speaker_b", "->") + mock_cg.get_variable.side_effect = [PARENT_OBJ, target] + check = ApplyCall("is_output({})", (("target", cg.RawExpression("Speaker *")),)) + await _run_apply_condition(registries, check, {"target": ID("speaker_b")}) + mock_cg.get_variable.assert_any_await(ID("speaker_b")) + assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in _apply_lambda(mock_cg) + + @pytest.mark.asyncio async def test_apply_lambdas( registries: tuple[Registry, Registry], mock_cg: MockCodegen From 0d445fb3bc02f36cbe65379af90fae7ab8137f5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:16:32 +0100 Subject: [PATCH 90/94] [router] Use register_apply_action for router.speaker.switch_output (#19695) --- esphome/components/router/speaker/__init__.py | 26 +++---------------- .../router/speaker/router_speaker.h | 11 -------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index 18311416c30..f568aedcbdf 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation, core +from esphome import automation import esphome.codegen as cg from esphome.components import audio, speaker import esphome.config_validation as cv @@ -9,9 +9,7 @@ from esphome.const import ( CONF_OUTPUT_SPEAKER, CONF_SAMPLE_RATE, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] @@ -20,7 +18,6 @@ CONF_TARGET_SPEAKER = "target_speaker" router_ns = cg.esphome_ns.namespace("router") Router = router_ns.class_("Router", cg.Component, speaker.Speaker) -SwitchOutputAction = router_ns.class_("SwitchOutputAction", automation.Action) SpeakerPtr = speaker.Speaker.operator("ptr") @@ -93,9 +90,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_output(spk)) -@automation.register_action( +automation.register_apply_action( "router.speaker.switch_output", - SwitchOutputAction, cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(Router), @@ -104,19 +100,5 @@ async def to_code(config: ConfigType) -> None: ), } ), - synchronous=True, + automation.ApplyField(CONF_TARGET_SPEAKER, "switch_to_output", SpeakerPtr), ) -async def switch_output_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - target = config[CONF_TARGET_SPEAKER] - if not isinstance(target, core.Lambda): - target = await cg.get_variable(target) - template_ = await cg.templatable(target, args, SpeakerPtr) - cg.add(var.set_target(template_)) - return var diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 31f3f906301..2ce4c1a906d 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -3,7 +3,6 @@ #ifdef USE_ESP32 #include "esphome/components/speaker/speaker.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -80,16 +79,6 @@ class Router final : public Component, public speaker::Speaker { std::atomic active_output_idx_{0}; }; -template class SwitchOutputAction final : public Action { - public: - explicit SwitchOutputAction(Router *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(speaker::Speaker *, target) - void play(const Ts &...x) override { this->parent_->switch_to_output(this->target_.value(x...)); } - - protected: - Router *parent_; -}; - } // namespace esphome::router #endif // USE_ESP32 From d2c33b0f6f2acf561e658a9ea4616fbbadfcb46f Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:24:51 +0000 Subject: [PATCH 91/94] [systa_bus] Add systa_bus component (#15473) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/systa_bus/__init__.py | 41 ++++++ .../components/systa_bus/sensor/__init__.py | 81 ++++++++++++ .../systa_bus/sensor/systa_bus_sensor.cpp | 37 ++++++ .../systa_bus/sensor/systa_bus_sensor.h | 28 ++++ esphome/components/systa_bus/systa_bus.cpp | 70 ++++++++++ esphome/components/systa_bus/systa_bus.h | 50 +++++++ esphome/core/defines.h | 1 + tests/components/systa_bus/__init__.py | 13 ++ tests/components/systa_bus/common.h | 37 ++++++ tests/components/systa_bus/common.yaml | 15 +++ tests/components/systa_bus/systa_bus_test.cpp | 125 ++++++++++++++++++ .../components/systa_bus/test.esp32-idf.yaml | 3 + 13 files changed, 502 insertions(+) create mode 100644 esphome/components/systa_bus/__init__.py create mode 100644 esphome/components/systa_bus/sensor/__init__.py create mode 100644 esphome/components/systa_bus/sensor/systa_bus_sensor.cpp create mode 100644 esphome/components/systa_bus/sensor/systa_bus_sensor.h create mode 100644 esphome/components/systa_bus/systa_bus.cpp create mode 100644 esphome/components/systa_bus/systa_bus.h create mode 100644 tests/components/systa_bus/__init__.py create mode 100644 tests/components/systa_bus/common.h create mode 100644 tests/components/systa_bus/common.yaml create mode 100644 tests/components/systa_bus/systa_bus_test.cpp create mode 100644 tests/components/systa_bus/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 52fec0a7aca..cfe5638a616 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -549,6 +549,7 @@ esphome/components/sx126x/* @swoboda1337 esphome/components/sx127x/* @swoboda1337 esphome/components/sy6970/* @linkedupbits esphome/components/syslog/* @clydebarrow +esphome/components/systa_bus/* @Mat931 esphome/components/t6615/* @tylermenezes esphome/components/tas2780/* @remcom esphome/components/tc74/* @sethgirvan diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py new file mode 100644 index 00000000000..0695b43d416 --- /dev/null +++ b/esphome/components/systa_bus/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@Mat931"] + +DEPENDENCIES = ["uart"] + +MULTI_CONF = True + +systa_bus_ns = cg.esphome_ns.namespace("systa_bus") +SystaBus = systa_bus_ns.class_("SystaBus", cg.Component, uart.UARTDevice) + +CONF_SYSTA_BUS_ID = "systa_bus_id" + +CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaBus), + } +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "systa_bus", baud_rate=9600, require_rx=True +) + +_request_listener_slot = cg.slot_counter("SYSTA_BUS_LISTENER_COUNT") + + +async def register_systa_bus_listener(systa_bus: MockObj, var: MockObj) -> None: + """Register a listener with its bus and count it for the compile-time listener storage.""" + _request_listener_slot(str(systa_bus)) + cg.add(systa_bus.register_listener(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py new file mode 100644 index 00000000000..3def5fba12f --- /dev/null +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -0,0 +1,81 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MODEL, + DEVICE_CLASS_TEMPERATURE, + ICON_PERCENT, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from .. import CONF_SYSTA_BUS_ID, SystaBus, register_systa_bus_listener, systa_bus_ns + +SystaSolarAquaSensor = systa_bus_ns.class_("SystaSolarAquaSensor", cg.Component) + +CONF_SYSTASOLAR_AQUA = "systasolar_aqua" +CONF_TEMPERATURE_TSA = "temperature_tsa" +CONF_TEMPERATURE_TSE = "temperature_tse" +CONF_TEMPERATURE_TWU = "temperature_twu" +CONF_TEMPERATURE_TW2 = "temperature_tw2" +CONF_PUMP_SPEED = "pump_speed" + +_TEMPERATURE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) +_PUMP_SPEED_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Sensors per model, keyed by config key; the C++ setter is "set__sensor" +MODEL_SENSORS = { + CONF_SYSTASOLAR_AQUA: { + CONF_TEMPERATURE_TSA: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TSE: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TWU: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TW2: _TEMPERATURE_SCHEMA, + CONF_PUMP_SPEED: _PUMP_SPEED_SCHEMA, + }, +} + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_SYSTASOLAR_AQUA: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaSolarAquaSensor), + cv.GenerateID(CONF_SYSTA_BUS_ID): cv.use_id(SystaBus), + **{ + cv.Optional(key): schema + for key, schema in MODEL_SENSORS[CONF_SYSTASOLAR_AQUA].items() + }, + } + ), + }, + key=CONF_MODEL, + lower=True, + space="_", +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + for key in MODEL_SENSORS[config[CONF_MODEL]]: + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(getattr(var, f"set_{key}_sensor")(sens)) + + systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) + await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp new file mode 100644 index 00000000000..08a46771805 --- /dev/null +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp @@ -0,0 +1,37 @@ +#include "systa_bus_sensor.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::systa_bus { + +static const char *const TAG = "systa_bus.sensor"; + +static int16_t get_i16be(std::span message, size_t start) { + return static_cast(encode_uint16(message[start], message[start + 1])); +} + +void SystaSolarAquaSensor::dump_config() { + ESP_LOGCONFIG(TAG, "SystaSolar Aqua:"); + LOG_SENSOR(" ", "Temperature TSA", this->temperature_tsa_sensor_); + LOG_SENSOR(" ", "Temperature TSE", this->temperature_tse_sensor_); + LOG_SENSOR(" ", "Temperature TWU", this->temperature_twu_sensor_); + LOG_SENSOR(" ", "Temperature TW2", this->temperature_tw2_sensor_); + LOG_SENSOR(" ", "Pump Speed", this->pump_speed_sensor_); +} + +void SystaSolarAquaSensor::handle_message(std::span message) { + if (get_message_type(message) != MESSAGE_TYPE_AQUA_SENSOR_DATA) + return; + if (this->temperature_tsa_sensor_ != nullptr) + this->temperature_tsa_sensor_->publish_state(get_i16be(message, 4) * 0.1f); + if (this->temperature_tse_sensor_ != nullptr) + this->temperature_tse_sensor_->publish_state(get_i16be(message, 6) * 0.1f); + if (this->temperature_twu_sensor_ != nullptr) + this->temperature_twu_sensor_->publish_state(get_i16be(message, 8) * 0.1f); + if (this->temperature_tw2_sensor_ != nullptr) + this->temperature_tw2_sensor_->publish_state(get_i16be(message, 10) * 0.1f); + if (this->pump_speed_sensor_ != nullptr) + this->pump_speed_sensor_->publish_state(message[12]); +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.h b/esphome/components/systa_bus/sensor/systa_bus_sensor.h new file mode 100644 index 00000000000..3688176d006 --- /dev/null +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -0,0 +1,28 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/sensor/sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaSensor : public SystaBusListener, public Component { + public: + void dump_config() override; + void set_temperature_tsa_sensor(sensor::Sensor *sensor) { this->temperature_tsa_sensor_ = sensor; } + void set_temperature_tse_sensor(sensor::Sensor *sensor) { this->temperature_tse_sensor_ = sensor; } + void set_temperature_twu_sensor(sensor::Sensor *sensor) { this->temperature_twu_sensor_ = sensor; } + void set_temperature_tw2_sensor(sensor::Sensor *sensor) { this->temperature_tw2_sensor_ = sensor; } + void set_pump_speed_sensor(sensor::Sensor *sensor) { this->pump_speed_sensor_ = sensor; } + void handle_message(std::span message) override; + + protected: + sensor::Sensor *temperature_tsa_sensor_{nullptr}; + sensor::Sensor *temperature_tse_sensor_{nullptr}; + sensor::Sensor *temperature_twu_sensor_{nullptr}; + sensor::Sensor *temperature_tw2_sensor_{nullptr}; + sensor::Sensor *pump_speed_sensor_{nullptr}; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp new file mode 100644 index 00000000000..3a2f6717e9b --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -0,0 +1,70 @@ +#include "systa_bus.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::systa_bus { + +static const char *const TAG = "systa_bus"; + +void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } + +static bool checksum(std::span data) { + uint8_t csum = 0; + for (uint8_t i : data) + csum += i; + return csum == 0; +} + +void SystaBus::loop() { + uint8_t c; + while (this->available() && this->read_byte(&c)) + this->handle_byte_(c); +} + +void SystaBus::handle_byte_(uint8_t c) { + if (this->buffer_.empty()) { + if (c == START_BYTE) + this->buffer_.push_back(c); + return; + } + this->buffer_.push_back(c); + if (this->buffer_.size() == 2) { + // The length byte is only trusted for known message types; anything else restarts the search + uint16_t message_type = get_message_type(this->buffer_); + if (message_type != MESSAGE_TYPE_AQUA_SENSOR_DATA) { + ESP_LOGV(TAG, "Unknown message type 0x%04x", message_type); + this->buffer_.clear(); + // A stray start byte followed by a real frame: keep this byte as the new start + if (c == START_BYTE) + this->buffer_.push_back(c); + } + return; + } + if (this->buffer_.size() < this->buffer_[1] + FRAME_OVERHEAD) + return; + if (!checksum(this->buffer_)) { + ESP_LOGW(TAG, "Checksum failed"); + this->resync_(); + return; + } +#ifdef SYSTA_BUS_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->handle_message(this->buffer_); +#endif + this->buffer_.clear(); +} + +// A frame that lost a byte swallows the start of the next one, so the failed bytes are fed back through the +// parser from the second byte on. They are fewer than a full frame, so this cannot fail the checksum again. +void SystaBus::resync_() { + std::array failed; + const size_t count = this->buffer_.size(); + std::copy(this->buffer_.begin(), this->buffer_.end(), failed.begin()); + this->buffer_.clear(); + for (size_t i = 1; i < count; i++) + this->handle_byte_(failed[i]); +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/systa_bus.h b/esphome/components/systa_bus/systa_bus.h new file mode 100644 index 00000000000..99ca31590f5 --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::systa_bus { + +// A frame is a start byte, the payload length, the payload and an 8-bit checksum that makes all bytes sum to +// zero. The first two bytes together are the message type, so the type also fixes the frame length. +static constexpr uint8_t START_BYTE = 0xfc; +static constexpr uint8_t FRAME_OVERHEAD = 3; // start byte, length, checksum + +// SystaSolar Aqua sensor data, 25 bytes: [0..1] type, [2..3] not decoded, big-endian int16 in 0.1 degrees at +// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..23] not decoded. +static constexpr uint16_t MESSAGE_TYPE_AQUA_SENSOR_DATA = 0xfc16; + +// The bus only buffers known message types, so the buffer is sized by the longest of them +static constexpr size_t MAX_MESSAGE_SIZE = (MESSAGE_TYPE_AQUA_SENSOR_DATA & 0xff) + FRAME_OVERHEAD; + +inline uint16_t get_message_type(std::span message) { return encode_uint16(message[0], message[1]); } + +class SystaBusListener { + public: + // Called with every frame that passed the checksum; listeners pick their message type + virtual void handle_message(std::span message) = 0; +}; + +class SystaBus : public uart::UARTDevice, public Component { + public: + void dump_config() override; + void loop() override; + +#ifdef SYSTA_BUS_LISTENER_COUNT + void register_listener(SystaBusListener *listener) { this->listeners_.push_back(listener); } +#endif + + protected: + void handle_byte_(uint8_t c); + void resync_(); + +#ifdef SYSTA_BUS_LISTENER_COUNT + StaticVector listeners_; +#endif + StaticVector buffer_; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e1ae1d5fb60..75c7a76a408 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -175,6 +175,7 @@ #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC #define USE_REMOTE_PROTOCOL_TOTO +#define SYSTA_BUS_LISTENER_COUNT 1 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/tests/components/systa_bus/__init__.py b/tests/components/systa_bus/__init__.py new file mode 100644 index 00000000000..95273b5ad58 --- /dev/null +++ b/tests/components/systa_bus/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Listener storage is sized by code generation; the gtests register one listener per bus. + cg.add_define("SYSTA_BUS_LISTENER_COUNT", 1) + + # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. + manifest.multi_conf = False + manifest.to_code = to_code_testing diff --git a/tests/components/systa_bus/common.h b/tests/components/systa_bus/common.h new file mode 100644 index 00000000000..9824059702e --- /dev/null +++ b/tests/components/systa_bus/common.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/components/systa_bus/systa_bus.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::systa_bus::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + void write_array(const uint8_t *data, size_t len) override {} + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) + return false; + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +} // namespace esphome::systa_bus::testing diff --git a/tests/components/systa_bus/common.yaml b/tests/components/systa_bus/common.yaml new file mode 100644 index 00000000000..ea97e2d840b --- /dev/null +++ b/tests/components/systa_bus/common.yaml @@ -0,0 +1,15 @@ +systa_bus: + +sensor: + - platform: systa_bus + model: systasolar_aqua + temperature_tsa: + name: TSA + temperature_tse: + name: TSE + temperature_twu: + name: TWU + temperature_tw2: + name: TW2 + pump_speed: + name: Pump Speed diff --git a/tests/components/systa_bus/systa_bus_test.cpp b/tests/components/systa_bus/systa_bus_test.cpp new file mode 100644 index 00000000000..3e6de9d2800 --- /dev/null +++ b/tests/components/systa_bus/systa_bus_test.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "common.h" + +namespace esphome::systa_bus::testing { + +namespace { + +class TestListener final : public SystaBusListener { + public: + void handle_message(std::span message) override { + this->messages.emplace_back(message.begin(), message.end()); + } + std::vector> messages; +}; + +void put_i16be(std::vector &frame, size_t offset, int16_t value) { + frame[offset] = static_cast(static_cast(value) >> 8); + frame[offset + 1] = static_cast(value); +} + +// A 25-byte Aqua sensor frame with a valid checksum +std::vector aqua_frame(int16_t tsa, int16_t tse, int16_t twu, int16_t tw2, uint8_t pump) { + std::vector frame(MAX_MESSAGE_SIZE, 0); + frame[0] = START_BYTE; + frame[1] = MESSAGE_TYPE_AQUA_SENSOR_DATA & 0xff; + put_i16be(frame, 4, tsa); + put_i16be(frame, 6, tse); + put_i16be(frame, 8, twu); + put_i16be(frame, 10, tw2); + frame[12] = pump; + uint8_t sum = 0; + for (size_t i = 0; i + 1 < frame.size(); i++) + sum += frame[i]; + frame.back() = static_cast(0 - sum); + return frame; +} + +class SystaBusTest : public ::testing::Test { + protected: + void SetUp() override { + this->bus_.set_uart_parent(&this->uart_); + this->bus_.register_listener(&this->listener_); + } + void feed_(const std::vector &bytes) { + this->uart_.push_rx(bytes); + this->bus_.loop(); + } + + MockUARTComponent uart_; + SystaBus bus_; + TestListener listener_; +}; + +const std::vector FRAME_A = aqua_frame(512, 301, -15, 700, 60); +const std::vector FRAME_B = aqua_frame(513, 302, -14, 701, 61); + +} // namespace + +TEST_F(SystaBusTest, DecodesAValidFrame) { + this->feed_(FRAME_A); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, IgnoresGarbageBeforeAFrame) { + std::vector bytes = {0x00, 0x16, 0x42, 0xfc}; // includes a stray start byte + bytes.insert(bytes.end(), FRAME_A.begin(), FRAME_A.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, SkipsAnUnknownMessageType) { + std::vector bytes = {START_BYTE, 0x1a, 0x01, 0x02, 0x03}; + bytes.insert(bytes.end(), FRAME_A.begin(), FRAME_A.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, RejectsACorruptedByte) { + auto bad = FRAME_A; + bad[6] ^= 0x10; + std::vector bytes = bad; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_B); +} + +// A frame that lost a byte swallows the start byte of the next frame; the parser must still find that frame. +TEST_F(SystaBusTest, RecoversTheFrameAfterADroppedByte) { + auto truncated = FRAME_A; + truncated.erase(truncated.begin() + 9); + std::vector bytes = truncated; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_B); +} + +TEST_F(SystaBusTest, DecodesFramesSplitAcrossLoops) { + std::vector first(FRAME_A.begin(), FRAME_A.begin() + 10); + std::vector second(FRAME_A.begin() + 10, FRAME_A.end()); + this->feed_(first); + EXPECT_TRUE(this->listener_.messages.empty()); + this->feed_(second); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, DecodesBackToBackFrames) { + std::vector bytes = FRAME_A; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 2u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); + EXPECT_EQ(this->listener_.messages[1], FRAME_B); +} + +} // namespace esphome::systa_bus::testing diff --git a/tests/components/systa_bus/test.esp32-idf.yaml b/tests/components/systa_bus/test.esp32-idf.yaml new file mode 100644 index 00000000000..0393d30348d --- /dev/null +++ b/tests/components/systa_bus/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + systa_bus: !include common.yaml From 1de5a58dbe8377220f9d7d3d68d9917b562e29c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:36:31 +0100 Subject: [PATCH 92/94] [display] Use register_apply_condition for display.is_displaying_page (#19705) --- esphome/components/display/__init__.py | 13 ++----------- esphome/components/display/display.h | 12 ------------ tests/components/ssd1306_i2c/common-actions.yaml | 16 ++++++++++++++++ .../ssd1306_i2c/test-actions.esp8266-ard.yaml | 7 +++++++ 4 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 tests/components/ssd1306_i2c/common-actions.yaml create mode 100644 tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index b664579be93..1cd75ddaf07 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -39,9 +39,6 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayIsDisplayingPageCondition = display_ns.class_( - "DisplayIsDisplayingPageCondition", automation.Condition -) DisplayOnPageChangeTrigger = display_ns.class_( "DisplayOnPageChangeTrigger", automation.Trigger ) @@ -301,9 +298,8 @@ automation.register_apply_action( ) -@automation.register_condition( +automation.register_apply_condition( "display.is_displaying_page", - DisplayIsDisplayingPageCondition, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Display), @@ -311,13 +307,8 @@ automation.register_apply_action( }, key=CONF_PAGE_ID, ), + automation.ApplyCall("get_active_page() == {}", ((CONF_PAGE_ID, DisplayPagePtr),)), ) -async def display_is_displaying_page_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - page = await cg.get_variable(config[CONF_PAGE_ID]) - var = cg.new_Pvariable(condition_id, template_arg, paren) - cg.add(var.set_page(page)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index af5a7bc1324..bebeba8dcb2 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -848,18 +848,6 @@ template class DisplayPageShowAction final : public Action class DisplayIsDisplayingPageCondition final : public Condition { - public: - DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} - - void set_page(DisplayPage *page) { this->page_ = page; } - bool check(const Ts &...x) override { return this->parent_->get_active_page() == this->page_; } - - protected: - Display *parent_; - DisplayPage *page_; -}; - class DisplayOnPageChangeTrigger final : public Trigger { public: explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } diff --git a/tests/components/ssd1306_i2c/common-actions.yaml b/tests/components/ssd1306_i2c/common-actions.yaml new file mode 100644 index 00000000000..737fe7f26b9 --- /dev/null +++ b/tests/components/ssd1306_i2c/common-actions.yaml @@ -0,0 +1,16 @@ +button: + - platform: template + name: Page check + on_press: + - if: + condition: + display.is_displaying_page: ssd1306_i2c_page1 + then: + - logger.log: On page 1 + - if: + condition: + display.is_displaying_page: + id: ssd1306_i2c_ssd1306_i2c_display + page_id: ssd1306_i2c_page2 + then: + - logger.log: On page 2 diff --git a/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml b/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..e18129c9199 --- /dev/null +++ b/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + reset_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ssd1306_i2c: !include common.yaml + actions: !include common-actions.yaml From 974405820179d4686c79cb574a8d8cff77a1dafa Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:14:12 +0000 Subject: [PATCH 93/94] Bump bundled esphome-device-builder to 1.16.0 (#19710) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d94a7ce41a7..c68cd0f4ba4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.15.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.16.0 RUN \ platformio settings set enable_telemetry No \ From 22fa536fa58dadb1a40cb5cf3d61f7aeac4abe9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 10:31:52 +0100 Subject: [PATCH 94/94] Simplify the native toolchain driver Drops the ninja dry-run probe (a second full manifest load and stat sweep per build to suppress one line), the compile-database stamp file (a plain mtime compare says the same), and the ccache sentinel (resolve_ccache_path only spawns when it returns a value, so a plain fallback never re-probes). The crash decoder resolves addr2line and the ELF once per run instead of once per address, which removes the module-level warn-dedup dict along with its test fixture. The size summary uses the toolchain path it was handed rather than re-deriving it, and its two failure branches merge. The framework import moves into the functions that need it: the serial upload and logs fast path resolves this module for artifact paths alone and was paying 16 ms and 29 modules for the package-download stack. test_lazy_imports now guards that module so it cannot come back. analyze-memory's idedata candidate list learns the suffixed native cache name, and warn_if_idedata_missing goes back to being a one-for-one extraction. --- esphome/analyze_memory/toolchain.py | 4 + esphome/arduino8266/toolchain.py | 134 ++++++------------ esphome/build_helpers/idedata.py | 8 +- esphome/components/esp8266/__init__.py | 93 +++++------- esphome/components/esp8266/const.py | 2 + esphome/writer.py | 5 +- .../esp8266/test_toolchain_validation.py | 3 +- .../unit_tests/test_arduino8266_toolchain.py | 105 ++------------ tests/unit_tests/test_lazy_imports.py | 7 +- 9 files changed, 107 insertions(+), 254 deletions(-) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 19041ac8071..b64af5ea5b8 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -68,12 +68,16 @@ def idedata_candidates(build_path: Path) -> list[Path]: The candidate idedata JSON paths, most specific first """ name = build_path.name + data_dir = build_path.parent.parent / "idedata" + # Native backends suffix the cache by toolchain (.arduino.json) + suffixed = sorted(data_dir.glob(f"{name}.*.json")) if data_dir.is_dir() else [] return [ # In .pioenvs for test builds build_path / ".pioenvs" / name / "idedata.json", # Both toolchains cache it in the data dir, which holds this build dir: # /idedata/.json next to /build/ build_path.parent.parent / "idedata" / f"{name}.json", + *suffixed, # Regular builds, invoked from the config dir or from anywhere Path.cwd() / ".esphome" / "idedata" / f"{name}.json", Path.home() / ".esphome" / "idedata" / f"{name}.json", diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index c0170ca0ff4..339fb44337f 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -6,9 +6,8 @@ import json import logging from pathlib import Path import subprocess -from typing import Any +from typing import TYPE_CHECKING -from esphome.arduino8266 import framework from esphome.build_helpers.ccache import resolve_ccache_path from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, @@ -17,9 +16,12 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, ) from esphome.core import CORE, EsphomeError -from esphome.helpers import write_file_if_changed +from esphome.helpers import write_file from esphome.types import ConfigType +if TYPE_CHECKING: + from esphome.arduino8266.framework import InstalledPaths + _LOGGER = logging.getLogger(__name__) # ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest) @@ -53,6 +55,11 @@ def get_elf_path() -> Path: def _toolchain_tool(name: str) -> Path: + # Imported here, not at module scope: the serial upload/logs fast path + # resolves this module for its artifact paths alone, and framework + # pulls in the whole package-download stack + from esphome.arduino8266 import framework + return framework.toolchain_tool(framework.get_toolchain_path(), name) @@ -75,32 +82,23 @@ def get_readelf_path() -> Path: def run_compile(config: ConfigType, verbose: bool) -> int: + from esphome.arduino8266 import framework from esphome.build_gen import arduino8266 as build_gen _warn_ignored_platformio_options() paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) # Resolved once: the probe is not free and three consumers need it ccache = resolve_ccache_path() - ninja_changed = build_gen.write_project(paths, ccache) + build_gen.write_project(paths, ccache) build_dir = get_build_dir() env = framework.get_build_env(paths.toolchain, ccache) - # Regenerate the compile DB when it is older than build.ninja. - # Freshness rides a stamp: the DB is written via write_file_if_changed - # (mtime feeds get_idedata's cache), so identical content would - # otherwise stay "stale" forever + # The DB is a pure function of build.ninja; regenerate when it is older compdb = build_dir / "compile_commands.json" - compdb_stamp = build_dir / ".compile_commands.stamp" ninja_file = build_dir / "build.ninja" - if ( - ninja_changed - or not compdb.is_file() - or not compdb_stamp.is_file() - or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime - ): + if not compdb.is_file() or compdb.stat().st_mtime < ninja_file.stat().st_mtime: _write_compile_commands(paths.ninja, build_dir, env) - compdb_stamp.touch() cmd = [str(paths.ninja)] if verbose: @@ -112,38 +110,13 @@ def run_compile(config: ConfigType, verbose: bool) -> int: targets = ["firmware.factory.bin", "firmware.ota.bin"] cmd += targets - # The dry-run probe keeps a no-op rebuild quiet; a rewritten manifest - # all but guarantees work, so skip it then. cwd (not -C) drops the - # "Entering directory" banner. - skip_build = False - if not ninja_changed: - probe = subprocess.run( - [str(paths.ninja), "-n", *targets], - cwd=build_dir, - env=env, - capture_output=True, - text=True, - check=False, - close_fds=False, - ) - if probe.stderr.strip(): - # A load-time diagnostic flags a generator bug; the skip branch - # would otherwise swallow it forever - _LOGGER.warning("ninja: %s", probe.stderr.strip()) - if probe.returncode != 0: - # An unknown target here is the defective-manifest case; fall - # through to the real build so the error prints attributably - _LOGGER.debug("ninja probe failed; running the full build") - skip_build = probe.returncode == 0 and "no work to do" in probe.stdout - if skip_build: - _LOGGER.debug("ninja: nothing to rebuild") - else: - _LOGGER.debug("Running: %s", " ".join(cmd)) - rc = subprocess.run( - cmd, cwd=build_dir, env=env, check=False, close_fds=False - ).returncode - if rc != 0: - return rc + # cwd, not -C: drops ninja's "Entering directory" banner + _LOGGER.debug("Running: %s", " ".join(cmd)) + rc = subprocess.run( + cmd, cwd=build_dir, env=env, check=False, close_fds=False + ).returncode + if rc != 0: + return rc # ninja already refused missing targets; existence covers a rule that # ran but wrote elsewhere @@ -200,12 +173,10 @@ def _write_compile_commands( "ninja produced an empty compile database; the generator's rule " "names no longer match" ) - # write_file_if_changed keeps the mtime stable on no-op builds so the - # idedata cache in get_idedata() stays valid. - write_file_if_changed(compdb, result.stdout) + write_file(compdb, result.stdout) -def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None: +def _parse_app_size(build_dir: Path, paths: InstalledPaths) -> int | None: """Read the app flash budget (irom0_0_seg length) from the linker script.""" from esphome.build_gen.arduino8266 import get_flash_ld_path from esphome.components.esp8266.build_surgery import segment_length @@ -219,50 +190,43 @@ def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | N # A corrupt script degrades the same way, never aborts the build _LOGGER.warning("Cannot read linker script for the Flash summary: %s", err) return None - app_size = segment_length(ld_text, "irom0_0_seg") - if app_size is None: - _LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path) - return None - if app_size == 0: - _LOGGER.warning( - "irom0_0_seg has zero length in %s; skipping Flash summary", ld_path - ) + if not (app_size := segment_length(ld_text, "irom0_0_seg")): + _LOGGER.warning("No usable irom0_0_seg in %s; skipping Flash summary", ld_path) return None return app_size -def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool: +def _print_size_summary(build_dir: Path, paths: InstalledPaths) -> bool: """Print the RAM/Flash lines ``ci_memory_impact_extract.py`` parses; False when skipped.""" + from esphome.arduino8266.framework import toolchain_tool from esphome.build_helpers.size_summary import print_size_line - size_tool = _toolchain_tool("size") try: result = subprocess.run( - [str(size_tool), "-A", "-d", str(get_elf_path())], + [ + str(toolchain_tool(paths.toolchain, "size")), + "-A", + "-d", + str(build_dir / "firmware.elf"), + ], capture_output=True, text=True, - check=False, + check=True, close_fds=False, ) - except OSError as err: + except (OSError, subprocess.CalledProcessError) as err: # The summary is a bonus artifact like idedata; a truncated # toolchain extraction must not discard an already-linked build _LOGGER.warning("Could not summarize firmware size: %s", err) return False - if result.returncode != 0: - _LOGGER.warning("Could not summarize firmware size: %s", result.stderr) - return False - sections: dict[str, int] = {} - for line in result.stdout.splitlines(): - parts = line.split() - if len(parts) >= 2 and parts[0].startswith("."): - try: - sections[parts[0]] = int(parts[1]) - except ValueError: - # An unparsed RAM/Flash section trips the missing-sections - # guard below, so no total is built on a dropped value - _LOGGER.warning("Unparsable size output for section %s", parts[0]) + # -d prints decimal sizes; anything else trips the missing-sections guard + sections = { + parts[0]: int(parts[1]) + for line in result.stdout.splitlines() + if (parts := line.split())[:1] and parts[0].startswith(".") and len(parts) >= 2 + if parts[1].isdigit() + } if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections): # A defaulted 0 would print a confidently wrong total for CI's metric _LOGGER.warning( @@ -282,18 +246,14 @@ def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> boo return True -# Sentinel: "resolve for me"; None is a real value meaning disabled. -_CCACHE_UNRESOLVED: Any = object() - - -def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None: +def get_idedata(ccache: str | None = None) -> dict | None: """Derive idedata from the build's compile_commands.json (same contract as ``espidf.toolchain.get_idedata``).""" from esphome.build_helpers.idedata import load_or_build_idedata - if ccache is _CCACHE_UNRESOLVED: - # Uncached: env/PATH can change between builds in a host process - ccache = resolve_ccache_path() + # A disabled ccache resolves to None without spawning anything, so + # re-resolving here costs nothing when the caller has no answer + launcher = ccache or resolve_ccache_path() return load_or_build_idedata( get_build_dir() / "compile_commands.json", get_elf_path(), @@ -302,5 +262,5 @@ def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None: CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"), # The compile DB's commands carry the same ccache prefix the ninja # rules were generated with - launcher=str(ccache) if ccache else None, + launcher=str(launcher) if launcher else None, ) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index e76422257af..98ee8044030 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -47,13 +47,7 @@ def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None: "memory-analysis data will be unavailable for this build)", err, ) - if isinstance(err, (EsphomeError, OSError)): - # Routine environmental failures keep the detail at debug - _LOGGER.debug("Idedata failure detail", exc_info=True) - else: - # LookupError/ValueError/RuntimeError smell like a parsing bug; - # a permanently masked traceback would hide it on every build - _LOGGER.warning("Idedata failure detail", exc_info=True) + _LOGGER.debug("Idedata failure detail", exc_info=True) # C++ translation-unit suffixes used to identify ESPHome source files. diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 93547ab6767..d285c19a389 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -3,7 +3,6 @@ from pathlib import Path import platform import re import subprocess -import time from typing import Any import esphome.codegen as cg @@ -38,7 +37,7 @@ from esphome.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script +from .boards import BOARDS, board_ld_script from .const import ( BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, @@ -46,6 +45,7 @@ from .const import ( CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, KEY_BOARD, + KEY_DECODE_TOOLS, KEY_ESP8266, KEY_PIN_INITIAL_STATES, KEY_SCANF_FLOAT, @@ -141,12 +141,7 @@ def _validate_native_toolchain(config: ConfigType) -> ConfigType: "'toolchain: arduino' does not support a custom framework source; " "use 'toolchain: platformio'" ) - # BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is - # a drift guard for the independently regenerated tables - if ( - config[CONF_BOARD] not in BOARDS - or config[CONF_BOARD] not in ESP8266_BOARD_BUILD - ): + if config[CONF_BOARD] not in BOARDS: raise cv.Invalid( f"Board '{config[CONF_BOARD]}' is not supported by " "'toolchain: arduino'; use 'toolchain: platformio'" @@ -591,73 +586,53 @@ ESP8266_EXCEPTION_CODES = { } -_DECODE_WARNED_AT: dict[str, float] = {} +def _resolve_decode_tools(config: ConfigType) -> tuple[str, str] | None: + """``(addr2line, elf)`` for this build, or None after warning why.""" + if (native_toolchain := native_toolchain_module()) is not None: + addr2line = native_toolchain.get_addr2line_path() + elf = native_toolchain.get_elf_path() + for path in (addr2line, elf): + if not path.is_file(): + _LOGGER.warning("Cannot decode crash addresses: %s missing", path) + return None + return str(addr2line), str(elf) + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + if not idedata.addr2line_path or not idedata.firmware_elf_path: + _LOGGER.warning("Cannot decode crash addresses: no addr2line or ELF in idedata") + return None + return idedata.addr2line_path, idedata.firmware_elf_path -def _warn_decode_problem(key: str, message: str, *args) -> bool: - """Warn, deduplicated briefly so a burst of stack-dump addresses warns - once but a later dump warns again; returns whether it warned so the - caller can mark suppressed addresses individually.""" - now = time.monotonic() - last = _DECODE_WARNED_AT.get(key) - if last is not None and now - last < 30: - return False - _DECODE_WARNED_AT[key] = now - _LOGGER.warning(message, *args) - return True +def _decode_tools(config: ConfigType) -> tuple[str, str] | None: + """Resolved once per run: the tools are a property of the build, not of + the address, so a stack dump cannot repeat the failure warning.""" + data = CORE.data.setdefault(KEY_ESP8266, {}) + if KEY_DECODE_TOOLS not in data: + data[KEY_DECODE_TOOLS] = _resolve_decode_tools(config) + return data[KEY_DECODE_TOOLS] def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None: """Decode one crash address. ``bulk``: the caller is scanning every 8-hex stack word, most of which are not code addresses -- unmappable ones log at debug so real frames are not buried.""" - if (native_toolchain := native_toolchain_module()) is not None: - addr2line = native_toolchain.get_addr2line_path() - elf = native_toolchain.get_elf_path() - for path in (addr2line, elf): - if not path.is_file(): - _warn_decode_problem( - str(path), "Cannot decode crash addresses: %s missing", path - ) - # The detailed warning names no address; mark named - # registers, but bulk stack words at debug (~150 per dump) - log = _LOGGER.debug if bulk else _LOGGER.warning - log("Not decoded %s (toolchain file missing)", addr) - return - addr2line, elf = str(addr2line), str(elf) - else: - from esphome.platformio import toolchain - - idedata = toolchain.get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: - _warn_decode_problem( - "no-addr2line", - "Cannot decode crash addresses: no addr2line or ELF in idedata", - ) - log = _LOGGER.debug if bulk else _LOGGER.warning - log("Not decoded %s (no addr2line or ELF)", addr) - return - addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path + # Bulk stack words are ~150 per dump; only named registers warn + log = _LOGGER.debug if bulk else _LOGGER.warning + if (tools := _decode_tools(config)) is None: + log("Not decoded %s (no addr2line or ELF)", addr) + return + addr2line, elf = tools command = [addr2line, "-pfiaC", "-e", elf, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - # Warn (rate-limited across a dump); mark undecoded addresses - # inline or the rest read as merely unmappable - if not _warn_decode_problem( - "addr2line-failed", "Could not decode crash address %s (%s)", addr, err - ): - # The detailed warning already named this address; mark only - # the rate-limited ones, and bulk stack words at debug - log = _LOGGER.debug if bulk else _LOGGER.warning - log("Not decoded %s (addr2line failed)", addr) + log("Could not decode crash address %s (%s)", addr, err) _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return if "?? ??:0" in translation: - # A named register that fails to decode is confusing silence; a - # bulk stack word failing is the expected common case - log = _LOGGER.debug if bulk else _LOGGER.warning log("Not decoded %s (address not in %s)", addr, elf) return translation = translation.replace(" at ??:?", "").replace(":?", "") diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index ac2fe0aa3b4..bf4dd1a3584 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -19,6 +19,8 @@ KEY_SERIAL1_REQUIRED = "serial1_required" KEY_SCANF_FLOAT = "scanf_float" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" +# Crash-decode tools, resolved once per run by __init__ +KEY_DECODE_TOOLS = "decode_tools" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/esphome/writer.py b/esphome/writer.py index 6ffa090e189..bddb6a0f5f8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -612,10 +612,7 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): # idedata caches live under the data dir, not the build path; globbed # so a future backend suffix cannot drift out of clean-all idedata_dir = CORE.relative_internal_path("idedata") - for idedata_cache in ( - *idedata_dir.glob(f"{CORE.name}.json"), - *idedata_dir.glob(f"{CORE.name}.*.json"), - ): + for idedata_cache in idedata_dir.glob(f"{CORE.name}*.json"): _LOGGER.info("Deleting %s", idedata_cache) idedata_cache.unlink() diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index aecc8a13027..4e3cfcbfd08 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -32,10 +32,9 @@ from esphome.types import ConfigType @pytest.fixture(autouse=True) def _arduino_toolchain() -> Generator[None]: # The suite-wide reset_core fixture clears CORE.toolchain after each test + # The decode-tool cache lives in CORE.data, which reset_core clears CORE.toolchain = Toolchain.ARDUINO - esp8266._DECODE_WARNED_AT.clear() yield - esp8266._DECODE_WARNED_AT.clear() def _config( diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index 15f0e9717da..a9c0546b4b1 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -4,6 +4,7 @@ from __future__ import annotations import os from pathlib import Path +import subprocess from unittest.mock import MagicMock, patch import pytest @@ -93,7 +94,6 @@ def test_run_compile_success(tmp_path: Path) -> None: with ( patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), patch.object(framework, "get_build_env", return_value={}), - # An unchanged manifest is what makes the -n probe run patch("esphome.build_gen.arduino8266.write_project", return_value=False), patch.object( toolchain.subprocess, @@ -108,48 +108,19 @@ def test_run_compile_success(tmp_path: Path) -> None: {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}, verbose=False ) assert rc == 0 - # The -n probe runs first, then the real build (cwd, no -C banner) - ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])] - assert "-n" in ninja_calls[0][0][0] + call = next(c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])) + cmd = call[0][0] # Explicit targets: a manifest missing them fails as "unknown target" - assert ninja_calls[0][0][0][-1] == "firmware.ota.bin" - cmd = ninja_calls[1][0][0] - assert cmd[-4:] == ["-j", "4", "firmware.factory.bin", "firmware.ota.bin"] + assert {"firmware.factory.bin", "firmware.ota.bin"} <= set(cmd) + assert cmd[cmd.index("-j") + 1] == "4" + # cwd, not -C, so ninja prints no "Entering directory" banner assert "-C" not in cmd - assert ninja_calls[1][1]["cwd"] is not None + assert call[1]["cwd"] is not None mock_compdb.assert_called_once() mock_size.assert_called_once() mock_idedata.assert_called_once() -def test_run_compile_noop_skips_the_build_spawn(tmp_path: Path) -> None: - """A no-op rebuild stays quiet: the -n probe answers "no work to do" - and the real ninja spawn (and its banner) never happens.""" - with ( - patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), - patch.object(framework, "get_build_env", return_value={}), - # An unchanged manifest is what makes the -n probe run - patch("esphome.build_gen.arduino8266.write_project", return_value=False), - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock( - returncode=0, stdout="ninja: no work to do.\n", stderr="" - ), - ) as mock_run, - patch.object(toolchain, "_write_compile_commands"), - patch.object(toolchain, "_print_size_summary"), - patch.object(toolchain, "get_idedata"), - ): - rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) - assert rc == 0 - ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])] - assert len(ninja_calls) == 1 - assert "-n" in ninja_calls[0][0][0] - # Explicit targets: a manifest missing them fails as "unknown target" - assert ninja_calls[0][0][0][-1] == "firmware.ota.bin" - - def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None: """An interrupted run can leave build.ninja newer than the compile DB; mere existence must not skip regeneration.""" @@ -178,34 +149,6 @@ def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None: mock_compdb.assert_called_once() -def test_run_compile_surfaces_probe_diagnostics( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - """A load-time ninja diagnostic (a generator bug signal) reaches the - user even when the no-work branch skips the real spawn.""" - with ( - patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), - patch.object(framework, "get_build_env", return_value={}), - # An unchanged manifest is what makes the -n probe run - patch("esphome.build_gen.arduino8266.write_project", return_value=False), - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock( - returncode=0, - stdout="ninja: no work to do.\n", - stderr="ninja: warning: multiple rules generate x\n", - ), - ), - patch.object(toolchain, "_write_compile_commands"), - patch.object(toolchain, "_print_size_summary"), - patch.object(toolchain, "get_idedata"), - ): - rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) - assert rc == 0 - assert "multiple rules generate x" in caplog.text - - def test_run_compile_missing_artifact_fails( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -386,7 +329,7 @@ def test_print_size_summary_size_tool_failure( with patch.object( toolchain.subprocess, "run", - return_value=MagicMock(returncode=1, stdout="", stderr="bad elf"), + side_effect=subprocess.CalledProcessError(1, "size", stderr="bad elf"), ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert capsys.readouterr().out == "" @@ -459,7 +402,7 @@ def test_print_size_summary_unparsable_section( caplog: pytest.LogCaptureFixture, ) -> None: """A totals-relevant section that fails to parse must not produce a - confident wrong number; an irrelevant one only warns.""" + confident wrong number; an irrelevant one is ignored.""" bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc") with patch.object( toolchain.subprocess, @@ -468,7 +411,7 @@ def test_print_size_summary_unparsable_section( ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert capsys.readouterr().out == "" - assert "Unparsable size output" in caplog.text + assert "missing section(s) .bss" in caplog.text caplog.clear() harmless = _SIZE_OUTPUT + ".broken abc 0\n" @@ -482,7 +425,7 @@ def test_print_size_summary_unparsable_section( ): toolchain._print_size_summary(tmp_path, _paths(tmp_path)) assert "RAM:" in capsys.readouterr().out - assert "Unparsable size output" in caplog.text + assert not caplog.text def test_print_size_summary_missing_section_skips_summary( @@ -590,32 +533,6 @@ def test_parse_app_size_non_utf8_ld_warns( assert "Cannot read linker script" in caplog.text -def test_run_compile_failed_probe_runs_full_build(tmp_path: Path) -> None: - """A failing -n probe (e.g. unknown target from a defective manifest) - falls through to the real build so the error prints attributably.""" - probe = MagicMock(returncode=1, stdout="", stderr="") - ok = MagicMock(returncode=0, stdout="", stderr="") - - def fake_run(cmd, *args, **kwargs): - return probe if "-n" in cmd else ok - - with ( - patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), - patch.object(framework, "get_build_env", return_value={}), - patch("esphome.build_gen.arduino8266.write_project", return_value=False), - patch.object(toolchain.subprocess, "run", side_effect=fake_run) as mock_run, - patch.object(toolchain, "_write_compile_commands"), - patch.object(toolchain, "_print_size_summary", return_value=True), - patch.object(toolchain, "get_idedata", return_value={}), - ): - assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 - # The real build ran after the failed probe - assert any( - "firmware.ota.bin" in c[0][0] and "-n" not in c[0][0] - for c in mock_run.call_args_list - ) - - def test_get_idedata_accepts_preresolved_ccache() -> None: """run_compile threads its resolved ccache through; the probe must not run again.""" diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 736cd524d2f..1eeddd80eac 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -34,7 +34,12 @@ HEAVY_MODULES = ( # Everything the storage fast path must keep out of sys.modules; the # existence guard and the leak check must watch the same list. -FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) +FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ( + "esphome.components.esp32", + # A native backend module resolves artifact paths on this path; its + # framework half pulls in the whole package-download stack + "esphome.platformio.registry", +) # Heavy only for modules that must not know about the API transport; # in the existence guard so a rename can't silently no-op its check.