From ebdcb3e4d96bf0ce41796a37255145ff8cbac3ca Mon Sep 17 00:00:00 2001 From: esphomebot Date: Sun, 21 Sep 2025 01:09:21 +1200 Subject: [PATCH 1/4] Synchronise Device Classes from Home Assistant (#10803) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/number/__init__.py | 2 ++ esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 3 files changed, 5 insertions(+) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index c2cad2f7f1..76a7b05ea1 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -51,6 +51,7 @@ from esphome.const import ( DEVICE_CLASS_OZONE, DEVICE_CLASS_PH, DEVICE_CLASS_PM1, + DEVICE_CLASS_PM4, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, @@ -116,6 +117,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, + DEVICE_CLASS_PM4, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRECIPITATION, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index fe9822b3ca..2b99f68ac0 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -74,6 +74,7 @@ from esphome.const import ( DEVICE_CLASS_OZONE, DEVICE_CLASS_PH, DEVICE_CLASS_PM1, + DEVICE_CLASS_PM4, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, @@ -143,6 +144,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, + DEVICE_CLASS_PM4, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRECIPITATION, diff --git a/esphome/const.py b/esphome/const.py index c91c81f484..3e93200f14 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1269,6 +1269,7 @@ DEVICE_CLASS_PLUG = "plug" DEVICE_CLASS_PM1 = "pm1" DEVICE_CLASS_PM10 = "pm10" DEVICE_CLASS_PM25 = "pm25" +DEVICE_CLASS_PM4 = "pm4" DEVICE_CLASS_POWER = "power" DEVICE_CLASS_POWER_FACTOR = "power_factor" DEVICE_CLASS_PRECIPITATION = "precipitation" From e3b64103cc2fb4c6365c838d39527f8cd439a87f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 20 Sep 2025 22:23:33 -0400 Subject: [PATCH 2/4] [sensirion] Fix warning (#10813) --- esphome/components/sensirion_common/i2c_sensirion.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index 22c4b0e53c..9eac6b4525 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -76,7 +76,8 @@ bool SensirionI2CDevice::write_command_(uint16_t command, CommandLen command_len temp[raw_idx++] = data[i] >> 8; #endif // Use MSB first since Sensirion devices use CRC-8 with MSB first - temp[raw_idx++] = crc8(&temp[raw_idx - 2], 2, 0xFF, CRC_POLYNOMIAL, true); + uint8_t crc = crc8(&temp[raw_idx - 2], 2, 0xFF, CRC_POLYNOMIAL, true); + temp[raw_idx++] = crc; } this->last_error_ = this->write(temp, raw_idx); return this->last_error_ == i2c::ERROR_OK; From 7ea680a802b6b64ebe70b9c679cc5dfd74bee183 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:00:49 -0600 Subject: [PATCH 3/4] [core] Fix TypeError in update-all command after Path migration --- esphome/__main__.py | 10 +- tests/unit_tests/test_main.py | 244 ++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index fff66bcd50..b63720d672 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -772,7 +772,7 @@ 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, f)}") + safe_print(f"Updating {color(AnsiFore.CYAN, str(f))}") safe_print("-" * twidth) safe_print() if CORE.dashboard: @@ -784,10 +784,10 @@ def command_update_all(args: ArgsProtocol) -> int | None: "esphome", "run", f, "--no-logs", "--device", "OTA" ) if rc == 0: - print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {f}") + print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") success[f] = True else: - print_bar(f"[{color(AnsiFore.BOLD_RED, 'ERROR')}] {f}") + print_bar(f"[{color(AnsiFore.BOLD_RED, 'ERROR')}] {str(f)}") success[f] = False safe_print() @@ -798,9 +798,9 @@ def command_update_all(args: ArgsProtocol) -> int | None: failed = 0 for f in files: if success[f]: - safe_print(f" - {f}: {color(AnsiFore.GREEN, 'SUCCESS')}") + safe_print(f" - {str(f)}: {color(AnsiFore.GREEN, 'SUCCESS')}") else: - safe_print(f" - {f}: {color(AnsiFore.BOLD_RED, 'FAILED')}") + safe_print(f" - {str(f)}: {color(AnsiFore.BOLD_RED, 'FAILED')}") failed += 1 return failed diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e805ecb2eb..49cbd1d597 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass from pathlib import Path +import re from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -15,6 +16,7 @@ from esphome.__main__ import ( Purpose, choose_upload_log_host, command_rename, + command_update_all, command_wizard, get_port_type, has_ip_address, @@ -55,6 +57,17 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError +def strip_ansi_codes(text: str) -> str: + """Remove ANSI escape codes from text. + + This helps make test assertions cleaner by removing color codes and other + terminal formatting that can make tests brittle. + """ + # Pattern to match ANSI escape sequences + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + @dataclass class MockSerialPort: """Mock serial port for testing. @@ -1545,3 +1558,234 @@ esp32: captured = capfd.readouterr() assert "Rename failed" in captured.out + + +def test_command_update_all_path_string_conversion( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test that command_update_all properly converts Path objects to strings in output.""" + # Create test YAML files + yaml1 = tmp_path / "device1.yaml" + yaml1.write_text(""" +esphome: + name: device1 + +esp32: + board: nodemcu-32s +""") + + yaml2 = tmp_path / "device2.yaml" + yaml2.write_text(""" +esphome: + name: device2 + +esp8266: + board: nodemcuv2 +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock successful updates + mock_run_external_process.return_value = 0 + + # Create args with the directory as configuration + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed + assert result == 0 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check that Path objects were properly converted to strings + # The output should contain file paths without causing TypeError + assert "device1.yaml" in clean_output + assert "device2.yaml" in clean_output + assert "SUCCESS" in clean_output + assert "SUMMARY" in clean_output + + # Verify run_external_process was called for each file + assert mock_run_external_process.call_count == 2 + + +def test_command_update_all_with_failures( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all handles mixed success/failure cases properly.""" + # Create test YAML files + yaml1 = tmp_path / "success_device.yaml" + yaml1.write_text(""" +esphome: + name: success_device + +esp32: + board: nodemcu-32s +""") + + yaml2 = tmp_path / "failed_device.yaml" + yaml2.write_text(""" +esphome: + name: failed_device + +esp8266: + board: nodemcuv2 +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock mixed results - first succeeds, second fails + mock_run_external_process.side_effect = [0, 1] + + # Create args with the directory as configuration + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should return 1 (failure) since one device failed + assert result == 1 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check that both success and failure are properly displayed + assert "SUCCESS" in clean_output + assert "ERROR" in clean_output or "FAILED" in clean_output + assert "SUMMARY" in clean_output + + # Files are processed in alphabetical order, so we need to check which one succeeded/failed + # The mock_run_external_process.side_effect = [0, 1] applies to files in alphabetical order + # So "failed_device.yaml" gets 0 (success) and "success_device.yaml" gets 1 (failure) + assert "failed_device.yaml: SUCCESS" in clean_output + assert "success_device.yaml: FAILED" in clean_output + + +def test_command_update_all_empty_directory( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all with an empty directory (no YAML files).""" + # Set up CORE with empty directory + setup_core(tmp_path=tmp_path) + + # Create args with the directory as configuration + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed with no updates + assert result == 0 + + # Should not have called run_external_process + mock_run_external_process.assert_not_called() + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Should still show summary + assert "SUMMARY" in clean_output + + +def test_command_update_all_single_file( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all with a single YAML file specified.""" + # Create test YAML file + yaml_file = tmp_path / "single_device.yaml" + yaml_file.write_text(""" +esphome: + name: single_device + +esp32: + board: nodemcu-32s +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock successful update + mock_run_external_process.return_value = 0 + + # Create args with single file as configuration + args = MockArgs(configuration=[str(yaml_file)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed + assert result == 0 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check output + assert "single_device.yaml" in clean_output + assert "SUCCESS" in clean_output + + # Verify run_external_process was called once + mock_run_external_process.assert_called_once() + + +def test_command_update_all_path_formatting_in_color_calls( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test that Path objects are properly converted when passed to color() function.""" + # Create a test YAML file with special characters in name + yaml_file = tmp_path / "test-device_123.yaml" + yaml_file.write_text(""" +esphome: + name: test-device_123 + +esp32: + board: nodemcu-32s +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock successful update + mock_run_external_process.return_value = 0 + + # Create args + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed + assert result == 0 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # The file path should appear in the output without causing TypeError + assert "test-device_123.yaml" in clean_output + + # Check that output contains expected content + assert "Updating" in clean_output + assert "SUCCESS" in clean_output + assert "SUMMARY" in clean_output + + # Should not have any Python error messages + assert "TypeError" not in clean_output + assert "can only concatenate str" not in clean_output From 56be0dfc905d5b0ae8fba797ae2734398f32d393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:04:02 -0600 Subject: [PATCH 4/4] preen --- tests/unit_tests/test_main.py | 73 +++-------------------------------- 1 file changed, 5 insertions(+), 68 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 49cbd1d597..da280b1fd8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1566,7 +1566,6 @@ def test_command_update_all_path_string_conversion( capfd: CaptureFixture[str], ) -> None: """Test that command_update_all properly converts Path objects to strings in output.""" - # Create test YAML files yaml1 = tmp_path / "device1.yaml" yaml1.write_text(""" esphome: @@ -1585,22 +1584,11 @@ esp8266: board: nodemcuv2 """) - # Set up CORE setup_core(tmp_path=tmp_path) - - # Mock successful updates mock_run_external_process.return_value = 0 - # Create args with the directory as configuration - args = MockArgs(configuration=[str(tmp_path)]) + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 - # Run command_update_all - result = command_update_all(args) - - # Should succeed - assert result == 0 - - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) @@ -1621,7 +1609,6 @@ def test_command_update_all_with_failures( capfd: CaptureFixture[str], ) -> None: """Test command_update_all handles mixed success/failure cases properly.""" - # Create test YAML files yaml1 = tmp_path / "success_device.yaml" yaml1.write_text(""" esphome: @@ -1640,22 +1627,14 @@ esp8266: board: nodemcuv2 """) - # Set up CORE setup_core(tmp_path=tmp_path) # Mock mixed results - first succeeds, second fails mock_run_external_process.side_effect = [0, 1] - # Create args with the directory as configuration - args = MockArgs(configuration=[str(tmp_path)]) - - # Run command_update_all - result = command_update_all(args) - # Should return 1 (failure) since one device failed - assert result == 1 + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 1 - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) @@ -1677,26 +1656,14 @@ def test_command_update_all_empty_directory( capfd: CaptureFixture[str], ) -> None: """Test command_update_all with an empty directory (no YAML files).""" - # Set up CORE with empty directory setup_core(tmp_path=tmp_path) - # Create args with the directory as configuration - args = MockArgs(configuration=[str(tmp_path)]) - - # Run command_update_all - result = command_update_all(args) - - # Should succeed with no updates - assert result == 0 - - # Should not have called run_external_process + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 mock_run_external_process.assert_not_called() - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) - # Should still show summary assert "SUMMARY" in clean_output @@ -1706,7 +1673,6 @@ def test_command_update_all_single_file( capfd: CaptureFixture[str], ) -> None: """Test command_update_all with a single YAML file specified.""" - # Create test YAML file yaml_file = tmp_path / "single_device.yaml" yaml_file.write_text(""" esphome: @@ -1716,30 +1682,16 @@ esp32: board: nodemcu-32s """) - # Set up CORE setup_core(tmp_path=tmp_path) - - # Mock successful update mock_run_external_process.return_value = 0 - # Create args with single file as configuration - args = MockArgs(configuration=[str(yaml_file)]) + assert command_update_all(MockArgs(configuration=[str(yaml_file)])) == 0 - # Run command_update_all - result = command_update_all(args) - - # Should succeed - assert result == 0 - - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) - # Check output assert "single_device.yaml" in clean_output assert "SUCCESS" in clean_output - - # Verify run_external_process was called once mock_run_external_process.assert_called_once() @@ -1749,7 +1701,6 @@ def test_command_update_all_path_formatting_in_color_calls( capfd: CaptureFixture[str], ) -> None: """Test that Path objects are properly converted when passed to color() function.""" - # Create a test YAML file with special characters in name yaml_file = tmp_path / "test-device_123.yaml" yaml_file.write_text(""" esphome: @@ -1759,29 +1710,15 @@ esp32: board: nodemcu-32s """) - # Set up CORE setup_core(tmp_path=tmp_path) - - # Mock successful update mock_run_external_process.return_value = 0 - # Create args - args = MockArgs(configuration=[str(tmp_path)]) + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 - # Run command_update_all - result = command_update_all(args) - - # Should succeed - assert result == 0 - - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) - # The file path should appear in the output without causing TypeError assert "test-device_123.yaml" in clean_output - - # Check that output contains expected content assert "Updating" in clean_output assert "SUCCESS" in clean_output assert "SUMMARY" in clean_output