Add smarttub cover sensor (#139134)

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Matt Zimmerman 2025-07-25 11:10:39 -07:00 committed by GitHub
parent a069b59efc
commit b2710c1bce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 94 additions and 17 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from smarttub import Spa, SpaError, SpaReminder 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.typing import VolDictType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 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 .controller import SmartTubConfigEntry
from .entity import SmartTubEntity, SmartTubSensorBase from .entity import (
SmartTubEntity,
SmartTubExternalSensorBase,
SmartTubOnboardSensorBase,
)
# whether the reminder has been snoozed (bool) # whether the reminder has been snoozed (bool)
ATTR_REMINDER_SNOOZED = "snoozed" ATTR_REMINDER_SNOOZED = "snoozed"
@ -44,6 +49,8 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = {
) )
} }
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -62,6 +69,12 @@ async def async_setup_entry(
SmartTubReminder(controller.coordinator, spa, reminder) SmartTubReminder(controller.coordinator, spa, reminder)
for reminder in controller.coordinator.data[spa.id][ATTR_REMINDERS].values() 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) 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).""" """A binary sensor indicating whether the spa is currently online (connected to the cloud)."""
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
@ -192,3 +205,16 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity):
ATTR_CREATED_AT: error.created_at.isoformat(), ATTR_CREATED_AT: error.created_at.isoformat(),
ATTR_UPDATED_AT: error.updated_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

View File

@ -24,3 +24,4 @@ ATTR_LIGHTS = "lights"
ATTR_PUMPS = "pumps" ATTR_PUMPS = "pumps"
ATTR_REMINDERS = "reminders" ATTR_REMINDERS = "reminders"
ATTR_STATUS = "status" ATTR_STATUS = "status"
ATTR_SENSORS = "sensors"

View File

@ -22,6 +22,7 @@ from .const import (
ATTR_LIGHTS, ATTR_LIGHTS,
ATTR_PUMPS, ATTR_PUMPS,
ATTR_REMINDERS, ATTR_REMINDERS,
ATTR_SENSORS,
ATTR_STATUS, ATTR_STATUS,
DOMAIN, DOMAIN,
POLLING_TIMEOUT, POLLING_TIMEOUT,
@ -108,6 +109,7 @@ class SmartTubController:
ATTR_LIGHTS: {light.zone: light for light in full_status.lights}, ATTR_LIGHTS: {light.zone: light for light in full_status.lights},
ATTR_REMINDERS: {reminder.id: reminder for reminder in reminders}, ATTR_REMINDERS: {reminder.id: reminder for reminder in reminders},
ATTR_ERRORS: errors, ATTR_ERRORS: errors,
ATTR_SENSORS: {sensor.address: sensor for sensor in full_status.sensors},
} }
@callback @callback

View File

@ -2,7 +2,7 @@
from typing import Any 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.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator, DataUpdateCoordinator,
) )
from .const import DOMAIN from .const import ATTR_SENSORS, DOMAIN
from .helpers import get_spa_name from .helpers import get_spa_name
@ -47,8 +47,8 @@ class SmartTubEntity(CoordinatorEntity):
return self.coordinator.data[self.spa.id].get("status") return self.coordinator.data[self.spa.id].get("status")
class SmartTubSensorBase(SmartTubEntity): class SmartTubOnboardSensorBase(SmartTubEntity):
"""Base class for SmartTub sensors.""" """Base class for SmartTub onboard sensors."""
def __init__( def __init__(
self, self,
@ -65,3 +65,29 @@ class SmartTubSensorBase(SmartTubEntity):
def _state(self): def _state(self):
"""Retrieve the underlying state from the spa.""" """Retrieve the underlying state from the spa."""
return getattr(self.spa_status, self._state_key) 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]

View File

@ -14,7 +14,7 @@ from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .controller import SmartTubConfigEntry from .controller import SmartTubConfigEntry
from .entity import SmartTubSensorBase from .entity import SmartTubOnboardSensorBase
# the desired duration, in hours, of the cycle # the desired duration, in hours, of the cycle
ATTR_DURATION = "duration" ATTR_DURATION = "duration"
@ -56,16 +56,16 @@ async def async_setup_entry(
for spa in controller.spas: for spa in controller.spas:
entities.extend( entities.extend(
[ [
SmartTubSensor(controller.coordinator, spa, "State", "state"), SmartTubBuiltinSensor(controller.coordinator, spa, "State", "state"),
SmartTubSensor( SmartTubBuiltinSensor(
controller.coordinator, spa, "Flow Switch", "flow_switch" controller.coordinator, spa, "Flow Switch", "flow_switch"
), ),
SmartTubSensor(controller.coordinator, spa, "Ozone", "ozone"), SmartTubBuiltinSensor(controller.coordinator, spa, "Ozone", "ozone"),
SmartTubSensor(controller.coordinator, spa, "UV", "uv"), SmartTubBuiltinSensor(controller.coordinator, spa, "UV", "uv"),
SmartTubSensor( SmartTubBuiltinSensor(
controller.coordinator, spa, "Blowout Cycle", "blowout_cycle" controller.coordinator, spa, "Blowout Cycle", "blowout_cycle"
), ),
SmartTubSensor( SmartTubBuiltinSensor(
controller.coordinator, spa, "Cleanup Cycle", "cleanup_cycle" controller.coordinator, spa, "Cleanup Cycle", "cleanup_cycle"
), ),
SmartTubPrimaryFiltrationCycle(controller.coordinator, spa), 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.""" """Generic class for SmartTub status sensors."""
@property @property
@ -105,7 +105,7 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity):
return self._state.lower() return self._state.lower()
class SmartTubPrimaryFiltrationCycle(SmartTubSensor): class SmartTubPrimaryFiltrationCycle(SmartTubBuiltinSensor):
"""The primary filtration cycle.""" """The primary filtration cycle."""
def __init__( def __init__(
@ -145,7 +145,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor):
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
class SmartTubSecondaryFiltrationCycle(SmartTubSensor): class SmartTubSecondaryFiltrationCycle(SmartTubBuiltinSensor):
"""The secondary filtration cycle.""" """The secondary filtration cycle."""
def __init__( def __init__(

View File

@ -81,6 +81,16 @@ def mock_spa(spa_state):
spa_state.lights = [mock_light_off, mock_light_on] 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 = create_autospec(smarttub.SpaReminder, instance=True)
mock_filter_reminder.id = "FILTER01" mock_filter_reminder.id = "FILTER01"
mock_filter_reminder.name = "MyFilter" mock_filter_reminder.name = "MyFilter"
@ -127,6 +137,7 @@ def mock_spa_state():
"cleanupCycle": "INACTIVE", "cleanupCycle": "INACTIVE",
"lights": [], "lights": [],
"pumps": [], "pumps": [],
"sensors": [],
}, },
) )

View File

@ -104,3 +104,14 @@ async def test_reset_reminder(spa, setup_entry, hass: HomeAssistant) -> None:
) )
reminder.reset.assert_called_with(days) 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