diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index a120650e84b..1a329ce8a25 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from smarttub import Spa, SpaError, SpaReminder @@ -17,9 +18,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTR_ERRORS, ATTR_REMINDERS +from .const import ATTR_ERRORS, ATTR_REMINDERS, ATTR_SENSORS from .controller import SmartTubConfigEntry -from .entity import SmartTubEntity, SmartTubSensorBase +from .entity import ( + SmartTubEntity, + SmartTubExternalSensorBase, + SmartTubOnboardSensorBase, +) # whether the reminder has been snoozed (bool) ATTR_REMINDER_SNOOZED = "snoozed" @@ -44,6 +49,8 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = { ) } +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -62,6 +69,12 @@ async def async_setup_entry( SmartTubReminder(controller.coordinator, spa, reminder) for reminder in controller.coordinator.data[spa.id][ATTR_REMINDERS].values() ) + for sensor in controller.coordinator.data[spa.id][ATTR_SENSORS].values(): + name = sensor.name.strip("{}") + if name.startswith("cover-"): + entities.append( + SmartTubCoverSensor(controller.coordinator, spa, sensor) + ) async_add_entities(entities) @@ -79,7 +92,7 @@ async def async_setup_entry( ) -class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): +class SmartTubOnline(SmartTubOnboardSensorBase, BinarySensorEntity): """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @@ -192,3 +205,16 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity): ATTR_CREATED_AT: error.created_at.isoformat(), ATTR_UPDATED_AT: error.updated_at.isoformat(), } + + +class SmartTubCoverSensor(SmartTubExternalSensorBase, BinarySensorEntity): + """Wireless magnetic cover sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool: + """Return False if the cover is closed, True if open.""" + # magnet is True when the cover is closed, False when open + # device class OPENING wants True to mean open, False to mean closed + return not self.sensor.magnet diff --git a/homeassistant/components/smarttub/const.py b/homeassistant/components/smarttub/const.py index dadc66da942..8bf9da281a9 100644 --- a/homeassistant/components/smarttub/const.py +++ b/homeassistant/components/smarttub/const.py @@ -24,3 +24,4 @@ ATTR_LIGHTS = "lights" ATTR_PUMPS = "pumps" ATTR_REMINDERS = "reminders" ATTR_STATUS = "status" +ATTR_SENSORS = "sensors" diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index d8299bbd786..337959e0316 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -22,6 +22,7 @@ from .const import ( ATTR_LIGHTS, ATTR_PUMPS, ATTR_REMINDERS, + ATTR_SENSORS, ATTR_STATUS, DOMAIN, POLLING_TIMEOUT, @@ -108,6 +109,7 @@ class SmartTubController: ATTR_LIGHTS: {light.zone: light for light in full_status.lights}, ATTR_REMINDERS: {reminder.id: reminder for reminder in reminders}, ATTR_ERRORS: errors, + ATTR_SENSORS: {sensor.address: sensor for sensor in full_status.sensors}, } @callback diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 069fd50c5f2..53562fd887a 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -2,7 +2,7 @@ from typing import Any -from smarttub import Spa, SpaState +from smarttub import Spa, SpaSensor, SpaState from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN +from .const import ATTR_SENSORS, DOMAIN from .helpers import get_spa_name @@ -47,8 +47,8 @@ class SmartTubEntity(CoordinatorEntity): return self.coordinator.data[self.spa.id].get("status") -class SmartTubSensorBase(SmartTubEntity): - """Base class for SmartTub sensors.""" +class SmartTubOnboardSensorBase(SmartTubEntity): + """Base class for SmartTub onboard sensors.""" def __init__( self, @@ -65,3 +65,29 @@ class SmartTubSensorBase(SmartTubEntity): def _state(self): """Retrieve the underlying state from the spa.""" return getattr(self.spa_status, self._state_key) + + +class SmartTubExternalSensorBase(SmartTubEntity): + """Class for additional BLE wireless sensors sold separately.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: Spa, + sensor: SpaSensor, + ) -> None: + """Initialize the external sensor entity.""" + self.sensor_address = sensor.address + self._attr_unique_id = f"{spa.id}-externalsensor-{sensor.address}" + super().__init__(coordinator, spa, self._human_readable_name(sensor)) + + @staticmethod + def _human_readable_name(sensor: SpaSensor) -> str: + return " ".join( + word.capitalize() for word in sensor.name.strip("{}").split("-") + ) + + @property + def sensor(self) -> SpaSensor: + """Convenience property to access the smarttub.SpaSensor instance for this sensor.""" + return self.coordinator.data[self.spa.id][ATTR_SENSORS][self.sensor_address] diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 5116bfb3aee..64e5eec1f46 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .controller import SmartTubConfigEntry -from .entity import SmartTubSensorBase +from .entity import SmartTubOnboardSensorBase # the desired duration, in hours, of the cycle ATTR_DURATION = "duration" @@ -56,16 +56,16 @@ async def async_setup_entry( for spa in controller.spas: entities.extend( [ - SmartTubSensor(controller.coordinator, spa, "State", "state"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "State", "state"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Flow Switch", "flow_switch" ), - SmartTubSensor(controller.coordinator, spa, "Ozone", "ozone"), - SmartTubSensor(controller.coordinator, spa, "UV", "uv"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "Ozone", "ozone"), + SmartTubBuiltinSensor(controller.coordinator, spa, "UV", "uv"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Blowout Cycle", "blowout_cycle" ), - SmartTubSensor( + SmartTubBuiltinSensor( controller.coordinator, spa, "Cleanup Cycle", "cleanup_cycle" ), SmartTubPrimaryFiltrationCycle(controller.coordinator, spa), @@ -90,7 +90,7 @@ async def async_setup_entry( ) -class SmartTubSensor(SmartTubSensorBase, SensorEntity): +class SmartTubBuiltinSensor(SmartTubOnboardSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property @@ -105,7 +105,7 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): return self._state.lower() -class SmartTubPrimaryFiltrationCycle(SmartTubSensor): +class SmartTubPrimaryFiltrationCycle(SmartTubBuiltinSensor): """The primary filtration cycle.""" def __init__( @@ -145,7 +145,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): await self.coordinator.async_request_refresh() -class SmartTubSecondaryFiltrationCycle(SmartTubSensor): +class SmartTubSecondaryFiltrationCycle(SmartTubBuiltinSensor): """The secondary filtration cycle.""" def __init__( diff --git a/tests/components/smarttub/conftest.py b/tests/components/smarttub/conftest.py index 06780f8fb1e..f7677100aad 100644 --- a/tests/components/smarttub/conftest.py +++ b/tests/components/smarttub/conftest.py @@ -81,6 +81,16 @@ def mock_spa(spa_state): spa_state.lights = [mock_light_off, mock_light_on] + mock_cover_sensor = create_autospec(smarttub.SpaSensor, instance=True) + mock_cover_sensor.spa = mock_spa + mock_cover_sensor.address = "address1" + mock_cover_sensor.name = "{cover-sensor-1}" + mock_cover_sensor.type = "ibs0x" + mock_cover_sensor.subType = "magnet" + mock_cover_sensor.magnet = True # closed + + spa_state.sensors = [mock_cover_sensor] + mock_filter_reminder = create_autospec(smarttub.SpaReminder, instance=True) mock_filter_reminder.id = "FILTER01" mock_filter_reminder.name = "MyFilter" @@ -127,6 +137,7 @@ def mock_spa_state(): "cleanupCycle": "INACTIVE", "lights": [], "pumps": [], + "sensors": [], }, ) diff --git a/tests/components/smarttub/test_binary_sensor.py b/tests/components/smarttub/test_binary_sensor.py index 3365b03b041..cf5676aa0bb 100644 --- a/tests/components/smarttub/test_binary_sensor.py +++ b/tests/components/smarttub/test_binary_sensor.py @@ -104,3 +104,14 @@ async def test_reset_reminder(spa, setup_entry, hass: HomeAssistant) -> None: ) reminder.reset.assert_called_with(days) + + +async def test_cover_sensor(hass: HomeAssistant, spa, setup_entry) -> None: + """Test cover sensor.""" + + entity_id = f"binary_sensor.{spa.brand}_{spa.model}_cover_sensor_1" + + state = hass.states.get(entity_id) + assert state is not None + + assert state.state == STATE_OFF # closed