1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-06 13:22:19 +01:00

Merge remote-tracking branch 'upstream/dev' into ota_fixes

This commit is contained in:
J. Nick Koston
2025-08-10 18:04:22 -05:00
36 changed files with 412 additions and 287 deletions

View File

@@ -90,7 +90,7 @@ def main():
def run_command(*cmd, ignore_error: bool = False): def run_command(*cmd, ignore_error: bool = False):
print(f"$ {shlex.join(list(cmd))}") print(f"$ {shlex.join(list(cmd))}")
if not args.dry_run: if not args.dry_run:
rc = subprocess.call(list(cmd)) rc = subprocess.call(list(cmd), close_fds=False)
if rc != 0 and not ignore_error: if rc != 0 and not ignore_error:
print("Command failed") print("Command failed")
sys.exit(1) sys.exit(1)

View File

@@ -36,6 +36,7 @@ from esphome.const import (
UNIT_WATT, UNIT_WATT,
UNIT_WATT_HOURS, UNIT_WATT_HOURS,
) )
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"] DEPENDENCIES = ["i2c"]
@@ -51,6 +52,20 @@ CONF_POWER_GAIN = "power_gain"
CONF_NEUTRAL = "neutral" 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( NEUTRAL_CHANNEL_SCHEMA = cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(NeutralChannel), 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.Schema(
{ {
cv.GenerateID(): cv.declare_id(ADE7880), cv.GenerateID(): cv.declare_id(ADE7880),
@@ -167,7 +239,7 @@ CONFIG_SCHEMA = (
} }
) )
.extend(cv.polling_component_schema("60s")) .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): async def power_channel(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
for sensor_type in [ for sensor_type in POWER_SENSOR_TYPES:
CONF_CURRENT,
CONF_VOLTAGE,
CONF_ACTIVE_POWER,
CONF_APPARENT_POWER,
CONF_POWER_FACTOR,
CONF_FORWARD_ACTIVE_ENERGY,
CONF_REVERSE_ACTIVE_ENERGY,
]:
if conf := config.get(sensor_type): if conf := config.get(sensor_type):
sens = await sensor.new_sensor(conf) sens = await sensor.new_sensor(conf)
cg.add(getattr(var, f"set_{sensor_type}")(sens)) cg.add(getattr(var, f"set_{sensor_type}")(sens))
@@ -216,44 +280,6 @@ async def power_channel(config):
return var 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): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)

View File

@@ -15,6 +15,7 @@ from esphome.const import (
CONF_FRAMEWORK, CONF_FRAMEWORK,
CONF_IGNORE_EFUSE_CUSTOM_MAC, CONF_IGNORE_EFUSE_CUSTOM_MAC,
CONF_IGNORE_EFUSE_MAC_CRC, CONF_IGNORE_EFUSE_MAC_CRC,
CONF_LOG_LEVEL,
CONF_NAME, CONF_NAME,
CONF_PATH, CONF_PATH,
CONF_PLATFORM_VERSION, CONF_PLATFORM_VERSION,
@@ -79,6 +80,15 @@ CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_RELEASE = "release" CONF_RELEASE = "release"
LOG_LEVELS_IDF = [
"NONE",
"ERROR",
"WARN",
"INFO",
"DEBUG",
"VERBOSE",
]
ASSERTION_LEVELS = { ASSERTION_LEVELS = {
"DISABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE", "DISABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE",
"ENABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE", "ENABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE",
@@ -623,6 +633,9 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All(
cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): {
cv.string_strict: cv.string_strict cv.string_strict: cv.string_strict
}, },
cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of(
*LOG_LEVELS_IDF, upper=True
),
cv.Optional(CONF_ADVANCED, default={}): cv.Schema( cv.Optional(CONF_ADVANCED, default={}): cv.Schema(
{ {
cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of(
@@ -937,6 +950,10 @@ async def to_code(config):
), ),
) )
add_idf_sdkconfig_option(
f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True
)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) add_idf_sdkconfig_option(name, RawSdkconfigValue(value))

View File

@@ -101,6 +101,38 @@ void ESP32BLETracker::loop() {
this->start_scan(); 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_(); ClientStateCounts counts = this->count_client_states_();
if (counts != this->client_state_counts_) { if (counts != this->client_state_counts_) {
this->client_state_counts_ = 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_)); ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
return; return;
} }
this->cancel_timeout("scan"); // Reset timeout state machine when stopping scan
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
this->set_scanner_state_(ScannerState::STOPPING); this->set_scanner_state_(ScannerState::STOPPING);
esp_err_t err = esp_ble_gap_stop_scanning(); esp_err_t err = esp_ble_gap_stop_scanning();
if (err != ESP_OK) { 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_interval = this->scan_interval_;
this->scan_params_.scan_window = this->scan_window_; this->scan_params_.scan_window = this->scan_window_;
// Start timeout before scan is started. Otherwise scan never starts if any error. // Start timeout monitoring in loop() instead of using scheduler
this->set_timeout("scan", this->scan_duration_ * 2000, []() { // This prevents false reboots when the loop is blocked
ESP_LOGE(TAG, "Scan never terminated, rebooting to restore stack (IDF)"); this->scan_start_time_ = App.get_loop_component_start_time();
App.reboot(); this->scan_timeout_state_ = ScanTimeoutState::MONITORING;
});
esp_err_t err = esp_ble_gap_set_scan_params(&this->scan_params_); esp_err_t err = esp_ble_gap_set_scan_params(&this->scan_params_);
if (err != ESP_OK) { if (err != ESP_OK) {
@@ -752,7 +784,8 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
#ifdef USE_ESP32_BLE_DEVICE #ifdef USE_ESP32_BLE_DEVICE
this->already_discovered_.clear(); this->already_discovered_.clear();
#endif #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_) for (auto *listener : this->listeners_)
listener->on_scan_end(); listener->on_scan_end();

View File

@@ -367,6 +367,14 @@ class ESP32BLETracker : public Component,
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_{false}; bool coex_prefer_ble_{false};
#endif #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 // NOLINTNEXTLINE

View File

@@ -12,6 +12,7 @@ from esphome.const import (
CONF_GROUP, CONF_GROUP,
CONF_ID, CONF_ID,
CONF_LAMBDA, CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_BOOT, CONF_ON_BOOT,
CONF_ON_IDLE, CONF_ON_IDLE,
CONF_PAGES, CONF_PAGES,
@@ -186,7 +187,7 @@ def multi_conf_validate(configs: list[dict]):
base_config = configs[0] base_config = configs[0]
for config in configs[1:]: for config in configs[1:]:
for item in ( for item in (
df.CONF_LOG_LEVEL, CONF_LOG_LEVEL,
CONF_COLOR_DEPTH, CONF_COLOR_DEPTH,
df.CONF_BYTE_ORDER, df.CONF_BYTE_ORDER,
df.CONF_TRANSPARENCY_KEY, df.CONF_TRANSPARENCY_KEY,
@@ -269,11 +270,11 @@ async def to_code(configs):
add_define( add_define(
"LV_LOG_LEVEL", "LV_LOG_LEVEL",
f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[df.CONF_LOG_LEVEL]]}", f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[CONF_LOG_LEVEL]]}",
) )
cg.add_define( cg.add_define(
"LVGL_LOG_LEVEL", "LVGL_LOG_LEVEL",
cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[df.CONF_LOG_LEVEL]}"), cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"),
) )
add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH])
for font in helpers.lv_fonts_used: for font in helpers.lv_fonts_used:
@@ -423,7 +424,7 @@ LVGL_SCHEMA = cv.All(
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(df.CONF_LOG_LEVEL, default="WARN"): cv.one_of( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True *df.LV_LOG_LEVELS, upper=True
), ),
cv.Optional(df.CONF_BYTE_ORDER, default="big_endian"): cv.one_of( cv.Optional(df.CONF_BYTE_ORDER, default="big_endian"): cv.one_of(

View File

@@ -456,7 +456,6 @@ CONF_KEYPADS = "keypads"
CONF_LAYOUT = "layout" CONF_LAYOUT = "layout"
CONF_LEFT_BUTTON = "left_button" CONF_LEFT_BUTTON = "left_button"
CONF_LINE_WIDTH = "line_width" CONF_LINE_WIDTH = "line_width"
CONF_LOG_LEVEL = "log_level"
CONF_LONG_PRESS_TIME = "long_press_time" CONF_LONG_PRESS_TIME = "long_press_time"
CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time"
CONF_LVGL_ID = "lvgl_id" CONF_LVGL_ID = "lvgl_id"

View File

@@ -287,10 +287,14 @@ def angle(value):
:param value: The input in the range 0..360 :param value: The input in the range 0..360
:return: An angle in 1/10 degree units. :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") @schema_extractor("one_of")

View File

@@ -451,7 +451,8 @@ void LvglComponent::setup() {
if (buffer == nullptr && this->buffer_frac_ == 0) { if (buffer == nullptr && this->buffer_frac_ == 0) {
frac = MIN_BUFFER_FRAC; frac = MIN_BUFFER_FRAC;
buffer_pixels /= 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) { if (buffer == nullptr) {
this->status_set_error("Memory allocation failure"); this->status_set_error("Memory allocation failure");

View File

@@ -161,7 +161,7 @@ class WidgetType:
""" """
return [] 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 Create an instance of the widget type
:param parent: The parent to which it should be attached :param parent: The parent to which it should be attached

View File

@@ -439,7 +439,7 @@ async def widget_to_code(w_cnfig, w_type: WidgetType, parent):
:return: :return:
""" """
spec: WidgetType = WIDGET_TYPES[w_type] 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.name)
add_lv_use(*spec.get_uses()) add_lv_use(*spec.get_uses())
wid = w_cnfig[CONF_ID] wid = w_cnfig[CONF_ID]

View File

@@ -20,7 +20,7 @@ from ..defines import (
CONF_START_ANGLE, CONF_START_ANGLE,
literal, 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 ..lvcode import lv, lv_expr, lv_obj
from ..types import LvNumber, NumberType from ..types import LvNumber, NumberType
from . import Widget from . import Widget
@@ -29,11 +29,11 @@ CONF_ARC = "arc"
ARC_SCHEMA = cv.Schema( ARC_SCHEMA = cv.Schema(
{ {
cv.Optional(CONF_VALUE): lv_float, cv.Optional(CONF_VALUE): lv_float,
cv.Optional(CONF_MIN_VALUE, default=0): cv.int_, cv.Optional(CONF_MIN_VALUE, default=0): lv_int,
cv.Optional(CONF_MAX_VALUE, default=100): cv.int_, cv.Optional(CONF_MAX_VALUE, default=100): lv_int,
cv.Optional(CONF_START_ANGLE, default=135): angle, cv.Optional(CONF_START_ANGLE, default=135): lv_angle_degrees,
cv.Optional(CONF_END_ANGLE, default=45): angle, cv.Optional(CONF_END_ANGLE, default=45): lv_angle_degrees,
cv.Optional(CONF_ROTATION, default=0.0): angle, cv.Optional(CONF_ROTATION, default=0.0): lv_angle_degrees,
cv.Optional(CONF_ADJUSTABLE, default=False): bool, cv.Optional(CONF_ADJUSTABLE, default=False): bool,
cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of, cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of,
cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t, 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): async def to_code(self, w: Widget, config):
if CONF_MIN_VALUE in config: if CONF_MIN_VALUE in config:
lv.arc_set_range(w.obj, config[CONF_MIN_VALUE], config[CONF_MAX_VALUE]) max_value = await lv_int.process(config[CONF_MAX_VALUE])
lv.arc_set_bg_angles( min_value = await lv_int.process(config[CONF_MIN_VALUE])
w.obj, config[CONF_START_ANGLE] // 10, config[CONF_END_ANGLE] // 10 lv.arc_set_range(w.obj, min_value, max_value)
) start = await lv_angle_degrees.process(config[CONF_START_ANGLE])
lv.arc_set_rotation(w.obj, config[CONF_ROTATION] // 10) 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_mode(w.obj, literal(config[CONF_MODE]))
lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE]) lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE])

View File

@@ -4,7 +4,7 @@ from esphome.const import CONF_SIZE, CONF_TEXT
from esphome.cpp_generator import MockObjClass from esphome.cpp_generator import MockObjClass
from ..defines import CONF_MAIN 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 ..lvcode import LocalVariable, lv, lv_expr
from ..schemas import TEXT_SCHEMA from ..schemas import TEXT_SCHEMA
from ..types import WidgetType, lv_obj_t from ..types import WidgetType, lv_obj_t
@@ -16,8 +16,8 @@ CONF_LIGHT_COLOR = "light_color"
QRCODE_SCHEMA = TEXT_SCHEMA.extend( QRCODE_SCHEMA = TEXT_SCHEMA.extend(
{ {
cv.Optional(CONF_DARK_COLOR, default="black"): color, cv.Optional(CONF_DARK_COLOR, default="black"): lv_color,
cv.Optional(CONF_LIGHT_COLOR, default="white"): color, cv.Optional(CONF_LIGHT_COLOR, default="white"): lv_color,
cv.Required(CONF_SIZE): cv.int_, cv.Required(CONF_SIZE): cv.int_,
} }
) )
@@ -34,11 +34,11 @@ class QrCodeType(WidgetType):
) )
def get_uses(self): def get_uses(self):
return ("canvas", "img", "label") return "canvas", "img", "label"
def obj_creator(self, parent: MockObjClass, config: dict): async def obj_creator(self, parent: MockObjClass, config: dict):
dark_color = color_retmapper(config[CONF_DARK_COLOR]) dark_color = await lv_color.process(config[CONF_DARK_COLOR])
light_color = color_retmapper(config[CONF_LIGHT_COLOR]) light_color = await lv_color.process(config[CONF_LIGHT_COLOR])
size = config[CONF_SIZE] size = config[CONF_SIZE]
return lv_expr.call("qrcode_create", parent, size, dark_color, light_color) return lv_expr.call("qrcode_create", parent, size, dark_color, light_color)

View File

@@ -2,7 +2,7 @@ import esphome.config_validation as cv
from esphome.cpp_generator import MockObjClass from esphome.cpp_generator import MockObjClass
from ..defines import CONF_ARC_LENGTH, CONF_INDICATOR, CONF_MAIN, CONF_SPIN_TIME 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 ..lvcode import lv_expr
from ..types import LvType from ..types import LvType
from . import Widget, WidgetType from . import Widget, WidgetType
@@ -12,8 +12,8 @@ CONF_SPINNER = "spinner"
SPINNER_SCHEMA = cv.Schema( SPINNER_SCHEMA = cv.Schema(
{ {
cv.Required(CONF_ARC_LENGTH): angle, cv.Required(CONF_ARC_LENGTH): lv_angle_degrees,
cv.Required(CONF_SPIN_TIME): cv.positive_time_period_milliseconds, cv.Required(CONF_SPIN_TIME): lv_milliseconds,
} }
) )
@@ -34,9 +34,9 @@ class SpinnerType(WidgetType):
def get_uses(self): def get_uses(self):
return (CONF_ARC,) return (CONF_ARC,)
def obj_creator(self, parent: MockObjClass, config: dict): async def obj_creator(self, parent: MockObjClass, config: dict):
spin_time = config[CONF_SPIN_TIME].total_milliseconds spin_time = await lv_milliseconds.process(config[CONF_SPIN_TIME])
arc_length = config[CONF_ARC_LENGTH] // 10 arc_length = await lv_angle_degrees.process(config[CONF_ARC_LENGTH])
return lv_expr.call("spinner_create", parent, spin_time, arc_length) return lv_expr.call("spinner_create", parent, spin_time, arc_length)

View File

@@ -87,12 +87,12 @@ class TabviewType(WidgetType):
) as content_obj: ) as content_obj:
await set_obj_properties(Widget(content_obj, obj_spec), content_style) 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( return lv_expr.call(
"tabview_create", "tabview_create",
parent, parent,
literal(config[CONF_POSITION]), await DIRECTIONS.process(config[CONF_POSITION]),
literal(config[CONF_SIZE]), await size.process(config[CONF_SIZE]),
) )

View File

@@ -225,6 +225,9 @@ async def to_code(config):
# https://github.com/Makuna/NeoPixelBus/blob/master/library.json # https://github.com/Makuna/NeoPixelBus/blob/master/library.json
# Version Listed Here: https://registry.platformio.org/libraries/makuna/NeoPixelBus/versions # Version Listed Here: https://registry.platformio.org/libraries/makuna/NeoPixelBus/versions
if CORE.is_esp32: 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") cg.add_library("makuna/NeoPixelBus", "2.8.0")
else: else:
cg.add_library("makuna/NeoPixelBus", "2.7.3") cg.add_library("makuna/NeoPixelBus", "2.7.3")

View File

@@ -36,7 +36,9 @@ def get_sdl_options(value):
if value != "": if value != "":
return value return value
try: try:
return subprocess.check_output(["sdl2-config", "--cflags", "--libs"]).decode() return subprocess.check_output(
["sdl2-config", "--cflags", "--libs"], close_fds=False
).decode()
except Exception as e: except Exception as e:
raise cv.Invalid("Unable to run sdl2-config - have you installed sdl2?") from e raise cv.Invalid("Unable to run sdl2-config - have you installed sdl2?") from e

View File

@@ -10,6 +10,7 @@ from esphome.const import (
CONF_ID, CONF_ID,
CONF_INVERTED, CONF_INVERTED,
CONF_MQTT_ID, CONF_MQTT_ID,
CONF_ON_STATE,
CONF_ON_TURN_OFF, CONF_ON_TURN_OFF,
CONF_ON_TURN_ON, CONF_ON_TURN_ON,
CONF_RESTORE_MODE, CONF_RESTORE_MODE,
@@ -56,6 +57,9 @@ TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action)
SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action)
SwitchCondition = switch_ns.class_("SwitchCondition", Condition) SwitchCondition = switch_ns.class_("SwitchCondition", Condition)
SwitchStateTrigger = switch_ns.class_(
"SwitchStateTrigger", automation.Trigger.template(bool)
)
SwitchTurnOnTrigger = switch_ns.class_( SwitchTurnOnTrigger = switch_ns.class_(
"SwitchTurnOnTrigger", automation.Trigger.template() "SwitchTurnOnTrigger", automation.Trigger.template()
) )
@@ -77,6 +81,11 @@ _SWITCH_SCHEMA = (
cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum(
RESTORE_MODES, upper=True, space="_" 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.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{ {
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchTurnOnTrigger), 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: if (inverted := config.get(CONF_INVERTED)) is not None:
cg.add(var.set_inverted(inverted)) 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, []): for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf) await automation.build_automation(trigger, [], conf)

View File

@@ -64,6 +64,13 @@ template<typename... Ts> class SwitchCondition : public Condition<Ts...> {
bool state_; bool state_;
}; };
class SwitchStateTrigger : public Trigger<bool> {
public:
SwitchStateTrigger(Switch *a_switch) {
a_switch->add_on_state_callback([this](bool state) { this->trigger(state); });
}
};
class SwitchTurnOnTrigger : public Trigger<> { class SwitchTurnOnTrigger : public Trigger<> {
public: public:
SwitchTurnOnTrigger(Switch *a_switch) { SwitchTurnOnTrigger(Switch *a_switch) {

View File

@@ -635,15 +635,8 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc
} else if (match.method_equals("turn_on") || match.method_equals("turn_off")) { } else if (match.method_equals("turn_on") || match.method_equals("turn_off")) {
auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off(); auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off();
if (request->hasParam("speed_level")) { parse_int_param_(request, "speed_level", call, &decltype(call)::set_speed);
auto speed_level = request->getParam("speed_level")->value();
auto val = parse_number<int>(speed_level.c_str());
if (!val.has_value()) {
ESP_LOGW(TAG, "Can't convert '%s' to number!", speed_level.c_str());
return;
}
call.set_speed(*val);
}
if (request->hasParam("oscillation")) { if (request->hasParam("oscillation")) {
auto speed = request->getParam("oscillation")->value(); auto speed = request->getParam("oscillation")->value();
auto val = parse_on_off(speed.c_str()); auto val = parse_on_off(speed.c_str());
@@ -715,69 +708,26 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
request->send(200); request->send(200);
} else if (match.method_equals("turn_on")) { } else if (match.method_equals("turn_on")) {
auto call = obj->turn_on(); auto call = obj->turn_on();
if (request->hasParam("brightness")) {
auto brightness = parse_number<float>(request->getParam("brightness")->value().c_str()); // Parse color parameters
if (brightness.has_value()) { parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f);
call.set_brightness(*brightness / 255.0f); parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f);
} parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f);
} parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f);
if (request->hasParam("r")) { parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f);
auto r = parse_number<float>(request->getParam("r")->value().c_str()); parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature);
if (r.has_value()) {
call.set_red(*r / 255.0f); // Parse timing parameters
} parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000);
} parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000);
if (request->hasParam("g")) {
auto g = parse_number<float>(request->getParam("g")->value().c_str()); parse_string_param_(request, "effect", call, &decltype(call)::set_effect);
if (g.has_value()) {
call.set_green(*g / 255.0f);
}
}
if (request->hasParam("b")) {
auto b = parse_number<float>(request->getParam("b")->value().c_str());
if (b.has_value()) {
call.set_blue(*b / 255.0f);
}
}
if (request->hasParam("white_value")) {
auto white_value = parse_number<float>(request->getParam("white_value")->value().c_str());
if (white_value.has_value()) {
call.set_white(*white_value / 255.0f);
}
}
if (request->hasParam("color_temp")) {
auto color_temp = parse_number<float>(request->getParam("color_temp")->value().c_str());
if (color_temp.has_value()) {
call.set_color_temperature(*color_temp);
}
}
if (request->hasParam("flash")) {
auto flash = parse_number<uint32_t>(request->getParam("flash")->value().c_str());
if (flash.has_value()) {
call.set_flash_length(*flash * 1000);
}
}
if (request->hasParam("transition")) {
auto transition = parse_number<uint32_t>(request->getParam("transition")->value().c_str());
if (transition.has_value()) {
call.set_transition_length(*transition * 1000);
}
}
if (request->hasParam("effect")) {
const char *effect = request->getParam("effect")->value().c_str();
call.set_effect(effect);
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
} else if (match.method_equals("turn_off")) { } else if (match.method_equals("turn_off")) {
auto call = obj->turn_off(); auto call = obj->turn_off();
if (request->hasParam("transition")) { parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000);
auto transition = parse_number<uint32_t>(request->getParam("transition")->value().c_str());
if (transition.has_value()) {
call.set_transition_length(*transition * 1000);
}
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
} else { } else {
@@ -850,18 +800,8 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa
return; return;
} }
if (request->hasParam("position")) { parse_float_param_(request, "position", call, &decltype(call)::set_position);
auto position = parse_number<float>(request->getParam("position")->value().c_str()); parse_float_param_(request, "tilt", call, &decltype(call)::set_tilt);
if (position.has_value()) {
call.set_position(*position);
}
}
if (request->hasParam("tilt")) {
auto tilt = parse_number<float>(request->getParam("tilt")->value().c_str());
if (tilt.has_value()) {
call.set_tilt(*tilt);
}
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -915,11 +855,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM
} }
auto call = obj->make_call(); auto call = obj->make_call();
if (request->hasParam("value")) { parse_float_param_(request, "value", call, &decltype(call)::set_value);
auto value = parse_number<float>(request->getParam("value")->value().c_str());
if (value.has_value())
call.set_value(*value);
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -991,10 +927,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat
return; return;
} }
if (request->hasParam("value")) { parse_string_param_(request, "value", call, &decltype(call)::set_date);
std::string value = request->getParam("value")->value().c_str(); // NOLINT
call.set_date(value);
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -1050,10 +983,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat
return; return;
} }
if (request->hasParam("value")) { parse_string_param_(request, "value", call, &decltype(call)::set_time);
std::string value = request->getParam("value")->value().c_str(); // NOLINT
call.set_time(value);
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -1108,10 +1038,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur
return; return;
} }
if (request->hasParam("value")) { parse_string_param_(request, "value", call, &decltype(call)::set_datetime);
std::string value = request->getParam("value")->value().c_str(); // NOLINT
call.set_datetime(value);
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -1162,10 +1089,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat
} }
auto call = obj->make_call(); auto call = obj->make_call();
if (request->hasParam("value")) { parse_string_param_(request, "value", call, &decltype(call)::set_value);
String value = request->getParam("value")->value();
call.set_value(value.c_str()); // NOLINT
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -1224,11 +1148,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM
} }
auto call = obj->make_call(); auto call = obj->make_call();
parse_string_param_(request, "option", call, &decltype(call)::set_option);
if (request->hasParam("option")) {
auto option = request->getParam("option")->value();
call.set_option(option.c_str()); // NOLINT
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -1284,38 +1204,15 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
auto call = obj->make_call(); auto call = obj->make_call();
if (request->hasParam("mode")) { // Parse string mode parameters
auto mode = request->getParam("mode")->value(); parse_string_param_(request, "mode", call, &decltype(call)::set_mode);
call.set_mode(mode.c_str()); // NOLINT parse_string_param_(request, "fan_mode", call, &decltype(call)::set_fan_mode);
} parse_string_param_(request, "swing_mode", call, &decltype(call)::set_swing_mode);
if (request->hasParam("fan_mode")) { // Parse temperature parameters
auto mode = request->getParam("fan_mode")->value(); parse_float_param_(request, "target_temperature_high", call, &decltype(call)::set_target_temperature_high);
call.set_fan_mode(mode.c_str()); // NOLINT parse_float_param_(request, "target_temperature_low", call, &decltype(call)::set_target_temperature_low);
} parse_float_param_(request, "target_temperature", call, &decltype(call)::set_target_temperature);
if (request->hasParam("swing_mode")) {
auto mode = request->getParam("swing_mode")->value();
call.set_swing_mode(mode.c_str()); // NOLINT
}
if (request->hasParam("target_temperature_high")) {
auto target_temperature_high = parse_number<float>(request->getParam("target_temperature_high")->value().c_str());
if (target_temperature_high.has_value())
call.set_target_temperature_high(*target_temperature_high);
}
if (request->hasParam("target_temperature_low")) {
auto target_temperature_low = parse_number<float>(request->getParam("target_temperature_low")->value().c_str());
if (target_temperature_low.has_value())
call.set_target_temperature_low(*target_temperature_low);
}
if (request->hasParam("target_temperature")) {
auto target_temperature = parse_number<float>(request->getParam("target_temperature")->value().c_str());
if (target_temperature.has_value())
call.set_target_temperature(*target_temperature);
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -1506,12 +1403,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa
return; return;
} }
if (request->hasParam("position")) { parse_float_param_(request, "position", call, &decltype(call)::set_position);
auto position = parse_number<float>(request->getParam("position")->value().c_str());
if (position.has_value()) {
call.set_position(*position);
}
}
this->defer([call]() mutable { call.perform(); }); this->defer([call]() mutable { call.perform(); });
request->send(200); request->send(200);
@@ -1559,9 +1451,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques
} }
auto call = obj->make_call(); auto call = obj->make_call();
if (request->hasParam("code")) { parse_string_param_(request, "code", call, &decltype(call)::set_code);
call.set_code(request->getParam("code")->value().c_str()); // NOLINT
}
if (match.method_equals("disarm")) { if (match.method_equals("disarm")) {
call.disarm(); call.disarm();
@@ -1659,6 +1549,19 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty
#endif #endif
#ifdef USE_UPDATE #ifdef USE_UPDATE
static const char *update_state_to_string(update::UpdateState state) {
switch (state) {
case update::UPDATE_STATE_NO_UPDATE:
return "NO UPDATE";
case update::UPDATE_STATE_AVAILABLE:
return "UPDATE AVAILABLE";
case update::UPDATE_STATE_INSTALLING:
return "INSTALLING";
default:
return "UNKNOWN";
}
}
void WebServer::on_update(update::UpdateEntity *obj) { void WebServer::on_update(update::UpdateEntity *obj) {
if (this->events_.empty()) if (this->events_.empty())
return; return;
@@ -1698,20 +1601,7 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c
return json::build_json([this, obj, start_config](JsonObject root) { return json::build_json([this, obj, start_config](JsonObject root) {
set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); set_json_id(root, obj, "update-" + obj->get_object_id(), start_config);
root["value"] = obj->update_info.latest_version; root["value"] = obj->update_info.latest_version;
switch (obj->state) { root["state"] = update_state_to_string(obj->state);
case update::UPDATE_STATE_NO_UPDATE:
root["state"] = "NO UPDATE";
break;
case update::UPDATE_STATE_AVAILABLE:
root["state"] = "UPDATE AVAILABLE";
break;
case update::UPDATE_STATE_INSTALLING:
root["state"] = "INSTALLING";
break;
default:
root["state"] = "UNKNOWN";
break;
}
if (start_config == DETAIL_ALL) { if (start_config == DETAIL_ALL) {
root["current_version"] = obj->update_info.current_version; root["current_version"] = obj->update_info.current_version;
root["title"] = obj->update_info.title; root["title"] = obj->update_info.title;

View File

@@ -498,6 +498,66 @@ class WebServer : public Controller, public Component, public AsyncWebHandler {
protected: protected:
void add_sorting_info_(JsonObject &root, EntityBase *entity); void add_sorting_info_(JsonObject &root, EntityBase *entity);
#ifdef USE_LIGHT
// Helper to parse and apply a float parameter with optional scaling
template<typename T, typename Ret>
void parse_light_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float),
float scale = 1.0f) {
if (request->hasParam(param_name)) {
auto value = parse_number<float>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value / scale);
}
}
}
// Helper to parse and apply a uint32_t parameter with optional scaling
template<typename T, typename Ret>
void parse_light_param_uint_(AsyncWebServerRequest *request, const char *param_name, T &call,
Ret (T::*setter)(uint32_t), uint32_t scale = 1) {
if (request->hasParam(param_name)) {
auto value = parse_number<uint32_t>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value * scale);
}
}
}
#endif
// Generic helper to parse and apply a float parameter
template<typename T, typename Ret>
void parse_float_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float)) {
if (request->hasParam(param_name)) {
auto value = parse_number<float>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value);
}
}
}
// Generic helper to parse and apply an int parameter
template<typename T, typename Ret>
void parse_int_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(int)) {
if (request->hasParam(param_name)) {
auto value = parse_number<int>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value);
}
}
}
// Generic helper to parse and apply a string parameter
template<typename T, typename Ret>
void parse_string_param_(AsyncWebServerRequest *request, const char *param_name, T &call,
Ret (T::*setter)(const std::string &)) {
if (request->hasParam(param_name)) {
// .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);
}
}
web_server_base::WebServerBase *base_; web_server_base::WebServerBase *base_;
#ifdef USE_ARDUINO #ifdef USE_ARDUINO
DeferredUpdateEventSourceList events_; DeferredUpdateEventSourceList events_;

View File

@@ -523,6 +523,7 @@ CONF_LOADED_INTEGRATIONS = "loaded_integrations"
CONF_LOCAL = "local" CONF_LOCAL = "local"
CONF_LOCK_ACTION = "lock_action" CONF_LOCK_ACTION = "lock_action"
CONF_LOG = "log" CONF_LOG = "log"
CONF_LOG_LEVEL = "log_level"
CONF_LOG_TOPIC = "log_topic" CONF_LOG_TOPIC = "log_topic"
CONF_LOGGER = "logger" CONF_LOGGER = "logger"
CONF_LOGS = "logs" CONF_LOGS = "logs"

View File

@@ -229,6 +229,7 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
close_fds=False,
) )
stdout_thread = threading.Thread(target=self._stdout_thread) stdout_thread = threading.Thread(target=self._stdout_thread)
stdout_thread.daemon = True stdout_thread.daemon = True
@@ -324,14 +325,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
configuration = json_message["configuration"] configuration = json_message["configuration"]
config_file = settings.rel_path(configuration) config_file = settings.rel_path(configuration)
port = json_message["port"] port = json_message["port"]
addresses: list[str] = [port] addresses: list[str] = []
if ( if (
port == "OTA" # pylint: disable=too-many-boolean-expressions port == "OTA" # pylint: disable=too-many-boolean-expressions
and (entry := entries.get(config_file)) and (entry := entries.get(config_file))
and entry.loaded_integrations and entry.loaded_integrations
and "api" in entry.loaded_integrations and "api" in entry.loaded_integrations
): ):
addresses = []
# First priority: entry.address AKA use_address # First priority: entry.address AKA use_address
if ( if (
(use_address := entry.address) (use_address := entry.address)
@@ -359,6 +359,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
# since MQTT logging will not work otherwise # since MQTT logging will not work otherwise
addresses.extend(sort_ip_addresses(new_addresses)) addresses.extend(sort_ip_addresses(new_addresses))
if not addresses:
# If no address was found, use the port directly
# as otherwise they will get the chooser which
# does not work with the dashboard as there is no
# interactive way to get keyboard input
addresses = [port]
device_args: list[str] = [ device_args: list[str] = [
arg for address in addresses for arg in ("--device", address) arg for address in addresses for arg in ("--device", address)
] ]

View File

@@ -17,7 +17,9 @@ _LOGGER = logging.getLogger(__name__)
def run_git_command(cmd, cwd=None) -> str: def run_git_command(cmd, cwd=None) -> str:
_LOGGER.debug("Running git command: %s", " ".join(cmd)) _LOGGER.debug("Running git command: %s", " ".join(cmd))
try: try:
ret = subprocess.run(cmd, cwd=cwd, capture_output=True, check=False) ret = subprocess.run(
cmd, cwd=cwd, capture_output=True, check=False, close_fds=False
)
except FileNotFoundError as err: except FileNotFoundError as err:
raise cv.Invalid( raise cv.Invalid(
"git is not installed but required for external_components.\n" "git is not installed but required for external_components.\n"

View File

@@ -114,7 +114,9 @@ def cpp_string_escape(string, encoding="utf-8"):
def run_system_command(*args): def run_system_command(*args):
import subprocess import subprocess
with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as p: with subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=False
) as p:
stdout, stderr = p.communicate() stdout, stderr = p.communicate()
rc = p.returncode rc = p.returncode
return rc, stdout, stderr return rc, stdout, stderr

View File

@@ -211,7 +211,7 @@ def _decode_pc(config, addr):
return return
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try: try:
translation = subprocess.check_output(command).decode().strip() translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.debug("Caught exception for command %s", command, exc_info=1) _LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return return

View File

@@ -239,7 +239,12 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str:
try: try:
proc = subprocess.run( proc = subprocess.run(
cmd, stdout=sub_stdout, stderr=sub_stderr, encoding="utf-8", check=False cmd,
stdout=sub_stdout,
stderr=sub_stderr,
encoding="utf-8",
check=False,
close_fds=False,
) )
return proc.stdout if capture_stdout else proc.returncode return proc.stdout if capture_stdout else proc.returncode
except KeyboardInterrupt: # pylint: disable=try-except-raise except KeyboardInterrupt: # pylint: disable=try-except-raise

View File

@@ -500,7 +500,8 @@ def lint_constants_usage():
continue continue
errs.append( errs.append(
f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " 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 return errs

View File

@@ -31,7 +31,11 @@ def run_format(executable, args, queue, lock, failed_files):
invocation.append(path) invocation.append(path)
proc = subprocess.run( proc = subprocess.run(
invocation, capture_output=True, encoding="utf-8", check=False invocation,
capture_output=True,
encoding="utf-8",
check=False,
close_fds=False,
) )
if proc.returncode != 0: if proc.returncode != 0:
with lock: with lock:

View File

@@ -158,7 +158,11 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files):
invocation.extend(options) invocation.extend(options)
proc = subprocess.run( proc = subprocess.run(
invocation, capture_output=True, encoding="utf-8", check=False invocation,
capture_output=True,
encoding="utf-8",
check=False,
close_fds=False,
) )
if proc.returncode != 0: if proc.returncode != 0:
with lock: with lock:
@@ -320,9 +324,11 @@ def main():
print("Applying fixes ...") print("Applying fixes ...")
try: try:
try: try:
subprocess.call(["clang-apply-replacements-18", tmpdir]) subprocess.call(
["clang-apply-replacements-18", tmpdir], close_fds=False
)
except FileNotFoundError: except FileNotFoundError:
subprocess.call(["clang-apply-replacements", tmpdir]) subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False)
except FileNotFoundError: except FileNotFoundError:
print( print(
"Error please install clang-apply-replacements-18 or clang-apply-replacements.\n", "Error please install clang-apply-replacements-18 or clang-apply-replacements.\n",

View File

@@ -55,4 +55,6 @@ for section in config.sections():
tools.append("-t") tools.append("-t")
tools.append(tool) tools.append(tool)
subprocess.check_call(["platformio", "pkg", "install", "-g", *libs, *platforms, *tools]) subprocess.check_call(
["platformio", "pkg", "install", "-g", *libs, *platforms, *tools], close_fds=False
)

View File

@@ -13,7 +13,7 @@ def find_and_activate_virtualenv():
try: try:
# Get the top-level directory of the git repository # Get the top-level directory of the git repository
my_path = subprocess.check_output( my_path = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True ["git", "rev-parse", "--show-toplevel"], text=True, close_fds=False
).strip() ).strip()
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print( print(
@@ -44,7 +44,7 @@ def find_and_activate_virtualenv():
def run_command(): def run_command():
# Execute the remaining arguments in the new environment # Execute the remaining arguments in the new environment
if len(sys.argv) > 1: if len(sys.argv) > 1:
subprocess.run(sys.argv[1:], check=False) subprocess.run(sys.argv[1:], check=False, close_fds=False)
else: else:
print( print(
"No command provided to run in the virtual environment.", "No command provided to run in the virtual environment.",

View File

@@ -12,12 +12,12 @@ sensor:
frequency: 60Hz frequency: 60Hz
phase_a: phase_a:
name: Channel A name: Channel A
voltage: Channel A Voltage voltage: Voltage
current: Channel A Current current: Current
active_power: Channel A Active Power active_power: Active Power
power_factor: Channel A Power Factor power_factor: Power Factor
forward_active_energy: Channel A Forward Active Energy forward_active_energy: Forward Active Energy
reverse_active_energy: Channel A Reverse Active Energy reverse_active_energy: Reverse Active Energy
calibration: calibration:
current_gain: 3116628 current_gain: 3116628
voltage_gain: -757178 voltage_gain: -757178
@@ -25,12 +25,12 @@ sensor:
phase_angle: 188 phase_angle: 188
phase_b: phase_b:
name: Channel B name: Channel B
voltage: Channel B Voltage voltage: Voltage
current: Channel B Current current: Current
active_power: Channel B Active Power active_power: Active Power
power_factor: Channel B Power Factor power_factor: Power Factor
forward_active_energy: Channel B Forward Active Energy forward_active_energy: Forward Active Energy
reverse_active_energy: Channel B Reverse Active Energy reverse_active_energy: Reverse Active Energy
calibration: calibration:
current_gain: 3133655 current_gain: 3133655
voltage_gain: -755235 voltage_gain: -755235
@@ -38,12 +38,12 @@ sensor:
phase_angle: 188 phase_angle: 188
phase_c: phase_c:
name: Channel C name: Channel C
voltage: Channel C Voltage voltage: Voltage
current: Channel C Current current: Current
active_power: Channel C Active Power active_power: Active Power
power_factor: Channel C Power Factor power_factor: Power Factor
forward_active_energy: Channel C Forward Active Energy forward_active_energy: Forward Active Energy
reverse_active_energy: Channel C Reverse Active Energy reverse_active_energy: Reverse Active Energy
calibration: calibration:
current_gain: 3111158 current_gain: 3111158
voltage_gain: -743813 voltage_gain: -743813
@@ -51,6 +51,6 @@ sensor:
phase_angle: 180 phase_angle: 180
neutral: neutral:
name: Neutral name: Neutral
current: Neutral Current current: Current
calibration: calibration:
current_gain: 3189 current_gain: 3189

View File

@@ -723,6 +723,20 @@ lvgl:
arc_color: 0xFFFF00 arc_color: 0xFFFF00
focused: focused:
arc_color: 0x808080 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: - bar:
id: bar_id id: bar_id
align: top_mid align: top_mid

View File

@@ -9,6 +9,18 @@ switch:
name: "Template Switch" name: "Template Switch"
id: the_switch id: the_switch
optimistic: true 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: esphome:
on_boot: on_boot:

View File

@@ -105,6 +105,7 @@ logger:
check=True, check=True,
cwd=init_dir, cwd=init_dir,
env=env, env=env,
close_fds=False,
) )
# Lock is held until here, ensuring cache is fully populated before any test proceeds # Lock is held until here, ensuring cache is fully populated before any test proceeds
@@ -245,6 +246,7 @@ async def compile_esphome(
# Start in a new process group to isolate signal handling # Start in a new process group to isolate signal handling
start_new_session=True, start_new_session=True,
env=env, env=env,
close_fds=False,
) )
await proc.wait() await proc.wait()
@@ -477,6 +479,7 @@ async def run_binary_and_wait_for_port(
# Start in a new process group to isolate signal handling # Start in a new process group to isolate signal handling
start_new_session=True, start_new_session=True,
pass_fds=(device_fd,), pass_fds=(device_fd,),
close_fds=False,
) )
# Close the device end in the parent process # Close the device end in the parent process