1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-05 04:42:21 +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):
print(f"$ {shlex.join(list(cmd))}")
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:
print("Command failed")
sys.exit(1)

View File

@@ -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)

View File

@@ -15,6 +15,7 @@ from esphome.const import (
CONF_FRAMEWORK,
CONF_IGNORE_EFUSE_CUSTOM_MAC,
CONF_IGNORE_EFUSE_MAC_CRC,
CONF_LOG_LEVEL,
CONF_NAME,
CONF_PATH,
CONF_PLATFORM_VERSION,
@@ -79,6 +80,15 @@ CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_RELEASE = "release"
LOG_LEVELS_IDF = [
"NONE",
"ERROR",
"WARN",
"INFO",
"DEBUG",
"VERBOSE",
]
ASSERTION_LEVELS = {
"DISABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE",
"ENABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE",
@@ -623,6 +633,9 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All(
cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): {
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_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():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))

View File

@@ -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();

View File

@@ -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

View File

@@ -12,6 +12,7 @@ from esphome.const import (
CONF_GROUP,
CONF_ID,
CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_BOOT,
CONF_ON_IDLE,
CONF_PAGES,
@@ -186,7 +187,7 @@ def multi_conf_validate(configs: list[dict]):
base_config = configs[0]
for config in configs[1:]:
for item in (
df.CONF_LOG_LEVEL,
CONF_LOG_LEVEL,
CONF_COLOR_DEPTH,
df.CONF_BYTE_ORDER,
df.CONF_TRANSPARENCY_KEY,
@@ -269,11 +270,11 @@ async def to_code(configs):
add_define(
"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(
"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])
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(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
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
),
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_LEFT_BUTTON = "left_button"
CONF_LINE_WIDTH = "line_width"
CONF_LOG_LEVEL = "log_level"
CONF_LONG_PRESS_TIME = "long_press_time"
CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time"
CONF_LVGL_ID = "lvgl_id"

View File

@@ -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")

View File

@@ -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");

View File

@@ -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

View File

@@ -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]

View File

@@ -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])

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]),
)

View File

@@ -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")

View File

@@ -36,7 +36,9 @@ def get_sdl_options(value):
if value != "":
return value
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:
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_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)

View File

@@ -64,6 +64,13 @@ template<typename... Ts> class SwitchCondition : public Condition<Ts...> {
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<> {
public:
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")) {
auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off();
if (request->hasParam("speed_level")) {
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);
}
parse_int_param_(request, "speed_level", call, &decltype(call)::set_speed);
if (request->hasParam("oscillation")) {
auto speed = request->getParam("oscillation")->value();
auto val = parse_on_off(speed.c_str());
@@ -715,69 +708,26 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
request->send(200);
} else if (match.method_equals("turn_on")) {
auto call = obj->turn_on();
if (request->hasParam("brightness")) {
auto brightness = parse_number<float>(request->getParam("brightness")->value().c_str());
if (brightness.has_value()) {
call.set_brightness(*brightness / 255.0f);
}
}
if (request->hasParam("r")) {
auto r = parse_number<float>(request->getParam("r")->value().c_str());
if (r.has_value()) {
call.set_red(*r / 255.0f);
}
}
if (request->hasParam("g")) {
auto g = parse_number<float>(request->getParam("g")->value().c_str());
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);
}
// Parse color parameters
parse_light_param_(request, "brightness", call, &decltype(call)::set_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);
parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f);
parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature);
// 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);
parse_string_param_(request, "effect", call, &decltype(call)::set_effect);
this->defer([call]() mutable { call.perform(); });
request->send(200);
} else if (match.method_equals("turn_off")) {
auto call = obj->turn_off();
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);
}
}
parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000);
this->defer([call]() mutable { call.perform(); });
request->send(200);
} else {
@@ -850,18 +800,8 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa
return;
}
if (request->hasParam("position")) {
auto position = parse_number<float>(request->getParam("position")->value().c_str());
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);
}
}
parse_float_param_(request, "position", call, &decltype(call)::set_position);
parse_float_param_(request, "tilt", call, &decltype(call)::set_tilt);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -915,11 +855,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM
}
auto call = obj->make_call();
if (request->hasParam("value")) {
auto value = parse_number<float>(request->getParam("value")->value().c_str());
if (value.has_value())
call.set_value(*value);
}
parse_float_param_(request, "value", call, &decltype(call)::set_value);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -991,10 +927,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat
return;
}
if (request->hasParam("value")) {
std::string value = request->getParam("value")->value().c_str(); // NOLINT
call.set_date(value);
}
parse_string_param_(request, "value", call, &decltype(call)::set_date);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -1050,10 +983,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat
return;
}
if (request->hasParam("value")) {
std::string value = request->getParam("value")->value().c_str(); // NOLINT
call.set_time(value);
}
parse_string_param_(request, "value", call, &decltype(call)::set_time);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -1108,10 +1038,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur
return;
}
if (request->hasParam("value")) {
std::string value = request->getParam("value")->value().c_str(); // NOLINT
call.set_datetime(value);
}
parse_string_param_(request, "value", call, &decltype(call)::set_datetime);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -1162,10 +1089,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat
}
auto call = obj->make_call();
if (request->hasParam("value")) {
String value = request->getParam("value")->value();
call.set_value(value.c_str()); // NOLINT
}
parse_string_param_(request, "value", call, &decltype(call)::set_value);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -1224,11 +1148,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM
}
auto call = obj->make_call();
if (request->hasParam("option")) {
auto option = request->getParam("option")->value();
call.set_option(option.c_str()); // NOLINT
}
parse_string_param_(request, "option", call, &decltype(call)::set_option);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -1284,38 +1204,15 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
auto call = obj->make_call();
if (request->hasParam("mode")) {
auto mode = request->getParam("mode")->value();
call.set_mode(mode.c_str()); // NOLINT
}
// Parse string mode parameters
parse_string_param_(request, "mode", call, &decltype(call)::set_mode);
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")) {
auto mode = request->getParam("fan_mode")->value();
call.set_fan_mode(mode.c_str()); // NOLINT
}
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);
}
// Parse temperature parameters
parse_float_param_(request, "target_temperature_high", call, &decltype(call)::set_target_temperature_high);
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);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -1506,12 +1403,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa
return;
}
if (request->hasParam("position")) {
auto position = parse_number<float>(request->getParam("position")->value().c_str());
if (position.has_value()) {
call.set_position(*position);
}
}
parse_float_param_(request, "position", call, &decltype(call)::set_position);
this->defer([call]() mutable { call.perform(); });
request->send(200);
@@ -1559,9 +1451,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques
}
auto call = obj->make_call();
if (request->hasParam("code")) {
call.set_code(request->getParam("code")->value().c_str()); // NOLINT
}
parse_string_param_(request, "code", call, &decltype(call)::set_code);
if (match.method_equals("disarm")) {
call.disarm();
@@ -1659,6 +1549,19 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty
#endif
#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) {
if (this->events_.empty())
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) {
set_json_id(root, obj, "update-" + obj->get_object_id(), start_config);
root["value"] = obj->update_info.latest_version;
switch (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;
}
root["state"] = update_state_to_string(obj->state);
if (start_config == DETAIL_ALL) {
root["current_version"] = obj->update_info.current_version;
root["title"] = obj->update_info.title;

View File

@@ -498,6 +498,66 @@ class WebServer : public Controller, public Component, public AsyncWebHandler {
protected:
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_;
#ifdef USE_ARDUINO
DeferredUpdateEventSourceList events_;

View File

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

View File

@@ -229,6 +229,7 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
close_fds=False,
)
stdout_thread = threading.Thread(target=self._stdout_thread)
stdout_thread.daemon = True
@@ -324,14 +325,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
configuration = json_message["configuration"]
config_file = settings.rel_path(configuration)
port = json_message["port"]
addresses: list[str] = [port]
addresses: list[str] = []
if (
port == "OTA" # pylint: disable=too-many-boolean-expressions
and (entry := entries.get(config_file))
and entry.loaded_integrations
and "api" in entry.loaded_integrations
):
addresses = []
# First priority: entry.address AKA use_address
if (
(use_address := entry.address)
@@ -359,6 +359,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
# since MQTT logging will not work otherwise
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] = [
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:
_LOGGER.debug("Running git command: %s", " ".join(cmd))
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:
raise cv.Invalid(
"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):
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()
rc = p.returncode
return rc, stdout, stderr

View File

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

View File

@@ -239,7 +239,12 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str:
try:
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
except KeyboardInterrupt: # pylint: disable=try-except-raise

View File

@@ -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

View File

@@ -31,7 +31,11 @@ def run_format(executable, args, queue, lock, failed_files):
invocation.append(path)
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:
with lock:

View File

@@ -158,7 +158,11 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files):
invocation.extend(options)
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:
with lock:
@@ -320,9 +324,11 @@ def main():
print("Applying fixes ...")
try:
try:
subprocess.call(["clang-apply-replacements-18", tmpdir])
subprocess.call(
["clang-apply-replacements-18", tmpdir], close_fds=False
)
except FileNotFoundError:
subprocess.call(["clang-apply-replacements", tmpdir])
subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False)
except FileNotFoundError:
print(
"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(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:
# Get the top-level directory of the git repository
my_path = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
["git", "rev-parse", "--show-toplevel"], text=True, close_fds=False
).strip()
except subprocess.CalledProcessError:
print(
@@ -44,7 +44,7 @@ def find_and_activate_virtualenv():
def run_command():
# Execute the remaining arguments in the new environment
if len(sys.argv) > 1:
subprocess.run(sys.argv[1:], check=False)
subprocess.run(sys.argv[1:], check=False, close_fds=False)
else:
print(
"No command provided to run in the virtual environment.",

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -105,6 +105,7 @@ logger:
check=True,
cwd=init_dir,
env=env,
close_fds=False,
)
# 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_new_session=True,
env=env,
close_fds=False,
)
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_new_session=True,
pass_fds=(device_fd,),
close_fds=False,
)
# Close the device end in the parent process