From d5c36eaf2a3f4cf020a77ce6cd8213b014038e7c Mon Sep 17 00:00:00 2001 From: Juan Antonio Aldea Date: Sat, 18 Oct 2025 09:40:54 +0200 Subject: [PATCH 1/8] [tests] Remove superfluous else-blocks from lambdas (#11322) Co-authored-by: J. Nick Koston --- .../components/absolute_humidity/common.yaml | 6 ++---- tests/components/analog_threshold/common.yaml | 3 +-- tests/components/binary_sensor/common.yaml | 3 +-- .../components/binary_sensor_map/common.yaml | 9 +++------ tests/components/combination/common.yaml | 6 ++---- tests/components/duty_time/common.yaml | 3 +-- tests/components/endstop/common.yaml | 3 +-- tests/components/lock/common.yaml | 3 +-- tests/components/mqtt/common.yaml | 20 +++++++------------ tests/components/pid/common.yaml | 3 +-- tests/components/prometheus/common.yaml | 18 ++++++----------- tests/components/template/common-base.yaml | 18 ++++++----------- .../batch_delay_zero_rapid_transitions.yaml | 11 +++++----- 13 files changed, 37 insertions(+), 69 deletions(-) diff --git a/tests/components/absolute_humidity/common.yaml b/tests/components/absolute_humidity/common.yaml index 87a99f5206..026f88654f 100644 --- a/tests/components/absolute_humidity/common.yaml +++ b/tests/components/absolute_humidity/common.yaml @@ -8,14 +8,12 @@ sensor: lambda: |- if (millis() > 10000) { return 0.6; - } else { - return 0.0; } + return 0.0; - platform: template id: template_temperature lambda: |- if (millis() > 10000) { return 42.0; - } else { - return 0.0; } + return 0.0; diff --git a/tests/components/analog_threshold/common.yaml b/tests/components/analog_threshold/common.yaml index 44d79756b5..26c401b92a 100644 --- a/tests/components/analog_threshold/common.yaml +++ b/tests/components/analog_threshold/common.yaml @@ -5,9 +5,8 @@ sensor: lambda: |- if (millis() > 10000) { return 42.0; - } else { - return 0.0; } + return 0.0; update_interval: 15s binary_sensor: diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 2b4a006352..ed6322768f 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -23,9 +23,8 @@ binary_sensor: - lambda: |- if (id(some_binary_sensor).state) { return x; - } else { - return {}; } + return {}; - settle: 100ms - timeout: 10s diff --git a/tests/components/binary_sensor_map/common.yaml b/tests/components/binary_sensor_map/common.yaml index 2fed5ae515..c054022583 100644 --- a/tests/components/binary_sensor_map/common.yaml +++ b/tests/components/binary_sensor_map/common.yaml @@ -4,25 +4,22 @@ binary_sensor: lambda: |- if (millis() > 10000) { return true; - } else { - return false; } + return false; - platform: template id: bin2 lambda: |- if (millis() > 20000) { return true; - } else { - return false; } + return false; - platform: template id: bin3 lambda: |- if (millis() > 30000) { return true; - } else { - return false; } + return false; sensor: - platform: binary_sensor_map diff --git a/tests/components/combination/common.yaml b/tests/components/combination/common.yaml index 62246190af..0e5d512d08 100644 --- a/tests/components/combination/common.yaml +++ b/tests/components/combination/common.yaml @@ -4,17 +4,15 @@ sensor: lambda: |- if (millis() > 10000) { return 0.6; - } else { - return 0.0; } + return 0.0; - platform: template id: template_temperature2 lambda: |- if (millis() > 20000) { return 0.8; - } else { - return 0.0; } + return 0.0; - platform: combination type: kalman name: Kalman-filtered temperature diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 28fa4afd1c..761d10f16a 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -4,9 +4,8 @@ binary_sensor: lambda: |- if (millis() > 10000) { return true; - } else { - return false; } + return false; sensor: - platform: duty_time diff --git a/tests/components/endstop/common.yaml b/tests/components/endstop/common.yaml index 341fbf7260..b92b1e13b9 100644 --- a/tests/components/endstop/common.yaml +++ b/tests/components/endstop/common.yaml @@ -4,9 +4,8 @@ binary_sensor: lambda: |- if (millis() > 10000) { return true; - } else { - return false; } + return false; switch: - platform: template diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 1ee88a239a..9ba7f34857 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -17,9 +17,8 @@ lock: lambda: |- if (millis() > 10000) { return LOCK_STATE_LOCKED; - } else { - return LOCK_STATE_UNLOCKED; } + return LOCK_STATE_UNLOCKED; optimistic: true assumed_state: false on_unlock: diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 1ab8872fdb..3f1b83bb01 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -72,10 +72,9 @@ binary_sensor: if (id(template_sens).state > 30) { // Garage Door is open. return true; - } else { - // Garage Door is closed. - return false; } + // Garage Door is closed. + return false; on_state: - mqtt.publish: topic: some/topic/binary_sensor @@ -217,9 +216,8 @@ cover: lambda: |- if (id(some_binary_sensor).state) { return COVER_OPEN; - } else { - return COVER_CLOSED; } + return COVER_CLOSED; open_action: - logger.log: open_action close_action: @@ -321,9 +319,8 @@ lock: lambda: |- if (id(some_binary_sensor).state) { return LOCK_STATE_LOCKED; - } else { - return LOCK_STATE_UNLOCKED; } + return LOCK_STATE_UNLOCKED; lock_action: - logger.log: lock_action unlock_action: @@ -360,9 +357,8 @@ sensor: lambda: |- if (id(some_binary_sensor).state) { return 42.0; - } else { - return 0.0; } + return 0.0; update_interval: 60s on_value: - mqtt.publish: @@ -390,9 +386,8 @@ switch: lambda: |- if (id(some_binary_sensor).state) { return true; - } else { - return false; } + return false; turn_on_action: - logger.log: turn_on_action turn_off_action: @@ -436,9 +431,8 @@ valve: lambda: |- if (id(some_binary_sensor).state) { return VALVE_OPEN; - } else { - return VALVE_CLOSED; } + return VALVE_CLOSED; alarm_control_panel: - platform: template diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 5f7762872f..262e75591e 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -27,9 +27,8 @@ sensor: lambda: |- if (millis() > 10000) { return 42.0; - } else { - return 0.0; } + return 0.0; update_interval: 60s climate: diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index a9354ebe3c..cf46e882a7 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -35,9 +35,8 @@ sensor: lambda: |- if (millis() > 10000) { return 42.0; - } else { - return 0.0; } + return 0.0; update_interval: 60s text_sensor: @@ -49,9 +48,8 @@ text_sensor: lambda: |- if (millis() > 10000) { return {"Hello World"}; - } else { - return {"Goodbye (cruel) World"}; } + return {"Goodbye (cruel) World"}; update_interval: 60s binary_sensor: @@ -60,9 +58,8 @@ binary_sensor: lambda: |- if (millis() > 10000) { return true; - } else { - return false; } + return false; switch: - platform: template @@ -70,9 +67,8 @@ switch: lambda: |- if (millis() > 10000) { return true; - } else { - return false; } + return false; optimistic: true fan: @@ -85,9 +81,8 @@ cover: lambda: |- if (millis() > 10000) { return COVER_OPEN; - } else { - return COVER_CLOSED; } + return COVER_CLOSED; lock: - platform: template @@ -95,9 +90,8 @@ lock: lambda: |- if (millis() > 10000) { return LOCK_STATE_LOCKED; - } else { - return LOCK_STATE_UNLOCKED; } + return LOCK_STATE_UNLOCKED; optimistic: true select: diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 48537d21bc..ea812532d4 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -59,9 +59,8 @@ binary_sensor: - lambda: |- if (id(other_binary_sensor).state) { return x; - } else { - return {}; } + return {}; - settle: 500ms - timeout: 5s @@ -72,9 +71,8 @@ sensor: lambda: |- if (id(some_binary_sensor).state) { return 42.0; - } else { - return 0.0; } + return 0.0; update_interval: 60s filters: - calibrate_linear: @@ -183,9 +181,8 @@ switch: lambda: |- if (id(some_binary_sensor).state) { return true; - } else { - return false; } + return false; turn_on_action: - logger.log: "turn_on_action" turn_off_action: @@ -203,9 +200,8 @@ cover: lambda: |- if (id(some_binary_sensor).state) { return COVER_OPEN; - } else { - return COVER_CLOSED; } + return COVER_CLOSED; open_action: - logger.log: open_action close_action: @@ -238,9 +234,8 @@ lock: lambda: |- if (id(some_binary_sensor).state) { return LOCK_STATE_LOCKED; - } else { - return LOCK_STATE_UNLOCKED; } + return LOCK_STATE_UNLOCKED; lock_action: - logger.log: lock_action unlock_action: @@ -255,9 +250,8 @@ valve: lambda: |- if (id(some_binary_sensor).state) { return VALVE_OPEN; - } else { - return VALVE_CLOSED; } + return VALVE_CLOSED; open_action: - logger.log: open_action close_action: diff --git a/tests/integration/fixtures/batch_delay_zero_rapid_transitions.yaml b/tests/integration/fixtures/batch_delay_zero_rapid_transitions.yaml index 32cacfaa79..f7b0fdcb63 100644 --- a/tests/integration/fixtures/batch_delay_zero_rapid_transitions.yaml +++ b/tests/integration/fixtures/batch_delay_zero_rapid_transitions.yaml @@ -34,10 +34,9 @@ binary_sensor: ESP_LOGD("test", "Button ON at %u", now); } return true; - } else { - // Only log state change - if (id(ir_remote_button).state) { - ESP_LOGD("test", "Button OFF at %u", now); - } - return false; } + // Only log state change + if (id(ir_remote_button).state) { + ESP_LOGD("test", "Button OFF at %u", now); + } + return false; From 91a10d0e3672f148aa1fcf88346285534ef09071 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Oct 2025 02:36:30 -1000 Subject: [PATCH 2/8] [total_daily_energy] Fix ID conflicts in component test configuration (#11337) --- tests/components/total_daily_energy/common.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/total_daily_energy/common.yaml b/tests/components/total_daily_energy/common.yaml index ae4d30408b..dd7e648da6 100644 --- a/tests/components/total_daily_energy/common.yaml +++ b/tests/components/total_daily_energy/common.yaml @@ -17,10 +17,10 @@ sensor: name: HLW8012 Voltage power: name: HLW8012 Power - id: hlw8012_power + id: total_daily_energy_hlw8012_power energy: name: HLW8012 Energy - id: hlw8012_energy + id: total_daily_energy_hlw8012_energy update_interval: 15s current_resistor: 0.001 ohm voltage_divider: 2351 @@ -29,4 +29,4 @@ sensor: model: hlw8012 - platform: total_daily_energy name: HLW8012 Total Daily Energy - power_id: hlw8012_power + power_id: total_daily_energy_hlw8012_power From ae010fd6f18515dda58d39902ab49771ccb0a782 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 18 Oct 2025 19:32:12 +0200 Subject: [PATCH 3/8] [dashboard] fix migration to Path (#11342) Co-authored-by: J. Nick Koston --- esphome/dashboard/web_server.py | 3 +- tests/dashboard/test_web_server.py | 194 +++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index a79c67c3d2..804a2b99af 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1058,7 +1058,8 @@ class DownloadBinaryRequestHandler(BaseHandler): "download", f"{storage_json.name}-{file_name}", ) - path = storage_json.firmware_bin_path.with_name(file_name) + + path = storage_json.firmware_bin_path.parent.joinpath(file_name) if not path.is_file(): args = ["esphome", "idedata", settings.rel_path(configuration)] diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 6c424e56d4..385841b1c8 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -35,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport from .common import get_fixture_path +def get_build_path(base_path: Path, device_name: str) -> Path: + """Get the build directory path for a device. + + This is a test helper that constructs the standard ESPHome build directory + structure. Note: This helper does NOT perform path traversal sanitization + because it's only used in tests where we control the inputs. The actual + web_server.py code handles sanitization in DownloadBinaryRequestHandler.get() + via file_name.replace("..", "").lstrip("/"). + + Args: + base_path: The base temporary path (typically tmp_path from pytest) + device_name: The name of the device (should not contain path separators + in production use, but tests may use it for specific scenarios) + + Returns: + Path to the build directory (.esphome/build/device_name) + """ + return base_path / ".esphome" / "build" / device_name + + class DashboardTestHelper: def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None: self.io_loop = io_loop @@ -417,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback( assert response.body == b"bootloader content" +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_subdirectory_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case). + + This is a regression test for issue #11343 where the Path migration broke + downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'. + + The issue was that with_name() doesn't accept path separators: + - Before: path = storage_json.firmware_bin_path.with_name(file_name) + ValueError: Invalid name 'zephyr/zephyr.uf2' + - After: path = storage_json.firmware_bin_path.parent.joinpath(file_name) + Works correctly with subdirectory paths + """ + # Create a fake nRF52 build structure with firmware in subdirectory + build_dir = get_build_path(tmp_path, "nrf52-device") + zephyr_dir = build_dir / "zephyr" + zephyr_dir.mkdir(parents=True) + + # Create the main firmware binary (would be in build root) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"main firmware") + + # Create the UF2 file in zephyr subdirectory (nRF52 specific) + uf2_file = zephyr_dir / "zephyr.uf2" + uf2_file.write_bytes(b"nRF52 UF2 firmware content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "nrf52-device" + mock_storage.firmware_bin_path = firmware_file + mock_storage_json.load.return_value = mock_storage + + # Request the UF2 file with subdirectory path + response = await dashboard.fetch( + "/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2", + method="GET", + ) + assert response.code == 200 + assert response.body == b"nRF52 UF2 firmware content" + assert response.headers["Content-Type"] == "application/octet-stream" + assert "attachment" in response.headers["Content-Disposition"] + # Download name should be device-name + full file path + assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_subdirectory_file_url_encoded( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path. + + Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly + decoded and handled, and that custom download names work with subdirectories. + """ + # Create a fake build structure with firmware in subdirectory + build_dir = get_build_path(tmp_path, "test") + zephyr_dir = build_dir / "zephyr" + zephyr_dir.mkdir(parents=True) + + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"content") + + uf2_file = zephyr_dir / "zephyr.uf2" + uf2_file.write_bytes(b"content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = firmware_file + mock_storage_json.load.return_value = mock_storage + + # Request with URL-encoded path and custom download name + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin", + method="GET", + ) + assert response.code == 200 + assert "custom_name.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +@pytest.mark.parametrize( + "attack_path", + [ + pytest.param("../../../secrets.yaml", id="basic_traversal"), + pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"), + pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"), + pytest.param("/etc/passwd", id="absolute_path"), + pytest.param("//etc/passwd", id="double_slash_absolute"), + pytest.param("....//secrets.yaml", id="multiple_dots"), + ], +) +async def test_download_binary_handler_path_traversal_protection( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, + attack_path: str, +) -> None: + """Test that DownloadBinaryRequestHandler prevents path traversal attacks. + + Verifies that attempts to use '..' in file paths are sanitized to prevent + accessing files outside the build directory. Tests multiple attack vectors. + """ + # Create build structure + build_dir = get_build_path(tmp_path, "test") + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + + # Create a sensitive file outside the build directory that should NOT be accessible + sensitive_file = tmp_path / "secrets.yaml" + sensitive_file.write_bytes(b"secret: my_secret_password") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = firmware_file + mock_storage_json.load.return_value = mock_storage + + # Attempt path traversal attack - should be blocked + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + f"/download.bin?configuration=test.yaml&file={attack_path}", + method="GET", + ) + # Should get 404 (file not found after sanitization) or 500 (idedata fails) + assert exc_info.value.code in (404, 500) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_multiple_subdirectory_levels( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test downloading files from multiple subdirectory levels. + + Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'. + """ + # Create nested directory structure + build_dir = get_build_path(tmp_path, "test") + nested_dir = build_dir / "build" / "output" + nested_dir.mkdir(parents=True) + + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"main") + + nested_file = nested_dir / "firmware.bin" + nested_file.write_bytes(b"nested firmware content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = firmware_file + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=build/output/firmware.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"nested firmware content" + + @pytest.mark.asyncio async def test_edit_request_handler_post_invalid_file( dashboard: DashboardTestHelper, From 865663ce5f4f59301efccf4477f3efe76aec3d1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Oct 2025 11:48:25 -1000 Subject: [PATCH 4/8] Bump aioesphomeapi from 42.0.0 to 42.1.0 (#11350) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92ab24e754..2f25905d94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.0.0 +aioesphomeapi==42.1.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From ef52ce4d76e5a5c5d469b64b79bd7d83a15b349c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Oct 2025 11:56:40 -1000 Subject: [PATCH 5/8] [api_protobuf] Address copilot review: add bounds checking and clarify 32-bit loop intent - Add bounds checking in decode_varint_content to prevent undefined behavior if decoded enum value exceeds 31 - Add clarifying comments that 32-bit loops in encode_content and get_size_calculation are intentional to support the full range of enum_as_bitmask (enums with up to 32 values) - The uint32_t storage type supports general-purpose enum_as_bitmask, not just ColorMode's 10 values --- script/api_protobuf/api_protobuf.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 2fe6d01024..f423097b7f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1472,11 +1472,16 @@ class RepeatedTypeInfo(TypeInfo): if self._use_pointer: return None if self._use_bitmask: - # For bitmask fields, decode enum value and set corresponding bit + # For bitmask fields, decode enum value and set corresponding bit, with bounds checking content = self._ti.decode_varint if content is None: return None - return f"case {self.number}: this->{self.field_name} |= (1U << static_cast({content})); break;" + return ( + f"case {self.number}: " + f"if (static_cast({content}) <= 31) " + f"this->{self.field_name} |= (1U << static_cast({content})); " + f"break;" + ) content = self._ti.decode_varint if content is None: return None @@ -1533,6 +1538,9 @@ class RepeatedTypeInfo(TypeInfo): if self._use_bitmask: # For bitmask fields, iterate through set bits and encode each enum value # The bitmask is stored as uint32_t where bit N represents enum value N + # Note: We iterate through all 32 bits to support the full range of enum_as_bitmask + # (enums with up to 32 values). Specific uses may have fewer values, but the + # generated code is general-purpose. assert isinstance(self._ti, EnumType), ( "enum_as_bitmask only works with enum fields" ) @@ -1587,6 +1595,9 @@ class RepeatedTypeInfo(TypeInfo): if self._use_bitmask: # For bitmask fields, iterate through set bits and calculate size # Each set bit encodes one enum value (as varint) + # Note: We iterate through all 32 bits to support the full range of enum_as_bitmask + # (enums with up to 32 values). Specific uses may have fewer values, but the + # generated code is general-purpose. o = f"if ({name} != 0) {{\n" o += " for (uint8_t bit = 0; bit < 32; bit++) {\n" o += f" if ({name} & (1U << bit)) {{\n" From 02b626ae1a9136e5f9c9fcb8720c2fd4ac536b2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Oct 2025 12:00:29 -1000 Subject: [PATCH 6/8] fix --- script/api_protobuf/api_protobuf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f423097b7f..0f3505f657 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1478,7 +1478,7 @@ class RepeatedTypeInfo(TypeInfo): return None return ( f"case {self.number}: " - f"if (static_cast({content}) <= 31) " + f"if (static_cast({content}) < 32) " f"this->{self.field_name} |= (1U << static_cast({content})); " f"break;" ) From f88cc33cfc784ff9565a82740bed16ee8290a46e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Oct 2025 12:01:57 -1000 Subject: [PATCH 7/8] fix --- script/api_protobuf/api_protobuf.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 0f3505f657..075efe88f9 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1472,15 +1472,9 @@ class RepeatedTypeInfo(TypeInfo): if self._use_pointer: return None if self._use_bitmask: - # For bitmask fields, decode enum value and set corresponding bit, with bounds checking - content = self._ti.decode_varint - if content is None: - return None - return ( - f"case {self.number}: " - f"if (static_cast({content}) < 32) " - f"this->{self.field_name} |= (1U << static_cast({content})); " - f"break;" + # Bitmask fields don't support decoding (only used for device->client messages) + raise RuntimeError( + f"enum_as_bitmask fields do not support decoding: {self.field_name}" ) content = self._ti.decode_varint if content is None: From 753bebdde8ade56c58d223698f9c62a3e2914a8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Oct 2025 12:02:52 -1000 Subject: [PATCH 8/8] fix --- esphome/components/api/api_connection.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f7ee0619c5..8be96c641b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -476,6 +476,8 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c auto *light = static_cast(entity); ListEntitiesLightResponse msg; auto traits = light->get_traits(); + // msg.supported_color_modes is uint32_t, but get_mask() returns uint16_t + // The upper 16 bits are zero-extended during assignment (ColorMode only has 10 values) msg.supported_color_modes = traits.get_supported_color_modes().get_mask(); if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) || traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) {