diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 621942675f71..2c6c23c49896 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.COVER, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index c83863a1acd9..6bc27ff9c05a 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -23,6 +23,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + EntityCategory, UnitOfEnergy, UnitOfPower, UnitOfRatio, @@ -56,6 +57,7 @@ _PowerMeterDevice = SHCSmartPlug | SHCLightSwitchBSM | SHCMicromoduleShutterCont TEMPERATURE_SENSOR = "temperature" HUMIDITY_SENSOR = "humidity" VALVE_TAPPET_SENSOR = "valvetappet" +VALVE_TAPPET_STATE_SENSOR = "valve_tappet_state" PURITY_SENSOR = "purity" AIR_QUALITY_SENSOR = "airquality" TEMPERATURE_RATING_SENSOR = "temperature_rating" @@ -65,6 +67,15 @@ POWER_SENSOR = "power" ENERGY_SENSOR = "energy" COMMUNICATION_QUALITY_SENSOR = "communication_quality" + +def _valve_tappet_state_value(device: SHCThermostat) -> str | None: + """Return the valve motor status enum string, or None on unknown value.""" + try: + return str(device.valvestate.name.lower()) + except ValueError, AttributeError: + return None + + _THERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = ( SHCSensorEntityDescription( key=TEMPERATURE_SENSOR, @@ -80,10 +91,44 @@ _VALVE_TAPPET_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = ( translation_key=VALVE_TAPPET_SENSOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + # Superseded by the "valve" platform's position entity; kept + # available (opt-in) for anyone already relying on the raw percentage. + entity_registry_enabled_default=False, + suggested_display_precision=0, value_fn=lambda device: device.position, - attributes_fn=lambda device: { - "valve_tappet_state": device.valvestate.name, - }, + # Kept for anyone already reading this attribute in a template or + # automation, even though the same value is now also a first-class + # sensor below (_VALVE_TAPPET_STATE_DESCRIPTION). + attributes_fn=lambda device: {"valve_tappet_state": device.valvestate.name}, + ) +) +_VALVE_TAPPET_STATE_DESCRIPTION: SHCSensorEntityDescription[SHCThermostat] = ( + SHCSensorEntityDescription( + key=VALVE_TAPPET_STATE_SENSOR, + translation_key=VALVE_TAPPET_STATE_SENSOR, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "valve_adaption_successful", + "valve_adaption_in_progress", + "valve_adaption_requested", + "range_too_big", + "range_too_small", + "run_to_start_position", + "start_position_requested", + "in_start_position", + "not_available", + "no_valve_body_error", + "no_motor_error", + "valve_too_tight", + "fix_motor_logic_requested", + "fix_motor_logic_in_progress", + "fix_motor_logic_successful", + "error", + "unknown", + ], + value_fn=_valve_tappet_state_value, ) ) _WALLTHERMOSTAT_TEMPERATURE_DESCRIPTION: SHCSensorEntityDescription[ @@ -226,6 +271,7 @@ async def async_setup_entry( for description in ( _THERMOSTAT_TEMPERATURE_DESCRIPTION, _VALVE_TAPPET_DESCRIPTION, + _VALVE_TAPPET_STATE_DESCRIPTION, ) ] diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 63b169e0c343..7dac8015b548 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -65,6 +65,28 @@ "temperature_rating": { "name": "Temperature rating" }, + "valve_tappet_state": { + "name": "Valve motor status", + "state": { + "error": "Error", + "fix_motor_logic_in_progress": "Motor logic fix in progress", + "fix_motor_logic_requested": "Motor logic fix requested", + "fix_motor_logic_successful": "Motor logic fix successful", + "in_start_position": "In start position", + "no_motor_error": "Error: no motor detected", + "no_valve_body_error": "Error: no valve body detected", + "not_available": "Not available", + "range_too_big": "Range too big", + "range_too_small": "Range too small", + "run_to_start_position": "Running to start position", + "start_position_requested": "Start position requested", + "unknown": "Unknown", + "valve_adaption_in_progress": "Adaptation in progress", + "valve_adaption_requested": "Adaptation requested", + "valve_adaption_successful": "Adaptation successful", + "valve_too_tight": "Valve too tight" + } + }, "valvetappet": { "name": "Valvetappet" } @@ -82,6 +104,11 @@ "routing": { "name": "Range extension" } + }, + "valve": { + "valve": { + "name": "Valve" + } } } } diff --git a/homeassistant/components/bosch_shc/valve.py b/homeassistant/components/bosch_shc/valve.py new file mode 100644 index 000000000000..d969f8c0e147 --- /dev/null +++ b/homeassistant/components/bosch_shc/valve.py @@ -0,0 +1,57 @@ +"""Platform for valve integration.""" + +from typing import TYPE_CHECKING, override + +from boschshcpy import SHCThermostat + +from homeassistant.components.valve import ValveDeviceClass, ValveEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschConfigEntry +from .entity import SHCEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the SHC valve platform.""" + session = config_entry.runtime_data + + shc_info = session.information + if TYPE_CHECKING: + assert shc_info is not None and shc_info.unique_id is not None + + async_add_entities( + SHCValve( + hass=hass, + device=device, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for device in session.device_helper.thermostats + ) + + +class SHCValve(SHCEntity, ValveEntity): + """Representation of a SHC thermostat valve position.""" + + _attr_device_class = ValveDeviceClass.WATER + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_reports_position = True + _attr_translation_key = "valve" + _device: SHCThermostat + + @property + @override + def current_valve_position(self) -> int | None: + """Return current position of valve. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._device.position diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 4bfc80eb3e84..c12836b1ebbd 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -22,7 +22,10 @@ from boschshcpy import ( ShutterControlService, ThermostatService, ) -from boschshcpy.services_impl import PresenceSimulationConfigurationService +from boschshcpy.services_impl import ( + PresenceSimulationConfigurationService, + ValveTappetService, +) import pytest from homeassistant.components.bosch_shc.const import ( @@ -220,6 +223,8 @@ def thermostat_device( device_id: str = "hdm:ZigBee:thermostat1", name: str = "Thermostat", child_lock: ThermostatService.State = ThermostatService.State.OFF, + position: int = 50, + valvestate: ValveTappetService.State = ValveTappetService.State.VALVE_ADAPTION_SUCCESSFUL, ) -> SHCThermostat: """Build a minimal device double for the thermostats/roomthermostats/wallthermostats buckets.""" device = create_autospec(SHCThermostat, instance=True, spec_set=True) @@ -233,6 +238,8 @@ def thermostat_device( device.deleted = False device.status = "AVAILABLE" device.child_lock = child_lock + device.position = position + device.valvestate = valvestate return device diff --git a/tests/components/bosch_shc/test_sensor.py b/tests/components/bosch_shc/test_sensor.py index 7827f27db15e..4b15942c4543 100644 --- a/tests/components/bosch_shc/test_sensor.py +++ b/tests/components/bosch_shc/test_sensor.py @@ -1,18 +1,21 @@ """Tests for the Bosch SHC sensor platform.""" from collections.abc import Generator +from datetime import timedelta from unittest.mock import MagicMock, patch +from boschshcpy.services_impl import ValveTappetService import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util import dt as dt_util -from .conftest import setup_integration +from .conftest import setup_integration, thermostat_device -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) @@ -65,3 +68,80 @@ async def test_open_windows_doors_sensor( state = hass.states.get(entity_id) assert state is not None assert state.state == "0" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "thermostats": [ + thermostat_device(valvestate=ValveTappetService.State.VALVE_TOO_TIGHT) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valve_tappet_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A thermostat's valve motor status is exposed as an ENUM sensor.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.thermostat_valve_motor_status") + assert state is not None + assert state.state == "valve_too_tight" + + +@pytest.mark.parametrize( + "device_buckets", + [{"thermostats": [thermostat_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valvetappet_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The raw valve tappet percentage sensor stays opt-in, superseded by the valve entity.""" + await setup_integration(hass, mock_config_entry) + + entry = entity_registry.async_get("sensor.thermostat_valvetappet") + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "thermostats": [ + thermostat_device(valvestate=ValveTappetService.State.VALVE_TOO_TIGHT) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valvetappet_state_attribute_preserved( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Enabling the legacy percentage sensor still surfaces its valve_tappet_state attribute.""" + await setup_integration(hass, mock_config_entry) + + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity( + "sensor.thermostat_valvetappet", disabled_by=None + ) + await hass.async_block_till_done(wait_background_tasks=True) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.thermostat_valvetappet") + assert state is not None + assert state.attributes["valve_tappet_state"] == "VALVE_TOO_TIGHT" diff --git a/tests/components/bosch_shc/test_valve.py b/tests/components/bosch_shc/test_valve.py new file mode 100644 index 000000000000..0e56c40b3208 --- /dev/null +++ b/tests/components/bosch_shc/test_valve.py @@ -0,0 +1,38 @@ +"""Tests for the Bosch SHC valve platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import setup_integration, thermostat_device + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the valve platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.VALVE]): + yield + + +@pytest.mark.parametrize( + "device_buckets", + [{"thermostats": [thermostat_device(position=42)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_thermostat_valve_position( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A thermostat's valve tappet position is exposed as a valve entity.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("valve.thermostat_valve") + assert state is not None + assert state.attributes["current_position"] == 42