diff --git a/homeassistant/components/vistapool/entity.py b/homeassistant/components/vistapool/entity.py index 9b47f6e524d7..68a1b8c57754 100644 --- a/homeassistant/components/vistapool/entity.py +++ b/homeassistant/components/vistapool/entity.py @@ -32,16 +32,6 @@ class VistapoolEntity(CoordinatorEntity[VistapoolDataUpdateCoordinator]): """Return if entity is available.""" return super().available and self.coordinator.push_connected - @property - def pool_id(self) -> str: - """Return the pool ID for the entity.""" - return self.coordinator.pool_id - - @property - def pool_name(self) -> str: - """Return the friendly pool name for the entity.""" - return self.coordinator.pool_name - def build_unique_id(self, suffix: str) -> str: """Return a consistent unique ID for the entity.""" return f"{self.coordinator.pool_id}-{suffix}" diff --git a/homeassistant/components/vistapool/number.py b/homeassistant/components/vistapool/number.py index d7230f916132..0553bcede5fa 100644 --- a/homeassistant/components/vistapool/number.py +++ b/homeassistant/components/vistapool/number.py @@ -44,13 +44,12 @@ class VistapoolNumberEntityDescription(NumberEntityDescription): def _max_electrolysis(coordinator: VistapoolDataUpdateCoordinator) -> float: """Read the cell's hardware max, falling back to a safe default.""" + # The path is typed in the library's coercion map, so an unparsable value + # already comes back as None rather than reaching float(). raw = coordinator.get_value("hidro.maxAllowedValue") if raw is None: return 50.0 - try: - return float(raw) / 10 - except TypeError, ValueError: - return 50.0 + return float(raw) / 10 NUMBER_DESCRIPTIONS: tuple[VistapoolNumberEntityDescription, ...] = ( @@ -231,14 +230,12 @@ class VistapoolNumber(VistapoolEntity, NumberEntity): @override def native_value(self) -> float | None: """Return the scaled current value.""" + # Every number path is typed in the library's coercion map, so an + # unparsable value already comes back as None rather than reaching float(). raw = self.coordinator.get_value(self.entity_description.value_path) if raw is None: return None - try: - value = float(raw) - except TypeError, ValueError: - return None - return value / self.entity_description.scale + return float(raw) / self.entity_description.scale @override async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/vistapool/select.py b/homeassistant/components/vistapool/select.py index b7101cbcef65..42e3160803ed 100644 --- a/homeassistant/components/vistapool/select.py +++ b/homeassistant/components/vistapool/select.py @@ -41,10 +41,8 @@ class VistapoolSelectEntityDescription(SelectEntityDescription): """Describes a Vistapool select entity.""" value_path: str - # A capability flag that must be set, such as main.hasPH. - exists_path: str | tuple[str, ...] | None = None - # A field the controller only reports when it supports the feature. Unlike - # exists_path this is a presence check, so a valid zero still counts. + # A field the controller only reports when it supports the feature. This + # is a presence check, so a valid zero still counts. presence_path: str | None = None value_map: dict[str, int] | None = None @@ -93,14 +91,6 @@ def _build_select_entities( """Build the select entities for a single pool.""" entities: list[SelectEntity] = [] for description in SELECT_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue if ( description.presence_path is not None and coordinator.get_value(description.presence_path) is None @@ -135,13 +125,14 @@ async def async_setup_entry( def _to_index(raw: Any) -> int | None: - """Convert a coordinator value into an options-list index, or None if not possible.""" + """Convert a coordinator value into an options-list index, or None if missing. + + Every select path is typed in the library's coercion map, so get_value + already returns an int or None; an unparsable value never reaches here. + """ if raw is None: return None - try: - return int(raw) - except TypeError, ValueError: - return None + return int(raw) class VistapoolSelect(VistapoolEntity, SelectEntity): diff --git a/tests/components/vistapool/test_number.py b/tests/components/vistapool/test_number.py index dfc0f801f347..21be9b11b959 100644 --- a/tests/components/vistapool/test_number.py +++ b/tests/components/vistapool/test_number.py @@ -13,7 +13,7 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -113,6 +113,44 @@ async def test_number_electrolysis_max_fallback( assert state.attributes["max"] == 50.0 +@pytest.mark.parametrize( + ("entity_id", "pool_data"), + [ + pytest.param( + "number.my_pool_ph_maximum", + { + "main": {"hasPH": 1, "version": 1}, + "modules": {"ph": {"status": {"high_value": "garbage"}}}, + }, + id="ph_maximum", + ), + pytest.param( + "number.my_pool_redox_setpoint", + { + "main": {"hasRX": 1, "version": 1}, + "modules": {"rx": {"status": {"value": "garbage"}}}, + }, + id="redox_setpoint", + ), + ], +) +async def test_number_unknown_when_unparsable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + entity_id: str, + pool_data: dict[str, Any], +) -> None: + """Test an unparsable raw setpoint reads as unknown rather than raising.""" + mock_vistapool_client.fetch_pool_data.return_value = pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_number_hydrolysis_setpoint_branch( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/vistapool/test_select.py b/tests/components/vistapool/test_select.py index 853c660be761..f379946e0b59 100644 --- a/tests/components/vistapool/test_select.py +++ b/tests/components/vistapool/test_select.py @@ -276,6 +276,34 @@ async def test_light_mode_current_option( assert hass.states.get("select.my_pool_light_mode").state == expected +@pytest.mark.parametrize( + "light_data", + [ + pytest.param({"status": 0}, id="mode_missing"), + pytest.param({"mode": 0}, id="status_missing"), + ], +) +async def test_light_mode_unknown_after_partial_push( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + light_data: dict[str, Any], +) -> None: + """Test the light mode reports unknown when a push drops a field it derives from.""" + mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("select.my_pool_light_mode").state == "auto" + + on_data = mock_vistapool_client.subscribe_pool_resilient.call_args.args[1] + on_data({"main": {"version": 1}, "light": light_data}) + await hass.async_block_till_done() + + assert hass.states.get("select.my_pool_light_mode").state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("option", "expected_updates"), [