diff --git a/esphome/__main__.py b/esphome/__main__.py index b412ac39c0..d97c2c5b8c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1,5 +1,6 @@ # PYTHON_ARGCOMPLETE_OK import argparse +from collections.abc import Callable from datetime import datetime import functools import getpass @@ -943,11 +944,21 @@ def command_dashboard(args: ArgsProtocol) -> int | None: return dashboard.start_dashboard(args) -def command_update_all(args: ArgsProtocol) -> int | None: +def run_multiple_configs( + files: list, command_builder: Callable[[str], list[str]] +) -> int: + """Run a command for each configuration file in a subprocess. + + Args: + files: List of configuration files to process. + command_builder: Callable that takes a file path and returns a command list. + + Returns: + Number of failed files. + """ import click success = {} - files = list_yaml_files(args.configuration) twidth = 60 def print_bar(middle_text): @@ -957,17 +968,19 @@ def command_update_all(args: ArgsProtocol) -> int | None: safe_print(f"{half_line}{middle_text}{half_line}") for f in files: - safe_print(f"Updating {color(AnsiFore.CYAN, str(f))}") + f_path = Path(f) if not isinstance(f, Path) else f + + if any(f_path.name == x for x in SECRETS_FILES): + _LOGGER.warning("Skipping secrets file %s", f_path) + continue + + safe_print(f"Processing {color(AnsiFore.CYAN, str(f))}") safe_print("-" * twidth) safe_print() - if CORE.dashboard: - rc = run_external_process( - "esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA" - ) - else: - rc = run_external_process( - "esphome", "run", f, "--no-logs", "--device", "OTA" - ) + + cmd = command_builder(f) + rc = run_external_process(*cmd) + if rc == 0: print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") success[f] = True @@ -982,6 +995,8 @@ def command_update_all(args: ArgsProtocol) -> int | None: print_bar(f"[{color(AnsiFore.BOLD_WHITE, 'SUMMARY')}]") failed = 0 for f in files: + if f not in success: + continue # Skipped file if success[f]: safe_print(f" - {str(f)}: {color(AnsiFore.GREEN, 'SUCCESS')}") else: @@ -990,6 +1005,17 @@ def command_update_all(args: ArgsProtocol) -> int | None: return failed +def command_update_all(args: ArgsProtocol) -> int | None: + files = list_yaml_files(args.configuration) + + def build_command(f): + if CORE.dashboard: + return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"] + return ["esphome", "run", f, "--no-logs", "--device", "OTA"] + + return run_multiple_configs(files, build_command) + + def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json @@ -1551,38 +1577,48 @@ def run_esphome(argv): _LOGGER.info("ESPHome %s", const.__version__) - for conf_path in args.configuration: - conf_path = Path(conf_path) - if any(conf_path.name == x for x in SECRETS_FILES): - _LOGGER.warning("Skipping secrets file %s", conf_path) - continue + # Multiple configurations: use subprocesses to avoid state leakage + # between compilations (e.g., LVGL touchscreen state in module globals) + if len(args.configuration) > 1: + # Build command by reusing argv, replacing all configs with single file + # argv[0] is the program path, skip it since we prefix with "esphome" + def build_command(f): + return ( + ["esphome"] + + [arg for arg in argv[1:] if arg not in args.configuration] + + [str(f)] + ) - CORE.config_path = conf_path - CORE.dashboard = args.dashboard + return run_multiple_configs(args.configuration, build_command) - # For logs command, skip updating external components - skip_external = args.command == "logs" - config = read_config( - dict(args.substitution) if args.substitution else {}, - skip_external_update=skip_external, - ) - if config is None: - return 2 - CORE.config = config + # Single configuration + conf_path = Path(args.configuration[0]) + if any(conf_path.name == x for x in SECRETS_FILES): + _LOGGER.warning("Skipping secrets file %s", conf_path) + return 0 - if args.command not in POST_CONFIG_ACTIONS: - safe_print(f"Unknown command {args.command}") + CORE.config_path = conf_path + CORE.dashboard = args.dashboard - try: - rc = POST_CONFIG_ACTIONS[args.command](args, config) - except EsphomeError as e: - _LOGGER.error(e, exc_info=args.verbose) - return 1 - if rc != 0: - return rc + # For logs command, skip updating external components + skip_external = args.command == "logs" + config = read_config( + dict(args.substitution) if args.substitution else {}, + skip_external_update=skip_external, + ) + if config is None: + return 2 + CORE.config = config - CORE.reset() - return 0 + if args.command not in POST_CONFIG_ACTIONS: + safe_print(f"Unknown command {args.command}") + return 1 + + try: + return POST_CONFIG_ACTIONS[args.command](args, config) + except EsphomeError as e: + _LOGGER.error(e, exc_info=args.verbose) + return 1 def main(): diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index 64450b3eb0..fef4f1d852 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -18,31 +18,31 @@ AnovaPacket *AnovaCodec::clean_packet_() { AnovaPacket *AnovaCodec::get_read_device_status_request() { this->current_query_ = READ_DEVICE_STATUS; - strncpy((char *) this->packet_.data, CMD_READ_DEVICE_STATUS, sizeof(this->packet_.data)); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_DEVICE_STATUS); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_target_temp_request() { this->current_query_ = READ_TARGET_TEMPERATURE; - strncpy((char *) this->packet_.data, CMD_READ_TARGET_TEMP, sizeof(this->packet_.data)); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_TARGET_TEMP); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_current_temp_request() { this->current_query_ = READ_CURRENT_TEMPERATURE; - strncpy((char *) this->packet_.data, CMD_READ_CURRENT_TEMP, sizeof(this->packet_.data)); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_CURRENT_TEMP); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_unit_request() { this->current_query_ = READ_UNIT; - strncpy((char *) this->packet_.data, CMD_READ_UNIT, sizeof(this->packet_.data)); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_UNIT); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_data_request() { this->current_query_ = READ_DATA; - strncpy((char *) this->packet_.data, CMD_READ_DATA, sizeof(this->packet_.data)); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_DATA); return this->clean_packet_(); } @@ -62,13 +62,13 @@ AnovaPacket *AnovaCodec::get_set_unit_request(char unit) { AnovaPacket *AnovaCodec::get_start_request() { this->current_query_ = START; - strncpy((char *) this->packet_.data, CMD_START, sizeof(this->packet_.data)); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_START); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_stop_request() { this->current_query_ = STOP; - strncpy((char *) this->packet_.data, CMD_STOP, sizeof(this->packet_.data)); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_STOP); return this->clean_packet_(); } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 25512de4c7..0364879ccd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1715,7 +1715,7 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes // HA state max length is 255 characters, but attributes can be much longer // Use stack buffer for common case (states), heap fallback for large attributes size_t state_len = msg.state.size(); - SmallBufferWithHeapFallback<256> state_buf_alloc(state_len + 1); + SmallBufferWithHeapFallback state_buf_alloc(state_len + 1); char *state_buf = reinterpret_cast(state_buf_alloc.get()); if (state_len > 0) { memcpy(state_buf, msg.state.c_str(), state_len); diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index a59cb8104b..fd5bc97596 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -224,12 +224,9 @@ class MipiSpi : public display::Display, this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", DISPLAYPIXEL * 8, IS_BIG_ENDIAN ? "Big" : "Little"); if (this->brightness_.has_value()) esph_log_config(TAG, " Brightness: %u", this->brightness_.value()); - if (this->cs_ != nullptr) - esph_log_config(TAG, " CS Pin: %s", this->cs_->dump_summary().c_str()); - if (this->reset_pin_ != nullptr) - esph_log_config(TAG, " Reset Pin: %s", this->reset_pin_->dump_summary().c_str()); - if (this->dc_pin_ != nullptr) - esph_log_config(TAG, " DC Pin: %s", this->dc_pin_->dump_summary().c_str()); + log_pin(TAG, " CS Pin: ", this->cs_); + log_pin(TAG, " Reset Pin: ", this->reset_pin_); + log_pin(TAG, " DC Pin: ", this->dc_pin_); esph_log_config(TAG, " SPI Mode: %d\n" " SPI Data rate: %dMHz\n" diff --git a/esphome/components/mipi_spi/models/adafruit.py b/esphome/components/mipi_spi/models/adafruit.py index 0e91107bee..26790b1493 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -26,5 +26,3 @@ ST7789V.extend( reset_pin=40, invert_colors=True, ) - -models = {} diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 4d6c8da4b0..32cad70ac0 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -105,6 +105,3 @@ CO5300 = DriverChip( (WCE, 0x00), ), ) - - -models = {} diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index a25ecf33a8..7229412f18 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,10 +1,45 @@ -from .ili import ILI9341 +from .ili import ILI9341, ILI9342, ST7789V ILI9341.extend( + # ESP32-2432S028 CYD board with Micro USB, has ILI9341 controller "ESP32-2432S028", data_rate="40MHz", - cs_pin=15, - dc_pin=2, + cs_pin={"number": 15, "ignore_strapping_warning": True}, + dc_pin={"number": 2, "ignore_strapping_warning": True}, ) -models = {} +ST7789V.extend( + # ESP32-2432S028 CYD board with USB C + Micro USB, has ST7789V controller + "ESP32-2432S028-7789", + data_rate="40MHz", + cs_pin={"number": 15, "ignore_strapping_warning": True}, + dc_pin={"number": 2, "ignore_strapping_warning": True}, +) + +# fmt: off + +ILI9342.extend( + # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller + "ESP32-2432S028-9342", + data_rate="40MHz", + cs_pin={"number": 15, "ignore_strapping_warning": True}, + dc_pin={"number": 2, "ignore_strapping_warning": True}, + initsequence=( + (0xCB, 0x39, 0x2C, 0x00, 0x34, 0x02), # Power Control A + (0xCF, 0x00, 0xC1, 0x30), # Power Control B + (0xE8, 0x85, 0x00, 0x78), # Driver timing control A + (0xEA, 0x00, 0x00), # Driver timing control B + (0xED, 0x64, 0x03, 0x12, 0x81), # Power on sequence control + (0xF7, 0x20), # Pump ratio control + (0xC0, 0x23), # Power Control 1 + (0xC1, 0x10), # Power Control 2 + (0xC5, 0x3E, 0x28), # VCOM Control 1 + (0xC7, 0x86), # VCOM Control 2 + (0xB1, 0x00, 0x1B), # Frame Rate Control + (0xB6, 0x0A, 0xA2, 0x27, 0x00), # Display Function Control + (0xF2, 0x00), # Enable 3G + (0x26, 0x01), # Gamma Set + (0xE0, 0x00, 0x0C, 0x11, 0x04, 0x11, 0x08, 0x37, 0x89, 0x4C, 0x06, 0x0C, 0x0A, 0x2E, 0x34, 0x0F), # Positive Gamma Correction + (0xE1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x23, 0x33, 0x0F), # Negative Gamma Correction + ) +) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 60a25c32a9..6b672b0859 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -148,6 +148,34 @@ ILI9341 = DriverChip( ), ), ) + +# fmt: off + +ILI9342 = DriverChip( + "ILI9342", + width=320, + height=240, + mirror_x=True, + initsequence=( + (0xCB, 0x39, 0x2C, 0x00, 0x34, 0x02), # Power Control A + (0xCF, 0x00, 0xC1, 0x30), # Power Control B + (0xE8, 0x85, 0x00, 0x78), # Driver timing control A + (0xEA, 0x00, 0x00), # Driver timing control B + (0xED, 0x64, 0x03, 0x12, 0x81), # Power on sequence control + (0xF7, 0x20), # Pump ratio control + (0xC0, 0x23), # Power Control 1 + (0xC1, 0x10), # Power Control 2 + (0xC5, 0x3E, 0x28), # VCOM Control 1 + (0xC7, 0x86), # VCOM Control 2 + (0xB1, 0x00, 0x1B), # Frame Rate Control + (0xB6, 0x0A, 0xA2, 0x27, 0x00), # Display Function Control + (0xF2, 0x00), # Enable 3G + (0x26, 0x01), # Gamma Set + (0xE0, 0x0F, 0x1F, 0x1C, 0x0C, 0x0F, 0x08, 0x48, 0x98, 0x37, 0x0A, 0x13, 0x04, 0x11, 0x0D, 0x00), # Positive Gamma + (0xE1, 0x0F, 0x32, 0x2E, 0x0B, 0x0D, 0x05, 0x47, 0x75, 0x37, 0x06, 0x10, 0x03, 0x24, 0x20, 0x00), # Negative Gamma + ), +) + # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", @@ -758,5 +786,3 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) - -models = {} diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 5b936fd956..854814f572 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -588,5 +588,3 @@ DriverChip( (0x29, 0x00), ), ) - -models = {} diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 6f9aa58674..8cec3c8317 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -11,5 +11,3 @@ ST7789V.extend( dc_pin=21, reset_pin=18, ) - -models = {} diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 13ddc67465..46ec809029 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -56,5 +56,3 @@ ST7796.extend( backlight_pin=48, invert_colors=True, ) - -models = {} diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fd8f04ded5..3268f7ee87 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -34,6 +34,7 @@ from esphome.__main__ import ( has_non_ip_address, has_resolvable_address, mqtt_get_ip, + run_esphome, run_miniterm, show_logs, upload_program, @@ -1988,7 +1989,7 @@ esp32: clean_output = strip_ansi_codes(captured.out) assert "test-device_123.yaml" in clean_output - assert "Updating" in clean_output + assert "Processing" in clean_output assert "SUCCESS" in clean_output assert "SUMMARY" in clean_output @@ -3172,3 +3173,66 @@ def test_run_miniterm_buffer_limit_prevents_unbounded_growth() -> None: x_count = printed_line.count("X") assert x_count < 150, f"Expected truncation but got {x_count} X's" assert x_count == 95, f"Expected 95 X's after truncation but got {x_count}" + + +def test_run_esphome_multiple_configs_with_secrets( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test run_esphome with multiple configs and secrets file. + + Verifies: + - Multiple configs use subprocess isolation + - Secrets files are skipped with warning + - Secrets files don't appear in summary + """ + # Create two config files and a secrets file + yaml_file1 = tmp_path / "device1.yaml" + yaml_file1.write_text(""" +esphome: + name: device1 + +esp32: + board: nodemcu-32s +""") + yaml_file2 = tmp_path / "device2.yaml" + yaml_file2.write_text(""" +esphome: + name: device2 + +esp32: + board: nodemcu-32s +""") + secrets_file = tmp_path / "secrets.yaml" + secrets_file.write_text("wifi_password: secret123\n") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + # run_esphome expects argv[0] to be the program name (gets sliced off by parse_args) + with caplog.at_level(logging.WARNING): + result = run_esphome( + ["esphome", "compile", str(yaml_file1), str(secrets_file), str(yaml_file2)] + ) + + assert result == 0 + + # Check secrets file was skipped with warning + assert "Skipping secrets file" in caplog.text + assert "secrets.yaml" in caplog.text + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Both config files should be processed + assert "device1.yaml" in clean_output + assert "device2.yaml" in clean_output + assert "SUMMARY" in clean_output + + # Secrets should not appear in summary + summary_section = ( + clean_output.split("SUMMARY")[1] if "SUMMARY" in clean_output else "" + ) + assert "secrets.yaml" not in summary_section