diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 3ef5e6bfff..39dbeb225f 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -36,6 +36,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -51,6 +52,20 @@ CONF_POWER_GAIN = "power_gain" CONF_NEUTRAL = "neutral" +# Tuple of power channel phases +POWER_PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + +# Tuple of sensor types that can be configured for power channels +POWER_SENSOR_TYPES = ( + CONF_CURRENT, + CONF_VOLTAGE, + CONF_ACTIVE_POWER, + CONF_APPARENT_POWER, + CONF_POWER_FACTOR, + CONF_FORWARD_ACTIVE_ENERGY, + CONF_REVERSE_ACTIVE_ENERGY, +) + NEUTRAL_CHANNEL_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(NeutralChannel), @@ -150,7 +165,64 @@ POWER_CHANNEL_SCHEMA = cv.Schema( } ) -CONFIG_SCHEMA = ( + +def prefix_sensor_name( + sensor_conf: ConfigType, + channel_name: str, + channel_config: ConfigType, + sensor_type: str, +) -> None: + """Helper to prefix sensor name with channel name. + + Args: + sensor_conf: The sensor configuration (dict or string) + channel_name: The channel name to prefix with + channel_config: The channel configuration to update + sensor_type: The sensor type key in the channel config + """ + if isinstance(sensor_conf, dict) and CONF_NAME in sensor_conf: + sensor_name = sensor_conf[CONF_NAME] + if sensor_name and not sensor_name.startswith(channel_name): + sensor_conf[CONF_NAME] = f"{channel_name} {sensor_name}" + elif isinstance(sensor_conf, str): + # Simple value case - convert to dict with prefixed name + channel_config[sensor_type] = {CONF_NAME: f"{channel_name} {sensor_conf}"} + + +def process_channel_sensors( + config: ConfigType, channel_key: str, sensor_types: tuple +) -> None: + """Process sensors for a channel and prefix their names. + + Args: + config: The main configuration + channel_key: The channel key (e.g., CONF_PHASE_A, CONF_NEUTRAL) + sensor_types: Tuple of sensor types to process for this channel + """ + if not (channel_config := config.get(channel_key)) or not ( + channel_name := channel_config.get(CONF_NAME) + ): + return + + for sensor_type in sensor_types: + if sensor_conf := channel_config.get(sensor_type): + prefix_sensor_name(sensor_conf, channel_name, channel_config, sensor_type) + + +def preprocess_channels(config: ConfigType) -> ConfigType: + """Preprocess channel configurations to add channel name prefix to sensor names.""" + # Process power channels + for channel in POWER_PHASES: + process_channel_sensors(config, channel, POWER_SENSOR_TYPES) + + # Process neutral channel + process_channel_sensors(config, CONF_NEUTRAL, (CONF_CURRENT,)) + + return config + + +CONFIG_SCHEMA = cv.All( + preprocess_channels, cv.Schema( { cv.GenerateID(): cv.declare_id(ADE7880), @@ -167,7 +239,7 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x38)) + .extend(i2c.i2c_device_schema(0x38)), ) @@ -188,15 +260,7 @@ async def neutral_channel(config): async def power_channel(config): var = cg.new_Pvariable(config[CONF_ID]) - for sensor_type in [ - CONF_CURRENT, - CONF_VOLTAGE, - CONF_ACTIVE_POWER, - CONF_APPARENT_POWER, - CONF_POWER_FACTOR, - CONF_FORWARD_ACTIVE_ENERGY, - CONF_REVERSE_ACTIVE_ENERGY, - ]: + for sensor_type in POWER_SENSOR_TYPES: if conf := config.get(sensor_type): sens = await sensor.new_sensor(conf) cg.add(getattr(var, f"set_{sensor_type}")(sens)) @@ -216,44 +280,6 @@ async def power_channel(config): return var -def final_validate(config): - for channel in [CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]: - if channel := config.get(channel): - channel_name = channel.get(CONF_NAME) - - for sensor_type in [ - CONF_CURRENT, - CONF_VOLTAGE, - CONF_ACTIVE_POWER, - CONF_APPARENT_POWER, - CONF_POWER_FACTOR, - CONF_FORWARD_ACTIVE_ENERGY, - CONF_REVERSE_ACTIVE_ENERGY, - ]: - if conf := channel.get(sensor_type): - sensor_name = conf.get(CONF_NAME) - if ( - sensor_name - and channel_name - and not sensor_name.startswith(channel_name) - ): - conf[CONF_NAME] = f"{channel_name} {sensor_name}" - - if channel := config.get(CONF_NEUTRAL): - channel_name = channel.get(CONF_NAME) - if conf := channel.get(CONF_CURRENT): - sensor_name = conf.get(CONF_NAME) - if ( - sensor_name - and channel_name - and not sensor_name.startswith(channel_name) - ): - conf[CONF_NAME] = f"{channel_name} {sensor_name}" - - -FINAL_VALIDATE_SCHEMA = final_validate - - async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5e97c81044..0455d136df 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -101,6 +101,38 @@ void ESP32BLETracker::loop() { this->start_scan(); } } + + // Check for scan timeout - moved here from scheduler to avoid false reboots + // when the loop is blocked + if (this->scanner_state_ == ScannerState::RUNNING) { + switch (this->scan_timeout_state_) { + case ScanTimeoutState::MONITORING: { + uint32_t now = App.get_loop_component_start_time(); + uint32_t timeout_ms = this->scan_duration_ * 2000; + // Robust time comparison that handles rollover correctly + // This works because unsigned arithmetic wraps around predictably + if ((now - this->scan_start_time_) > timeout_ms) { + // First time we've seen the timeout exceeded - wait one more loop iteration + // This ensures all components have had a chance to process pending events + // This is because esp32_ble may not have run yet and called + // gap_scan_event_handler yet when the loop unblocks + ESP_LOGW(TAG, "Scan timeout exceeded"); + this->scan_timeout_state_ = ScanTimeoutState::EXCEEDED_WAIT; + } + break; + } + case ScanTimeoutState::EXCEEDED_WAIT: + // We've waited at least one full loop iteration, and scan is still running + ESP_LOGE(TAG, "Scan never terminated, rebooting"); + App.reboot(); + break; + + case ScanTimeoutState::INACTIVE: + // This case should be unreachable - scanner and timeout states are always synchronized + break; + } + } + ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; @@ -164,7 +196,8 @@ void ESP32BLETracker::stop_scan_() { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); return; } - this->cancel_timeout("scan"); + // Reset timeout state machine when stopping scan + this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; this->set_scanner_state_(ScannerState::STOPPING); esp_err_t err = esp_ble_gap_stop_scanning(); if (err != ESP_OK) { @@ -197,11 +230,10 @@ void ESP32BLETracker::start_scan_(bool first) { this->scan_params_.scan_interval = this->scan_interval_; this->scan_params_.scan_window = this->scan_window_; - // Start timeout before scan is started. Otherwise scan never starts if any error. - this->set_timeout("scan", this->scan_duration_ * 2000, []() { - ESP_LOGE(TAG, "Scan never terminated, rebooting to restore stack (IDF)"); - App.reboot(); - }); + // Start timeout monitoring in loop() instead of using scheduler + // This prevents false reboots when the loop is blocked + this->scan_start_time_ = App.get_loop_component_start_time(); + this->scan_timeout_state_ = ScanTimeoutState::MONITORING; esp_err_t err = esp_ble_gap_set_scan_params(&this->scan_params_); if (err != ESP_OK) { @@ -752,7 +784,8 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { #ifdef USE_ESP32_BLE_DEVICE this->already_discovered_.clear(); #endif - this->cancel_timeout("scan"); + // Reset timeout state machine instead of cancelling scheduler timeout + this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; for (auto *listener : this->listeners_) listener->on_scan_end(); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 4b09d521b6..bf99026810 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -367,6 +367,14 @@ class ESP32BLETracker : public Component, #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; #endif + // Scan timeout state machine + enum class ScanTimeoutState : uint8_t { + INACTIVE, // No timeout monitoring + MONITORING, // Actively monitoring for timeout + EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot + }; + uint32_t scan_start_time_{0}; + ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE}; }; // NOLINTNEXTLINE diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 938bf88930..c1d50790a2 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -20,6 +20,8 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr u_int16_t OTA_BLOCK_SIZE = 8192; +static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK @@ -83,9 +85,10 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Skip handle_() call if no client connected and no incoming connections + // Skip handle_handshake_() call if no client connected and no incoming connections // This optimization reduces idle loop overhead when OTA is not active - // Note: No need to check server_ for null as the component is marked failed in setup() if server_ creation fails + // Note: No need to check server_ for null as the component is marked failed in setup() + // if server_ creation fails if (this->client_ != nullptr || this->server_->ready()) { this->handle_handshake_(); } @@ -134,25 +137,31 @@ void ESPHomeOTAComponent::handle_handshake_() { // Try to read first byte of magic bytes uint8_t first_byte; ssize_t read = this->client_->read(&first_byte, 1); - if (read == 1) { - // Got the first byte, check if it's the magic byte - if (first_byte != 0x6C) { - ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); - this->cleanup_connection_(); - return; - } - // First byte is valid, continue with data handling - this->handle_data_(); - } else if (read == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - this->log_socket_error_("reading first byte"); - this->cleanup_connection_(); - } - // For EAGAIN/EWOULDBLOCK, just return and try again next loop - } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed connection during handshake"); - this->cleanup_connection_(); + + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop } + + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_("reading first byte"); + } else { + ESP_LOGW(TAG, "Remote closed during handshake"); + } + this->cleanup_connection_(); + return; + } + + // Got first byte, check if it's the magic byte + if (first_byte != 0x6C) { + ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); + this->cleanup_connection_(); + return; + } + + // First byte is valid, continue with data handling + this->handle_data_(); } void ESPHomeOTAComponent::handle_data_() { @@ -173,7 +182,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) if (!this->readall_(buf, 4)) { - ESP_LOGW(TAG, "Read magic bytes failed"); + this->log_read_error_("magic bytes"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 @@ -192,7 +201,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - ESP_LOGW(TAG, "Read features failed"); + this->log_read_error_("features"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT @@ -271,7 +280,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { - ESP_LOGW(TAG, "Read size failed"); + this->log_read_error_("size"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = 0; @@ -303,7 +312,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { - ESP_LOGW(TAG, "Read MD5 checksum failed"); + this->log_read_error_("MD5 checksum"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -378,7 +387,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Read ack failed"); + this->log_read_error_("ack"); // do not go to error, this is not fatal } @@ -407,12 +416,12 @@ error: #endif } -bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint32_t timeout) { +bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > timeout) { + if (now - start > OTA_SOCKET_TIMEOUT_DATA) { ESP_LOGW(TAG, "Timeout reading %d bytes", len); return false; } @@ -438,12 +447,12 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint32_t timeout) { return true; } -bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len, uint32_t timeout) { +bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > timeout) { + if (now - start > OTA_SOCKET_TIMEOUT_DATA) { ESP_LOGW(TAG, "Timeout writing %d bytes", len); return false; } @@ -472,6 +481,8 @@ void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } void ESPHomeOTAComponent::log_socket_error_(const char *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", msg, errno); } +void ESPHomeOTAComponent::log_read_error_(const char *what) { ESP_LOGW(TAG, "Read %s failed", what); } + void ESPHomeOTAComponent::log_start_(const char *phase) { ESP_LOGD(TAG, "Starting %s from %s", phase, this->client_->getpeername().c_str()); } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index d20d25d8c6..0059dfd0d9 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -9,9 +9,6 @@ namespace esphome { -static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake -static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer - /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { public: @@ -32,9 +29,10 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); - bool readall_(uint8_t *buf, size_t len, uint32_t timeout = OTA_SOCKET_TIMEOUT_DATA); - bool writeall_(const uint8_t *buf, size_t len, uint32_t timeout = OTA_SOCKET_TIMEOUT_DATA); + bool readall_(uint8_t *buf, size_t len); + bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const char *msg); + void log_read_error_(const char *what); void log_start_(const char *phase); void cleanup_connection_(); @@ -43,10 +41,10 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #endif // USE_OTA_PASSWORD uint16_t port_; + uint32_t client_connect_time_{0}; std::unique_ptr server_; std::unique_ptr client_; - uint32_t client_connect_time_{0}; }; } // namespace esphome diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 5a1b99cf7c..d345ac70f3 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -287,10 +287,14 @@ def angle(value): :param value: The input in the range 0..360 :return: An angle in 1/10 degree units. """ - return int(cv.float_range(0.0, 360.0)(cv.angle(value)) * 10) + return cv.float_range(0.0, 360.0)(cv.angle(value)) -lv_angle = LValidator(angle, uint32) +# Validator for angles in LVGL expressed in 1/10 degree units. +lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10)) + +# Validator for angles in LVGL expressed in whole degrees +lv_angle_degrees = LValidator(angle, uint32, retmapper=int) @schema_extractor("one_of") diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 32930ddec4..7a32691b53 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -451,7 +451,8 @@ void LvglComponent::setup() { if (buffer == nullptr && this->buffer_frac_ == 0) { frac = MIN_BUFFER_FRAC; buffer_pixels /= MIN_BUFFER_FRAC; - buffer = lv_custom_mem_alloc(buf_bytes / MIN_BUFFER_FRAC); // NOLINT + buf_bytes /= MIN_BUFFER_FRAC; + buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT } if (buffer == nullptr) { this->status_set_error("Memory allocation failure"); diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 10b6f63528..c19c89401a 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -161,7 +161,7 @@ class WidgetType: """ return [] - def obj_creator(self, parent: MockObjClass, config: dict): + async def obj_creator(self, parent: MockObjClass, config: dict): """ Create an instance of the widget type :param parent: The parent to which it should be attached diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d12464fe71..bb6155234c 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -439,7 +439,7 @@ async def widget_to_code(w_cnfig, w_type: WidgetType, parent): :return: """ spec: WidgetType = WIDGET_TYPES[w_type] - creator = spec.obj_creator(parent, w_cnfig) + creator = await spec.obj_creator(parent, w_cnfig) add_lv_use(spec.name) add_lv_use(*spec.get_uses()) wid = w_cnfig[CONF_ID] diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 65f0e785b6..ef4da0d815 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -20,7 +20,7 @@ from ..defines import ( CONF_START_ANGLE, literal, ) -from ..lv_validation import angle, get_start_value, lv_float +from ..lv_validation import get_start_value, lv_angle_degrees, lv_float, lv_int from ..lvcode import lv, lv_expr, lv_obj from ..types import LvNumber, NumberType from . import Widget @@ -29,11 +29,11 @@ CONF_ARC = "arc" ARC_SCHEMA = cv.Schema( { cv.Optional(CONF_VALUE): lv_float, - cv.Optional(CONF_MIN_VALUE, default=0): cv.int_, - cv.Optional(CONF_MAX_VALUE, default=100): cv.int_, - cv.Optional(CONF_START_ANGLE, default=135): angle, - cv.Optional(CONF_END_ANGLE, default=45): angle, - cv.Optional(CONF_ROTATION, default=0.0): angle, + cv.Optional(CONF_MIN_VALUE, default=0): lv_int, + cv.Optional(CONF_MAX_VALUE, default=100): lv_int, + cv.Optional(CONF_START_ANGLE, default=135): lv_angle_degrees, + cv.Optional(CONF_END_ANGLE, default=45): lv_angle_degrees, + cv.Optional(CONF_ROTATION, default=0.0): lv_angle_degrees, cv.Optional(CONF_ADJUSTABLE, default=False): bool, cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of, cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t, @@ -59,11 +59,14 @@ class ArcType(NumberType): async def to_code(self, w: Widget, config): if CONF_MIN_VALUE in config: - lv.arc_set_range(w.obj, config[CONF_MIN_VALUE], config[CONF_MAX_VALUE]) - lv.arc_set_bg_angles( - w.obj, config[CONF_START_ANGLE] // 10, config[CONF_END_ANGLE] // 10 - ) - lv.arc_set_rotation(w.obj, config[CONF_ROTATION] // 10) + max_value = await lv_int.process(config[CONF_MAX_VALUE]) + min_value = await lv_int.process(config[CONF_MIN_VALUE]) + lv.arc_set_range(w.obj, min_value, max_value) + start = await lv_angle_degrees.process(config[CONF_START_ANGLE]) + end = await lv_angle_degrees.process(config[CONF_END_ANGLE]) + rotation = await lv_angle_degrees.process(config[CONF_ROTATION]) + lv.arc_set_bg_angles(w.obj, start, end) + lv.arc_set_rotation(w.obj, rotation) lv.arc_set_mode(w.obj, literal(config[CONF_MODE])) lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE]) diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index 7d8d13d8c4..028a81b449 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -4,7 +4,7 @@ from esphome.const import CONF_SIZE, CONF_TEXT from esphome.cpp_generator import MockObjClass from ..defines import CONF_MAIN -from ..lv_validation import color, color_retmapper, lv_text +from ..lv_validation import lv_color, lv_text from ..lvcode import LocalVariable, lv, lv_expr from ..schemas import TEXT_SCHEMA from ..types import WidgetType, lv_obj_t @@ -16,8 +16,8 @@ CONF_LIGHT_COLOR = "light_color" QRCODE_SCHEMA = TEXT_SCHEMA.extend( { - cv.Optional(CONF_DARK_COLOR, default="black"): color, - cv.Optional(CONF_LIGHT_COLOR, default="white"): color, + cv.Optional(CONF_DARK_COLOR, default="black"): lv_color, + cv.Optional(CONF_LIGHT_COLOR, default="white"): lv_color, cv.Required(CONF_SIZE): cv.int_, } ) @@ -34,11 +34,11 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return ("canvas", "img", "label") + return "canvas", "img", "label" - def obj_creator(self, parent: MockObjClass, config: dict): - dark_color = color_retmapper(config[CONF_DARK_COLOR]) - light_color = color_retmapper(config[CONF_LIGHT_COLOR]) + async def obj_creator(self, parent: MockObjClass, config: dict): + dark_color = await lv_color.process(config[CONF_DARK_COLOR]) + light_color = await lv_color.process(config[CONF_LIGHT_COLOR]) size = config[CONF_SIZE] return lv_expr.call("qrcode_create", parent, size, dark_color, light_color) diff --git a/esphome/components/lvgl/widgets/spinner.py b/esphome/components/lvgl/widgets/spinner.py index 2940feb594..83aac25a59 100644 --- a/esphome/components/lvgl/widgets/spinner.py +++ b/esphome/components/lvgl/widgets/spinner.py @@ -2,7 +2,7 @@ import esphome.config_validation as cv from esphome.cpp_generator import MockObjClass from ..defines import CONF_ARC_LENGTH, CONF_INDICATOR, CONF_MAIN, CONF_SPIN_TIME -from ..lv_validation import angle +from ..lv_validation import lv_angle_degrees, lv_milliseconds from ..lvcode import lv_expr from ..types import LvType from . import Widget, WidgetType @@ -12,8 +12,8 @@ CONF_SPINNER = "spinner" SPINNER_SCHEMA = cv.Schema( { - cv.Required(CONF_ARC_LENGTH): angle, - cv.Required(CONF_SPIN_TIME): cv.positive_time_period_milliseconds, + cv.Required(CONF_ARC_LENGTH): lv_angle_degrees, + cv.Required(CONF_SPIN_TIME): lv_milliseconds, } ) @@ -34,9 +34,9 @@ class SpinnerType(WidgetType): def get_uses(self): return (CONF_ARC,) - def obj_creator(self, parent: MockObjClass, config: dict): - spin_time = config[CONF_SPIN_TIME].total_milliseconds - arc_length = config[CONF_ARC_LENGTH] // 10 + async def obj_creator(self, parent: MockObjClass, config: dict): + spin_time = await lv_milliseconds.process(config[CONF_SPIN_TIME]) + arc_length = await lv_angle_degrees.process(config[CONF_ARC_LENGTH]) return lv_expr.call("spinner_create", parent, spin_time, arc_length) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 42cf486e1c..e8931bab7c 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -87,12 +87,12 @@ class TabviewType(WidgetType): ) as content_obj: await set_obj_properties(Widget(content_obj, obj_spec), content_style) - def obj_creator(self, parent: MockObjClass, config: dict): + async def obj_creator(self, parent: MockObjClass, config: dict): return lv_expr.call( "tabview_create", parent, - literal(config[CONF_POSITION]), - literal(config[CONF_SIZE]), + await DIRECTIONS.process(config[CONF_POSITION]), + await size.process(config[CONF_SIZE]), ) diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index c63790e60b..0c9604e932 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -225,6 +225,9 @@ async def to_code(config): # https://github.com/Makuna/NeoPixelBus/blob/master/library.json # Version Listed Here: https://registry.platformio.org/libraries/makuna/NeoPixelBus/versions if CORE.is_esp32: + # disable built in rgb support as it uses the new RMT drivers and will + # conflict with NeoPixelBus which uses the legacy drivers + cg.add_build_flag("-DESP32_ARDUINO_NO_RGB_BUILTIN") cg.add_library("makuna/NeoPixelBus", "2.8.0") else: cg.add_library("makuna/NeoPixelBus", "2.7.3") diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 4668a1458c..f495dbc0b4 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_ID, CONF_INVERTED, CONF_MQTT_ID, + CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, CONF_RESTORE_MODE, @@ -56,6 +57,9 @@ TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) SwitchCondition = switch_ns.class_("SwitchCondition", Condition) +SwitchStateTrigger = switch_ns.class_( + "SwitchStateTrigger", automation.Trigger.template(bool) +) SwitchTurnOnTrigger = switch_ns.class_( "SwitchTurnOnTrigger", automation.Trigger.template() ) @@ -77,6 +81,11 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( RESTORE_MODES, upper=True, space="_" ), + cv.Optional(CONF_ON_STATE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchStateTrigger), + } + ), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchTurnOnTrigger), @@ -140,6 +149,9 @@ async def setup_switch_core_(var, config): if (inverted := config.get(CONF_INVERTED)) is not None: cg.add(var.set_inverted(inverted)) + for conf in config.get(CONF_ON_STATE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(bool, "x")], conf) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index 66818a80be..b8cbc9b976 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -64,6 +64,13 @@ template class SwitchCondition : public Condition { bool state_; }; +class SwitchStateTrigger : public Trigger { + public: + SwitchStateTrigger(Switch *a_switch) { + a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); + } +}; + class SwitchTurnOnTrigger : public Trigger<> { public: SwitchTurnOnTrigger(Switch *a_switch) { diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 450cdd4337..6bece732fc 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -552,7 +552,8 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { void parse_string_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(const std::string &)) { if (request->hasParam(param_name)) { - std::string value = request->getParam(param_name)->value(); + // .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string + std::string value = request->getParam(param_name)->value().c_str(); // NOLINT(readability-redundant-string-cstr) (call.*setter)(value); } } diff --git a/script/ci-custom.py b/script/ci-custom.py index 6f3c513f42..61081608d5 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -500,7 +500,8 @@ def lint_constants_usage(): continue errs.append( f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " - f"constant to const.py (Uses: {', '.join(uses)})" + f"constant to const.py (Uses: {', '.join(uses)}) in a separate PR. " + "See https://developers.esphome.io/contributing/code/#python" ) return errs diff --git a/tests/components/ade7880/common.yaml b/tests/components/ade7880/common.yaml index 48c22c8485..0aa388a325 100644 --- a/tests/components/ade7880/common.yaml +++ b/tests/components/ade7880/common.yaml @@ -12,12 +12,12 @@ sensor: frequency: 60Hz phase_a: name: Channel A - voltage: Channel A Voltage - current: Channel A Current - active_power: Channel A Active Power - power_factor: Channel A Power Factor - forward_active_energy: Channel A Forward Active Energy - reverse_active_energy: Channel A Reverse Active Energy + voltage: Voltage + current: Current + active_power: Active Power + power_factor: Power Factor + forward_active_energy: Forward Active Energy + reverse_active_energy: Reverse Active Energy calibration: current_gain: 3116628 voltage_gain: -757178 @@ -25,12 +25,12 @@ sensor: phase_angle: 188 phase_b: name: Channel B - voltage: Channel B Voltage - current: Channel B Current - active_power: Channel B Active Power - power_factor: Channel B Power Factor - forward_active_energy: Channel B Forward Active Energy - reverse_active_energy: Channel B Reverse Active Energy + voltage: Voltage + current: Current + active_power: Active Power + power_factor: Power Factor + forward_active_energy: Forward Active Energy + reverse_active_energy: Reverse Active Energy calibration: current_gain: 3133655 voltage_gain: -755235 @@ -38,12 +38,12 @@ sensor: phase_angle: 188 phase_c: name: Channel C - voltage: Channel C Voltage - current: Channel C Current - active_power: Channel C Active Power - power_factor: Channel C Power Factor - forward_active_energy: Channel C Forward Active Energy - reverse_active_energy: Channel C Reverse Active Energy + voltage: Voltage + current: Current + active_power: Active Power + power_factor: Power Factor + forward_active_energy: Forward Active Energy + reverse_active_energy: Reverse Active Energy calibration: current_gain: 3111158 voltage_gain: -743813 @@ -51,6 +51,6 @@ sensor: phase_angle: 180 neutral: name: Neutral - current: Neutral Current + current: Current calibration: current_gain: 3189 diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 7cd2e2b93e..feee96672c 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -723,6 +723,20 @@ lvgl: arc_color: 0xFFFF00 focused: arc_color: 0x808080 + - arc: + align: center + id: lv_arc_1 + value: !lambda return 75; + min_value: !lambda return 50; + max_value: !lambda return 60; + arc_color: 0xFF0000 + indicator: + arc_width: !lambda return 20; + arc_color: 0xF000FF + pressed: + arc_color: 0xFFFF00 + focused: + arc_color: 0x808080 - bar: id: bar_id align: top_mid diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index b69e36a1c0..afdf26c150 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -9,6 +9,18 @@ switch: name: "Template Switch" id: the_switch optimistic: true + on_state: + - if: + condition: + - lambda: return x; + then: + - logger.log: "Switch turned ON" + else: + - logger.log: "Switch turned OFF" + on_turn_on: + - logger.log: "Switch is now ON" + on_turn_off: + - logger.log: "Switch is now OFF" esphome: on_boot: