bosch_shc: add thermostat valve position entity (#182249)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas
2026-09-21 20:08:29 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 1f949d9234
commit 90233bf798
7 changed files with 262 additions and 6 deletions
@@ -20,6 +20,7 @@ PLATFORMS = [
Platform.COVER,
Platform.SENSOR,
Platform.SWITCH,
Platform.VALVE,
]
_LOGGER = logging.getLogger(__name__)
+49 -3
View File
@@ -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,
)
]
@@ -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"
}
}
}
}
@@ -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
+8 -1
View File
@@ -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
+82 -2
View File
@@ -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"
+38
View File
@@ -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