diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 2ad023ed1b0..0021654def6 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -3,6 +3,9 @@ name: PR Title Check on: pull_request: types: [opened, edited, synchronize, reopened] + branches-ignore: + - release + - beta permissions: contents: read diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index 09449504320..7e5d85c57a4 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -132,7 +132,6 @@ ATM90E32_PHASE_SCHEMA = cv.Schema( cv.Optional(CONF_PHASE_ANGLE): sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_HARMONIC_POWER): sensor.sensor_schema( diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 660f75ccd9e..d8cdaa5d58e 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -390,7 +390,7 @@ def validate_multi_click_timing(value): new_state = v_.get(CONF_STATE, not state) if new_state == state: raise cv.Invalid( - f"Timings must have alternating state. Indices {i} and {i + 1} have the same state {state}" + f"Timings must have alternating state. Indices {i - 1} and {i} have the same state {state}" ) if max_length is not None and max_length < min_length: raise cv.Invalid( diff --git a/esphome/components/bl0940/number/__init__.py b/esphome/components/bl0940/number/__init__.py index a640c2ae08f..92ab2837b34 100644 --- a/esphome/components/bl0940/number/__init__.py +++ b/esphome/components/bl0940/number/__init__.py @@ -89,6 +89,6 @@ async def to_code(config): ) await cg.register_component(var, conf) - if restore_value := config.get(CONF_RESTORE_VALUE): + if restore_value := conf.get(CONF_RESTORE_VALUE): cg.add(var.set_restore_value(restore_value)) cg.add(getattr(bl0940, setter_method)(var)) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 974611c9b18..79d05049bfb 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -373,14 +373,14 @@ def bt_uuid(value): value = in_value.upper() if len(value) == len(bt_uuid16_format): - pattern = re.compile("^[A-F|0-9]{4,}$") + pattern = re.compile("^[A-F0-9]{4,}$") if not pattern.match(value): raise cv.Invalid( f"Invalid hexadecimal value for 16 bit UUID format: '{in_value}'" ) return value if len(value) == len(bt_uuid32_format): - pattern = re.compile("^[A-F|0-9]{8,}$") + pattern = re.compile("^[A-F0-9]{8,}$") if not pattern.match(value): raise cv.Invalid( f"Invalid hexadecimal value for 32 bit UUID format: '{in_value}'" @@ -388,7 +388,7 @@ def bt_uuid(value): return value if len(value) == len(bt_uuid128_format): pattern = re.compile( - "^[A-F|0-9]{8,}-[A-F|0-9]{4,}-[A-F|0-9]{4,}-[A-F|0-9]{4,}-[A-F|0-9]{12,}$" + "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" ) if not pattern.match(value): raise cv.Invalid( diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index e2e790164ee..8052c13596b 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -10,11 +10,7 @@ AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] esp32_ble_beacon_ns = cg.esphome_ns.namespace("esp32_ble_beacon") -ESP32BLEBeacon = esp32_ble_beacon_ns.class_( - "ESP32BLEBeacon", - cg.Component, - cg.Parented.template(esp32_ble.ESP32BLE), -) +ESP32BLEBeacon = esp32_ble_beacon_ns.class_("ESP32BLEBeacon", cg.Component) CONF_MAJOR = "major" CONF_MINOR = "minor" CONF_MIN_INTERVAL = "min_interval" diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index e16c413179f..44a71334543 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -35,7 +35,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component, public Parented { +class ESP32BLEBeacon : public Component { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 57106cd93be..7bf3092a4e8 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -307,24 +307,30 @@ def final_validate_config(config): # Check if all characteristics that require notifications have the notify property set for char_id in CORE.data.get(DOMAIN, {}).get(KEY_NOTIFY_REQUIRED, set()): # Look for the characteristic in the configuration - char_config = [ + matches = [ char_conf for service_conf in config[CONF_SERVICES] for char_conf in service_conf[CONF_CHARACTERISTICS] if char_conf[CONF_ID] == char_id - ][0] + ] + if not matches: + continue + char_config = matches[0] if not char_config[CONF_NOTIFY]: raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has notify actions and the {CONF_NOTIFY} property is not set" ) for char_id in CORE.data.get(DOMAIN, {}).get(KEY_SET_VALUE, set()): # Look for the characteristic in the configuration - char_config = [ + matches = [ char_conf for service_conf in config[CONF_SERVICES] for char_conf in service_conf[CONF_CHARACTERISTICS] if char_conf[CONF_ID] == char_id - ][0] + ] + if not matches: + continue + char_config = matches[0] if isinstance(char_config.get(CONF_VALUE, {}).get(CONF_DATA), cv.Lambda): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has both a set_value action and a templated value" diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 43508afaf92..64be4a64952 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -155,7 +155,7 @@ ESP8266_PIN_SCHEMA = cv.All( @dataclass class PinInitialState: - mode = 255 + mode: int = 255 level: int = 255 diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 1c8d262810d..a9624734d02 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -158,15 +158,15 @@ def validate_peer(value): def _validate_raw_data(value): if isinstance(value, str): - if len(value) >= MAX_ESPNOW_PACKET_SIZE: + if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( - f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} characters long, got {len(value)}" + f"'{CONF_DATA}' must be at most {MAX_ESPNOW_PACKET_SIZE} characters long, got {len(value)}" ) return value if isinstance(value, list): if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( - f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} bytes long, got {len(value)}" + f"'{CONF_DATA}' must be at most {MAX_ESPNOW_PACKET_SIZE} bytes long, got {len(value)}" ) return cv.Schema([cv.hex_uint8_t])(value) raise cv.Invalid( @@ -245,7 +245,7 @@ async def send_action( data = config.get(CONF_DATA, []) if isinstance(data, str): - data = [cg.RawExpression(f"'{c}'") for c in data] + data = list(data.encode()) templ = await cg.templatable(data, args, byte_vector, byte_vector) cg.add(var.set_data(templ)) diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index fb37ac2c8da..134ac245bf3 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLUME, + DEVICE_CLASS_VOLUME_FLOW_RATE, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, UNIT_CELSIUS, @@ -75,7 +76,7 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_FLOW): sensor.sensor_schema( accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLUME, + device_class=DEVICE_CLASS_VOLUME_FLOW_RATE, state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=UNIT_LITRE_PER_HOUR, ), diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index eb08a522e51..75ae7036380 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -36,7 +36,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(lc709203f), - cv.Optional(CONF_SIZE, default="500"): cv.int_range(100, 3000), + cv.Optional(CONF_SIZE, default=500): cv.int_range(100, 3000), cv.Optional(CONF_VOLTAGE, default="3.7"): cv.enum( BATTERY_VOLTAGE_OPTIONS, upper=True ), diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b429e1e322a..b69f8ef57bf 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -2,7 +2,7 @@ import importlib from pathlib import Path import pkgutil -from esphome.automation import build_automation, validate_automation +from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import ( CONF_BYTE_ORDER, @@ -34,7 +34,6 @@ from esphome.const import ( CONF_ID, CONF_LAMBDA, CONF_LOG_LEVEL, - CONF_ON_BOOT, CONF_ON_IDLE, CONF_PAGES, CONF_ROTATION, @@ -59,7 +58,7 @@ from .encoders import ( from .gradient import GRADIENT_SCHEMA, gradients_to_code from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool, lv_images_used -from .lvcode import LvContext, LvglComponent, lvgl_static +from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, @@ -71,7 +70,7 @@ from .schemas import ( ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code -from .trigger import add_on_boot_triggers, generate_align_tos, generate_triggers +from .trigger import generate_align_tos, generate_triggers from .types import ( IdleTrigger, PlainTrigger, @@ -79,6 +78,7 @@ from .types import ( lv_font_t, lv_group_t, lv_lambda_t, + lv_obj_t_ptr, lv_style_t, lvgl_ns, ) @@ -398,7 +398,6 @@ async def to_code(configs): f"set_{trigger_name.removeprefix('on_')}_trigger", )(trigger_var) ) - await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) # This must be done after all widgets are created for comp in helpers.lvgl_components_required: @@ -502,6 +501,17 @@ LVGL_SCHEMA = cv.All( cv.polling_component_schema("1s") .extend( { + **{ + cv.Optional(event): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) + ), + } + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + + df.LV_DISPLAY_EVENT_TRIGGERS + }, cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), cv.GenerateID(df.CONF_DISPLAYS): display_schema, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index ae8387bccac..ef29a99ddd4 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -276,10 +276,6 @@ LV_EVENT_MAP = { "DRAW_POST_BEGIN": "DRAW_POST_BEGIN", "DRAW_POST_END": "DRAW_POST_END", "DRAW_TASK_ADD": "DRAW_TASK_ADDED", - "FLUSH_FINISH": "FLUSH_FINISH", - "FLUSH_START": "FLUSH_START", - "FLUSH_WAIT_FINISH": "FLUSH_WAIT_FINISH", - "FLUSH_WAIT_START": "FLUSH_WAIT_START", "FOCUS": "FOCUSED", "GESTURE": "GESTURE", "GET_SELF_SIZE": "GET_SELF_SIZE", @@ -300,18 +296,8 @@ LV_EVENT_MAP = { "READY": "READY", "REFRESH": "REFRESH", "REFR_EXT_DRAW_SIZE": "REFR_EXT_DRAW_SIZE", - "REFR_READY": "REFR_READY", - "REFR_REQUEST": "REFR_REQUEST", - "REFR_START": "REFR_START", "RELEASE": "RELEASED", - "RENDER_READY": "RENDER_READY", - "RENDER_START": "RENDER_START", - "RESOLUTION_CHANGE": "RESOLUTION_CHANGED", "ROTARY": "ROTARY", - "SCREEN_LOAD": "SCREEN_LOADED", - "SCREEN_LOAD_START": "SCREEN_LOAD_START", - "SCREEN_UNLOAD": "SCREEN_UNLOADED", - "SCREEN_UNLOAD_START": "SCREEN_UNLOAD_START", "SCROLL": "SCROLL", "SCROLL_BEGIN": "SCROLL_BEGIN", "SCROLL_END": "SCROLL_END", @@ -322,12 +308,34 @@ LV_EVENT_MAP = { "STATE_CHANGE": "STATE_CHANGED", "STYLE_CHANGE": "STYLE_CHANGED", "TRIPLE_CLICK": "TRIPLE_CLICKED", +} +LV_SCREEN_EVENT_MAP = { + "SCREEN_LOAD": "SCREEN_LOADED", + "SCREEN_LOAD_START": "SCREEN_LOAD_START", + "SCREEN_UNLOAD": "SCREEN_UNLOADED", + "SCREEN_UNLOAD_START": "SCREEN_UNLOAD_START", +} + +LV_DISPLAY_EVENT_MAP = { + "FLUSH_FINISH": "FLUSH_FINISH", + "FLUSH_START": "FLUSH_START", + "FLUSH_WAIT_FINISH": "FLUSH_WAIT_FINISH", + "FLUSH_WAIT_START": "FLUSH_WAIT_START", + "REFR_READY": "REFR_READY", + "REFR_REQUEST": "REFR_REQUEST", + "REFR_START": "REFR_START", + "RENDER_READY": "RENDER_READY", + "RENDER_START": "RENDER_START", + "RESOLUTION_CHANGE": "RESOLUTION_CHANGED", "UPDATE_LAYOUT_COMPLETE": "UPDATE_LAYOUT_COMPLETED", "VSYNC": "VSYNC", "VSYNC_REQUEST": "VSYNC_REQUEST", } LV_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_EVENT_MAP) +LV_DISPLAY_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_DISPLAY_EVENT_MAP) +LV_SCREEN_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_SCREEN_EVENT_MAP) + SWIPE_TRIGGERS = tuple( f"on_swipe_{x.lower()}" for x in DIRECTIONS.choices + ("up", "down") ) diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 54309cdf891..f825999e8a3 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -8,16 +8,21 @@ from esphome.const import ( CONF_X, CONF_Y, ) -from esphome.cpp_generator import new_Pvariable +from esphome.cpp_generator import MockObj, new_Pvariable from esphome.cpp_helpers import register_component +from esphome.cpp_types import nullptr from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, DIRECTIONS, + LV_DISPLAY_EVENT_MAP, + LV_DISPLAY_EVENT_TRIGGERS, LV_EVENT_MAP, LV_EVENT_TRIGGERS, + LV_SCREEN_EVENT_MAP, + LV_SCREEN_EVENT_TRIGGERS, SWIPE_TRIGGERS, literal, ) @@ -30,6 +35,7 @@ from .lvcode import ( lv, lv_add, lv_event_t_ptr, + lv_expr, lvgl_static, ) from .types import LV_EVENT @@ -49,25 +55,24 @@ async def generate_triggers(): Must be done after all widgets completed """ + all_triggers = ( + LV_EVENT_TRIGGERS + LV_DISPLAY_EVENT_TRIGGERS + LV_SCREEN_EVENT_TRIGGERS + ) for w in widget_map.values(): + config = w.config if isinstance(w.type, LvScrActType): w = get_screen_active(w.var) - if w.config: + if config: for event, conf in { - event: conf - for event, conf in w.config.items() - if event in LV_EVENT_TRIGGERS + event: conf for event, conf in config.items() if event in all_triggers }.items(): conf = conf[0] w.add_flag("LV_OBJ_FLAG_CLICKABLE") - event = literal("LV_EVENT_" + LV_EVENT_MAP[event[3:].upper()]) await add_trigger(conf, w, event) for event, conf in { - event: conf - for event, conf in w.config.items() - if event in SWIPE_TRIGGERS + event: conf for event, conf in config.items() if event in SWIPE_TRIGGERS }.items(): conf = conf[0] dir = event[9:].upper() @@ -77,11 +82,9 @@ async def generate_triggers(): selected = literal( f"lv_indev_get_gesture_dir(lv_indev_active()) == {dir}" ) - await add_trigger( - conf, w, literal("LV_EVENT_GESTURE"), is_selected=selected - ) + await add_trigger(conf, w, "GESTURE", is_selected=selected) - for conf in w.config.get(CONF_ON_VALUE, ()): + for conf in config.get(CONF_ON_VALUE, ()): await add_trigger( conf, w, @@ -90,7 +93,7 @@ async def generate_triggers(): UPDATE_EVENT, ) - await add_on_boot_triggers(w.config.get(CONF_ON_BOOT, ())) + await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) async def generate_align_tos(config: dict): @@ -119,6 +122,17 @@ async def generate_align_tos(config: dict): await register_component(var, {}) +TRIGGER_MAP = LV_EVENT_MAP | LV_DISPLAY_EVENT_MAP | LV_SCREEN_EVENT_MAP +DISPLAY_TRIGGERS = set(LV_DISPLAY_EVENT_TRIGGERS) + + +def _get_event_literal(trigger: str | MockObj) -> MockObj: + if isinstance(trigger, MockObj): + return trigger + trigger = trigger.removeprefix("on_") + return literal("LV_EVENT_" + TRIGGER_MAP[trigger.upper()]) + + async def add_trigger(conf, w, *events, is_selected=None): is_selected = is_selected or w.is_selected() tid = conf[CONF_TRIGGER_ID] @@ -129,4 +143,14 @@ async def add_trigger(conf, w, *events, is_selected=None): async with LambdaContext(EVENT_ARG, where=tid) as context: with LvConditional(is_selected): lv_add(trigger.trigger(*value, literal("event"))) - lv_add(lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *events)) + callback = await context.get_lambda() + event_literals = [_get_event_literal(event) for event in events] + if isinstance(events[0], str) and events[0] in DISPLAY_TRIGGERS: + assert len(events) == 1 + lv.display_add_event_cb( + lv_expr.obj_get_display(w.obj), callback, event_literals[0], nullptr + ) + else: + lv_add( + lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *event_literals) + ) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index fae48630b55..ff27dec6df0 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -405,7 +405,7 @@ def _model_config_to_manifest_data(model_config): file = _compute_local_file_path(model_config) / "manifest.json" else: - raise ValueError("Unsupported config type: {model_config[CONF_TYPE]}") + raise ValueError(f"Unsupported config type: {model_config[CONF_TYPE]}") return _load_model_data(file) diff --git a/esphome/components/neopixelbus/_methods.py b/esphome/components/neopixelbus/_methods.py index 9072f78035f..e1c327a2e0b 100644 --- a/esphome/components/neopixelbus/_methods.py +++ b/esphome/components/neopixelbus/_methods.py @@ -344,7 +344,7 @@ def _spi_extra_validate(config): if CORE.is_esp32: return - if config[CONF_DATA_PIN] != 13 and config[CONF_CLOCK_PIN] != 14: + if config[CONF_DATA_PIN] != 13 or config[CONF_CLOCK_PIN] != 14: raise cv.Invalid( "SPI only supports pins GPIO13 for data and GPIO14 for clock on ESP8266" ) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index cdcedc85acc..0a11120bf03 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -185,7 +185,7 @@ def validate_update_interval(value): return value -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(PMSX003Component), @@ -290,7 +290,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + validate_pmsx003_sensors, ) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 26a9e70f7c7..cc1541ce804 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -10,12 +10,14 @@ AUTO_LOAD = ["web_server_base"] prometheus_ns = cg.esphome_ns.namespace("prometheus") PrometheusHandler = prometheus_ns.class_("PrometheusHandler", cg.Component) -CUSTOMIZED_ENTITY = cv.Schema( - { - cv.Optional(CONF_ID): cv.string_strict, - cv.Optional(CONF_NAME): cv.string_strict, - }, - cv.has_at_least_one_key, +CUSTOMIZED_ENTITY = cv.All( + cv.Schema( + { + cv.Optional(CONF_ID): cv.string_strict, + cv.Optional(CONF_NAME): cv.string_strict, + }, + ), + cv.has_at_least_one_key(CONF_ID, CONF_NAME), ) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a0594d7f679..99eda76f81a 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -470,7 +470,7 @@ CANALSATLD_SCHEMA = cv.Schema( ) -@register_binary_sensor("canalsatld", CanalSatLDBinarySensor, CANALSAT_SCHEMA) +@register_binary_sensor("canalsatld", CanalSatLDBinarySensor, CANALSATLD_SCHEMA) def canalsatld_binary_sensor(var, config): cg.add( var.set_data( @@ -1130,7 +1130,7 @@ def sony_dumper(var, config): async def sony_action(var, config, args): template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint32) + template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) cg.add(var.set_nbits(template_)) @@ -1174,7 +1174,7 @@ def symphony_dumper(var, config): async def symphony_action(var, config, args): template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint32) + template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) cg.add(var.set_nbits(template_)) template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) cg.add(var.set_repeats(template_)) @@ -1188,7 +1188,7 @@ def validate_raw_alternating(value): this_negative = val < 0 if i != 0 and this_negative == last_negative: raise cv.Invalid( - f"Values must alternate between being positive and negative, please see index {i} and {i + 1}", + f"Values must alternate between being positive and negative, please see index {i - 1} and {i}", [i], ) last_negative = this_negative @@ -2105,12 +2105,12 @@ async def abbwelcome_action(var, config, args): ) cg.add( var.set_source_address( - await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint16) + await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint32) ) ) cg.add( var.set_destination_address( - await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint16) + await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint32) ) ) cg.add( diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index fc4202556d0..d88657e7152 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -50,7 +50,7 @@ def validate_min_max_value(config): max_val = config[CONF_MAX_VALUE] if min_val >= max_val: raise cv.Invalid( - f"Max value {max_val} must be smaller than min value {min_val}" + f"Max value {max_val} must be greater than min value {min_val}" ) return config diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 9dc695cafc8..fb2beb5b16f 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -272,7 +272,7 @@ SPRINKLER_VALVE_SCHEMA = cv.Schema( ), cv.Optional( CONF_UNIT_OF_MEASUREMENT, default=UNIT_SECOND - ): cv.one_of(UNIT_MINUTE, UNIT_SECOND, lower="True"), + ): cv.one_of(UNIT_MINUTE, UNIT_SECOND, lower=True), } ) .extend(cv.COMPONENT_SCHEMA), diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index c9f41996160..85414237cfe 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -127,7 +127,7 @@ def validate_st7789v(config): if model_data[REQUIRE_PS] and CONF_POWER_SUPPLY not in config: raise cv.Invalid( - f'{CONF_POWER_SUPPLY} must be specified when {CONF_MODEL} is {config[CONF_MODEL]}"' + f"{CONF_POWER_SUPPLY} must be specified when {CONF_MODEL} is {config[CONF_MODEL]}" ) if ( diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 27d4fc276d9..8acacc3b492 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -46,7 +46,7 @@ def validate_acceleration(value): def validate_speed(value): value = cv.string(value) - for suffix in ("steps/s", "steps/s"): + for suffix in ("steps/s",): value = value.removesuffix(suffix) if value == "inf": diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 09f460f46b5..c6b67e9f35b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1667,7 +1667,7 @@ def dimensions(value): match = re.match(r"\s*([0-9]+)\s*[xX]\s*([0-9]+)\s*", value) if not match: raise Invalid( - "Invalid value '{}' for dimensions. Only WIDTHxHEIGHT is allowed." + f"Invalid value '{value}' for dimensions. Only WIDTHxHEIGHT is allowed." ) return dimensions([match.group(1), match.group(2)]) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1732fc72e8b..5940f6ec985 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -22,6 +22,19 @@ namespace esphome { static const char *const TAG = "helpers"; +__attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, + size_t elem_size) { + ESPHOME_DEBUG_ASSERT(size < UINT16_MAX); + uint16_t new_cap = size + 1; + auto *new_data = ::operator new(new_cap *elem_size); + if (data) { + __builtin_memcpy(new_data, data, size * elem_size); + ::operator delete(data); + } + capacity = new_cap; + return new_data; +} + static const uint16_t CRC16_A001_LE_LUT_L[] = {0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 0xc601, 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440}; static const uint16_t CRC16_A001_LE_LUT_H[] = {0x0000, 0xcc01, 0xd801, 0x1400, 0xf001, 0x3c00, 0x2800, 0xe401, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f96b888e287..c26bbe17b75 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1801,33 +1801,77 @@ template struct Callback { } }; +/// Grow a CallbackManager's backing array to exactly size+1. Defined in helpers.cpp. +void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, size_t elem_size); + template class CallbackManager; /** Helper class to allow having multiple subscribers to a callback. + * + * Uses a trivial-copyable-specialized container instead of std::vector to avoid + * template bloat (_M_realloc_insert, exception-safe copies). Since Callback is + * trivially copyable (just {fn_ptr, ctx_ptr}), reallocation is a plain memcpy. + * Uses uint16_t for size/capacity (8 bytes on 32-bit vs 12 for std::vector). + * Grows to exact size on each add — callbacks are registered during setup() + * and most instances have only 1-2 callbacks, so slack capacity is wasteful. * * @tparam Ts The arguments for the callbacks, wrapped in void(). */ template class CallbackManager { + using CbType = Callback; + static_assert(std::is_trivially_copyable_v, "Callback must be trivially copyable"); + public: + CallbackManager() = default; + ~CallbackManager() { ::operator delete(this->data_); } + + // Non-copyable (would alias data_), movable (for std::map support) + CallbackManager(const CallbackManager &) = delete; + CallbackManager &operator=(const CallbackManager &) = delete; + CallbackManager(CallbackManager &&other) noexcept + : data_(other.data_), size_(other.size_), capacity_(other.capacity_) { + other.data_ = nullptr; + other.size_ = 0; + other.capacity_ = 0; + } + CallbackManager &operator=(CallbackManager &&other) noexcept { + std::swap(this->data_, other.data_); + std::swap(this->size_, other.size_); + std::swap(this->capacity_, other.capacity_); + return *this; + } + /// Add any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline without heap allocation or std::function. - template void add(F &&callback) { this->add_(Callback::create(std::forward(callback))); } - - /// Call all callbacks in this manager. No null check on invoke. - void call(Ts... args) { - for (auto &cb : this->callbacks_) - cb.call(args...); - } - size_t size() const { return this->callbacks_.size(); } + template void add(F &&callback) { this->add_(CbType::create(std::forward(callback))); } /// Call all callbacks in this manager. - void operator()(Ts... args) { call(args...); } + inline void ESPHOME_ALWAYS_INLINE call(Ts... args) { + if (this->size_ != 0) { + for (auto *it = this->data_, *end = it + this->size_; it != end; ++it) { + it->call(args...); + } + } + } + uint16_t size() const { return this->size_; } + + /// Call all callbacks in this manager. + void operator()(Ts... args) { this->call(args...); } protected: template friend class LazyCallbackManager; /// Non-template core to avoid code duplication per lambda type. - void add_(Callback cb) { this->callbacks_.push_back(cb); } - std::vector> callbacks_; + /// Inline fast path; cold growth path is in helpers.cpp via callback_manager_grow(). + void add_(CbType cb) { + if (this->size_ == this->capacity_) { + this->data_ = + static_cast(callback_manager_grow(this->data_, this->size_, this->capacity_, sizeof(CbType))); + } + this->data_[this->size_++] = cb; + } + CbType *data_{nullptr}; + uint16_t size_{0}; + uint16_t capacity_{0}; }; /** CallbackManager backed by StaticVector for compile-time-known callback counts. @@ -1871,7 +1915,7 @@ template class LazyCallbackManager; * from API and web_server components). * * Memory overhead comparison (32-bit systems): - * - CallbackManager: 12 bytes (empty std::vector) + * - CallbackManager: 8 bytes (pointer + uint16 size + uint16 capacity) * - LazyCallbackManager: 4 bytes (nullptr pointer) * * Uses plain pointer instead of unique_ptr to avoid template instantiation overhead. diff --git a/esphome/espota2.py b/esphome/espota2.py index 4b813e4060c..39f51e02e93 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -130,7 +130,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None :param expect: Expected response code(s), None to skip validation. :raises OTAError: If an error code is detected or response doesn't match expected. """ - if not expect: + if expect is None: return if not data: raise OTAError( @@ -278,7 +278,7 @@ def perform_ota( raise OTAError("ESP requests password, but no password given!") nonce_bytes = receive_exactly( - sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False + sock, nonce_size, f"{hash_name} authentication nonce", None, decode=False ) assert isinstance(nonce_bytes, bytes) nonce = nonce_bytes.decode() diff --git a/esphome/external_files.py b/esphome/external_files.py index 72a3f33fdc8..18b68fba088 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime import logging from pathlib import Path @@ -27,8 +27,8 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: local_modification_time = local_file_path.stat().st_mtime - local_modification_time_str = datetime.utcfromtimestamp( - local_modification_time + local_modification_time_str = datetime.fromtimestamp( + local_modification_time, tz=UTC ).strftime("%a, %d %b %Y %H:%M:%S GMT") headers = { diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index dc4ca77eb45..dd45b58a6cb 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -25,7 +25,7 @@ _BACKGROUND_TASKS: set[asyncio.Task] = set() class DashboardStatus: - def __init__(self, on_update: Callable[[dict[str, bool | None], []]]) -> None: + def __init__(self, on_update: Callable[[dict[str, bool | None]], None]) -> None: """Initialize the dashboard status.""" self.on_update = on_update diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index b724af54e05..bdc478ea036 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -29,6 +29,8 @@ espnow: data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' - espnow.broadcast: data: "Hello, World!" + - espnow.broadcast: + data: "it's a test" - espnow.broadcast: data: [0x01, 0x02, 0x03, 0x04, 0x05] - espnow.broadcast: diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4d44c62000c..967fe515927 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -49,6 +49,44 @@ lvgl: id: meter_arc_indicator start_value: 0 end_value: 180 + on_invalidate_area: + logger.log: Invalidate area + on_resolution_change: + logger.log: Resolution changed + on_color_format_change: + logger.log: Color format changed + on_refr_request: + logger.log: Refresh request + on_refr_start: + logger.log: Refresh start + on_refr_ready: + logger.log: Refresh ready + on_render_start: + logger.log: Render start + on_render_ready: + logger.log: Render ready + on_flush_start: + logger.log: Flush start + on_flush_finish: + logger.log: Flush finish + on_flush_wait_start: + logger.log: Flush wait start + on_flush_wait_finish: + logger.log: Flush wait finish + on_update_layout_complete: + logger.log: Update layout complete + on_vsync: + logger.log: Vsync + on_vsync_request: + logger.log: Vsync request + on_screen_load_start: + logger.log: Screen load start + on_screen_load: + logger.log: Screen loaded + on_screen_unload: + logger.log: Screen unloaded + on_screen_unload_start: + logger.log: Screen unload start bg_color: light_blue bottom_layer: widgets: @@ -660,14 +698,6 @@ lvgl: logger.log: Child created on_child_delete: logger.log: Child deleted - on_screen_unload_start: - logger.log: Screen unload start - on_screen_load_start: - logger.log: Screen load start - on_screen_load: - logger.log: Screen loaded - on_screen_unload: - logger.log: Screen unloaded on_size_change: logger.log: Size changed on_style_change: @@ -676,36 +706,6 @@ lvgl: logger.log: Layout changed on_get_self_size: logger.log: Get self size - on_invalidate_area: - logger.log: Invalidate area - on_resolution_change: - logger.log: Resolution changed - on_color_format_change: - logger.log: Color format changed - on_refr_request: - logger.log: Refresh request - on_refr_start: - logger.log: Refresh start - on_refr_ready: - logger.log: Refresh ready - on_render_start: - logger.log: Render start - on_render_ready: - logger.log: Render ready - on_flush_start: - logger.log: Flush start - on_flush_finish: - logger.log: Flush finish - on_flush_wait_start: - logger.log: Flush wait start - on_flush_wait_finish: - logger.log: Flush wait finish - on_update_layout_complete: - logger.log: Update layout complete - on_vsync: - logger.log: Vsync - on_vsync_request: - logger.log: Vsync request - led: id: lv_led color: 0x00FF00