From d41802ec50efda44fc4f8e8b7a77a98609171bb5 Mon Sep 17 00:00:00 2001 From: Thomas <10558666+mosandlt@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:47:39 +0200 Subject: [PATCH] bosch_shc: add Smart Plug energy-saving power threshold number (#183699) Co-authored-by: Claude Sonnet 5.5 --- homeassistant/components/bosch_shc/number.py | 51 +++++++++- .../components/bosch_shc/strings.json | 3 + tests/components/bosch_shc/conftest.py | 4 + tests/components/bosch_shc/test_number.py | 94 +++++++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bosch_shc/number.py b/homeassistant/components/bosch_shc/number.py index be47538cd7ca..88b5bd941ee6 100644 --- a/homeassistant/components/bosch_shc/number.py +++ b/homeassistant/components/bosch_shc/number.py @@ -4,7 +4,12 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING, Any, override -from boschshcpy import SHCMicromoduleRelay, SHCShutterContact2 +from boschshcpy import ( + SHCMicromoduleRelay, + SHCShutterContact2, + SHCSmartPlug, + SHCSmartPlugCompact, +) from boschshcpy.device import SHCDevice from homeassistant.components.number import ( @@ -13,7 +18,7 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import EntityCategory, UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -50,6 +55,12 @@ async def _impulse_length_set_value_fn( await device.async_set_impulse_length(round(value * 10)) +async def _power_threshold_set_value_fn( + device: SHCSmartPlug | SHCSmartPlugCompact, value: float +) -> None: + await device.async_set_power_threshold(value) + + IMPULSE_LENGTH = "impulse_length" NUMBER_TYPES: dict[str, SHCNumberEntityDescription] = { @@ -81,6 +92,19 @@ NUMBER_TYPES: dict[str, SHCNumberEntityDescription] = { round(value) ), ), + "power_threshold": SHCNumberEntityDescription[SHCSmartPlug | SHCSmartPlugCompact]( + key="power_threshold", + translation_key="energy_saving_power_threshold", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0.0, + native_max_value=3680.0, + native_step=1.0, + mode=NumberMode.BOX, + value_fn=lambda device: device.power_threshold, + set_value_fn=_power_threshold_set_value_fn, + ), } @@ -127,6 +151,29 @@ async def async_setup_entry( for device in session.device_helper.shutter_contacts2 ) + entities.extend( + SHCNumber( + hass=hass, + device=device, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=NUMBER_TYPES["power_threshold"], + ) + for device in session.device_helper.smart_plugs + if device.supports_energy_saving_mode and device.power_threshold is not None + ) + entities.extend( + SHCNumber( + hass=hass, + device=device, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=NUMBER_TYPES["power_threshold"], + ) + for device in session.device_helper.smart_plugs_compact + if device.supports_energy_saving_mode and device.power_threshold is not None + ) + async_add_entities(entities) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index f00d6c613641..d19af93f229e 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -63,6 +63,9 @@ "bypass_timeout": { "name": "Break function timeout" }, + "energy_saving_power_threshold": { + "name": "Energy-saving power threshold" + }, "impulse_length": { "name": "Pulse length" } diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 3c201910a698..717368510cdf 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -261,6 +261,7 @@ def smart_plug_device( routing: RoutingService.State = RoutingService.State.DISABLED, supports_energy_saving_mode: bool = False, energy_saving_mode_enabled: bool = False, + power_threshold: float | None = None, ) -> SHCSmartPlug: """Build a minimal device double for the smart_plugs bucket.""" device = create_autospec(SHCSmartPlug, instance=True, spec_set=True) @@ -277,6 +278,7 @@ def smart_plug_device( device.routing = routing device.supports_energy_saving_mode = supports_energy_saving_mode device.energy_saving_mode_enabled = energy_saving_mode_enabled + device.power_threshold = power_threshold return device @@ -285,6 +287,7 @@ def smart_plug_compact_device( name: str = "Smart Plug Compact", supports_energy_saving_mode: bool = False, energy_saving_mode_enabled: bool = False, + power_threshold: float | None = None, ) -> SHCSmartPlugCompact: """Build a minimal device double for the smart_plugs_compact bucket.""" device = create_autospec(SHCSmartPlugCompact, instance=True, spec_set=True) @@ -300,6 +303,7 @@ def smart_plug_compact_device( device.switchstate = PowerSwitchService.State.OFF device.supports_energy_saving_mode = supports_energy_saving_mode device.energy_saving_mode_enabled = energy_saving_mode_enabled + device.power_threshold = power_threshold return device diff --git a/tests/components/bosch_shc/test_number.py b/tests/components/bosch_shc/test_number.py index 25fb2cd88016..7762125efc89 100644 --- a/tests/components/bosch_shc/test_number.py +++ b/tests/components/bosch_shc/test_number.py @@ -16,12 +16,18 @@ from .conftest import ( micromodule_relay_device, setup_integration, shutter_contact2_device, + smart_plug_compact_device, + smart_plug_device, ) from tests.common import MockConfigEntry IMPULSE_LENGTH_ENTITY_ID = "number.relay_pulse_length" BYPASS_TIMEOUT_ENTITY_ID = "number.shutter_contact_break_function_timeout" +POWER_THRESHOLD_ENTITY_ID = "number.smart_plug_energy_saving_power_threshold" +POWER_THRESHOLD_COMPACT_ENTITY_ID = ( + "number.smart_plug_compact_energy_saving_power_threshold" +) @pytest.mark.parametrize( @@ -146,3 +152,91 @@ async def test_shutter_contact2_bypass_timeout_set_value( blocking=True, ) device.async_set_bypass_timeout.assert_awaited_once_with(11) + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "smart_plugs": [ + smart_plug_device(supports_energy_saving_mode=True, power_threshold=5.0) + ], + "smart_plugs_compact": [ + smart_plug_compact_device( + supports_energy_saving_mode=True, power_threshold=7.0 + ) + ], + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_smart_plug_power_threshold_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """The power threshold is reported for both smart plug variants.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(POWER_THRESHOLD_ENTITY_ID) + assert state is not None + assert state.state == "5.0" + state = hass.states.get(POWER_THRESHOLD_COMPACT_ENTITY_ID) + assert state is not None + assert state.state == "7.0" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "smart_plugs": [ + smart_plug_device(supports_energy_saving_mode=True, power_threshold=5.0) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_smart_plug_power_threshold_set_value( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Setting a value writes the power threshold to the device.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.smart_plugs[0] + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: POWER_THRESHOLD_ENTITY_ID, ATTR_VALUE: 20}, + blocking=True, + ) + device.async_set_power_threshold.assert_awaited_once_with(20) + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "smart_plugs": [smart_plug_device(power_threshold=5.0)], + "smart_plugs_compact": [ + smart_plug_compact_device( + supports_energy_saving_mode=True, power_threshold=None + ) + ], + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_smart_plug_no_power_threshold_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """No entity is created without energy-saving or a power threshold.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(POWER_THRESHOLD_ENTITY_ID) is None + assert hass.states.get(POWER_THRESHOLD_COMPACT_ENTITY_ID) is None