Add fault event to balboa (#138623)

* Add fault sensor to balboa

* Use an event instead of sensor for faults

* Don't set fault initially in conftest

* Use event type per fault message code

* Set fault to None in conftest
pull/138685/head^2
Nathan Spencer 2025-03-02 12:24:27 -07:00 committed by GitHub
parent e63b17cd58
commit f76e295204
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 295 additions and 1 deletions

View File

@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.EVENT,
Platform.FAN,
Platform.LIGHT,
Platform.SELECT,
@ -28,7 +29,6 @@ PLATFORMS = [
Platform.TIME,
]
KEEP_ALIVE_INTERVAL = timedelta(minutes=1)
SYNC_TIME_INTERVAL = timedelta(hours=1)

View File

@ -0,0 +1,91 @@
"""Support for Balboa events."""
from __future__ import annotations
from datetime import datetime, timedelta
from pybalboa import EVENT_UPDATE, SpaClient
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from . import BalboaConfigEntry
from .entity import BalboaEntity
FAULT = "fault"
FAULT_DATE = "fault_date"
REQUEST_FAULT_LOG_INTERVAL = timedelta(minutes=5)
FAULT_MESSAGE_CODE_MAP: dict[int, str] = {
15: "sensor_out_of_sync",
16: "low_flow",
17: "flow_failed",
18: "settings_reset",
19: "priming_mode",
20: "clock_failed",
21: "settings_reset",
22: "memory_failure",
26: "service_sensor_sync",
27: "heater_dry",
28: "heater_may_be_dry",
29: "water_too_hot",
30: "heater_too_hot",
31: "sensor_a_fault",
32: "sensor_b_fault",
34: "pump_stuck",
35: "hot_fault",
36: "gfci_test_failed",
37: "standby_mode",
}
FAULT_EVENT_TYPES = sorted(set(FAULT_MESSAGE_CODE_MAP.values()))
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa's events."""
async_add_entities([BalboaEventEntity(entry.runtime_data)])
class BalboaEventEntity(BalboaEntity, EventEntity):
"""Representation of a Balboa event entity."""
_attr_event_types = FAULT_EVENT_TYPES
_attr_translation_key = FAULT
def __init__(self, spa: SpaClient) -> None:
"""Initialize a Balboa event entity."""
super().__init__(spa, FAULT)
@callback
def _async_handle_event(self) -> None:
"""Handle the fault event."""
if not (fault := self._client.fault):
return
fault_date = fault.fault_datetime.isoformat()
if self.state_attributes.get(FAULT_DATE) != fault_date:
self._trigger_event(
FAULT_MESSAGE_CODE_MAP.get(fault.message_code, fault.message),
{FAULT_DATE: fault_date, "code": fault.message_code},
)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
self.async_on_remove(self._client.on(EVENT_UPDATE, self._async_handle_event))
async def request_fault_log(now: datetime | None = None) -> None:
"""Request the most recent fault log."""
await self._client.request_fault_log()
await request_fault_log()
self.async_on_remove(
async_track_time_interval(
self.hass, request_fault_log, REQUEST_FAULT_LOG_INTERVAL
)
)

View File

@ -57,6 +57,35 @@
}
}
},
"event": {
"fault": {
"name": "Fault",
"state_attributes": {
"event_type": {
"state": {
"sensor_out_of_sync": "Sensors are out of sync",
"low_flow": "The water flow is low",
"flow_failed": "The water flow has failed",
"settings_reset": "The settings have been reset",
"priming_mode": "Priming mode",
"clock_failed": "The clock has failed",
"memory_failure": "Program memory failure",
"service_sensor_sync": "Sensors are out of sync -- call for service",
"heater_dry": "The heater is dry",
"heater_may_be_dry": "The heater may be dry",
"water_too_hot": "The water is too hot",
"heater_too_hot": "The heater is too hot",
"sensor_a_fault": "Sensor A fault",
"sensor_b_fault": "Sensor B fault",
"pump_stuck": "A pump may be stuck on",
"hot_fault": "Hot fault",
"gfci_test_failed": "The GFCI test failed",
"standby_mode": "Standby mode (hold mode)"
}
}
}
}
},
"fan": {
"pump": {
"name": "Pump {index}"

View File

@ -68,4 +68,6 @@ def client_fixture() -> Generator[MagicMock]:
client.pumps = []
client.temperature_range.state = LowHighRange.LOW
client.fault = None
yield client

View File

@ -0,0 +1,90 @@
# serializer version: 1
# name: test_events[event.fakespa_fault-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
'clock_failed',
'flow_failed',
'gfci_test_failed',
'heater_dry',
'heater_may_be_dry',
'heater_too_hot',
'hot_fault',
'low_flow',
'memory_failure',
'priming_mode',
'pump_stuck',
'sensor_a_fault',
'sensor_b_fault',
'sensor_out_of_sync',
'service_sensor_sync',
'settings_reset',
'standby_mode',
'water_too_hot',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.fakespa_fault',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Fault',
'platform': 'balboa',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fault',
'unique_id': 'FakeSpa-fault-c0ffee',
'unit_of_measurement': None,
})
# ---
# name: test_events[event.fakespa_fault-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'event_type': None,
'event_types': list([
'clock_failed',
'flow_failed',
'gfci_test_failed',
'heater_dry',
'heater_may_be_dry',
'heater_too_hot',
'hot_fault',
'low_flow',
'memory_failure',
'priming_mode',
'pump_stuck',
'sensor_a_fault',
'sensor_b_fault',
'sensor_out_of_sync',
'service_sensor_sync',
'settings_reset',
'standby_mode',
'water_too_hot',
]),
'friendly_name': 'FakeSpa Fault',
}),
'context': <ANY>,
'entity_id': 'event.fakespa_fault',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -0,0 +1,82 @@
"""Tests of the events of the balboa integration."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.event import ATTR_EVENT_TYPE
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import init_integration
from tests.common import snapshot_platform
ENTITY_EVENT = "event.fakespa_fault"
FAULT_DATE = "fault_date"
async def test_events(
hass: HomeAssistant,
client: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test spa events."""
with patch("homeassistant.components.balboa.PLATFORMS", [Platform.EVENT]):
entry = await init_integration(hass)
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
async def test_event(hass: HomeAssistant, client: MagicMock) -> None:
"""Test spa fault event."""
await init_integration(hass)
# check the state is unknown
state = hass.states.get(ENTITY_EVENT)
assert state.state == STATE_UNKNOWN
# set a fault
client.fault = MagicMock(
fault_datetime=datetime(2025, 2, 15, 13, 0), message_code=16
)
client.emit("")
await hass.async_block_till_done()
# check new state is what we expect
state = hass.states.get(ENTITY_EVENT)
assert state.attributes[ATTR_EVENT_TYPE] == "low_flow"
assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00"
assert state.attributes["code"] == 16
# set fault to None
client.fault = None
client.emit("")
await hass.async_block_till_done()
# validate state remains unchanged
state = hass.states.get(ENTITY_EVENT)
assert state.attributes[ATTR_EVENT_TYPE] == "low_flow"
assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00"
assert state.attributes["code"] == 16
# set fault to an unknown one
client.fault = MagicMock(
fault_datetime=datetime(2025, 2, 15, 14, 0), message_code=-1
)
# validate a ValueError is raises
with pytest.raises(ValueError):
client.emit("")
await hass.async_block_till_done()
# validate state remains unchanged
state = hass.states.get(ENTITY_EVENT)
assert state.attributes[ATTR_EVENT_TYPE] == "low_flow"
assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00"
assert state.attributes["code"] == 16