diff --git a/esphome/__main__.py b/esphome/__main__.py index 1db046edf0..327741bb75 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -779,7 +779,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: @@ -791,10 +791,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() @@ -805,9 +805,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/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/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; 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" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e805ecb2eb..da280b1fd8 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,171 @@ 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.""" + 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 +""") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 + + 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.""" + 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 +""") + + setup_core(tmp_path=tmp_path) + + # Mock mixed results - first succeeds, second fails + mock_run_external_process.side_effect = [0, 1] + + # Should return 1 (failure) since one device failed + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 1 + + 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).""" + setup_core(tmp_path=tmp_path) + + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 + mock_run_external_process.assert_not_called() + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + 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.""" + yaml_file = tmp_path / "single_device.yaml" + yaml_file.write_text(""" +esphome: + name: single_device + +esp32: + board: nodemcu-32s +""") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + assert command_update_all(MockArgs(configuration=[str(yaml_file)])) == 0 + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + assert "single_device.yaml" in clean_output + assert "SUCCESS" in clean_output + 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.""" + yaml_file = tmp_path / "test-device_123.yaml" + yaml_file.write_text(""" +esphome: + name: test-device_123 + +esp32: + board: nodemcu-32s +""") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + assert "test-device_123.yaml" in clean_output + 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