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
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

View File

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

View File

@ -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

View File

@ -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]

View File

@ -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__(

View File

@ -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": [],
},
)

View File

@ -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