mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
bosch_shc: add thermostat valve position entity (#182249)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1f949d9234
commit
90233bf798
@@ -20,6 +20,7 @@ PLATFORMS = [
|
||||
Platform.COVER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user