From f76e295204fa1b67bd3c625dc37552e38f8c12fd Mon Sep 17 00:00:00 2001
From: Nathan Spencer <natekspencer@gmail.com>
Date: Sun, 2 Mar 2025 12:24:27 -0700
Subject: [PATCH] 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
---
 homeassistant/components/balboa/__init__.py   |  2 +-
 homeassistant/components/balboa/event.py      | 91 +++++++++++++++++++
 homeassistant/components/balboa/strings.json  | 29 ++++++
 tests/components/balboa/conftest.py           |  2 +
 .../balboa/snapshots/test_event.ambr          | 90 ++++++++++++++++++
 tests/components/balboa/test_event.py         | 82 +++++++++++++++++
 6 files changed, 295 insertions(+), 1 deletion(-)
 create mode 100644 homeassistant/components/balboa/event.py
 create mode 100644 tests/components/balboa/snapshots/test_event.ambr
 create mode 100644 tests/components/balboa/test_event.py

diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py
index 207826d136e..54ae569bb78 100644
--- a/homeassistant/components/balboa/__init__.py
+++ b/homeassistant/components/balboa/__init__.py
@@ -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)
 
diff --git a/homeassistant/components/balboa/event.py b/homeassistant/components/balboa/event.py
new file mode 100644
index 00000000000..57263c34783
--- /dev/null
+++ b/homeassistant/components/balboa/event.py
@@ -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
+            )
+        )
diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json
index 9779984b182..784ce8533a8 100644
--- a/homeassistant/components/balboa/strings.json
+++ b/homeassistant/components/balboa/strings.json
@@ -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}"
diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py
index 90f8fdc3d6e..18639b0c9be 100644
--- a/tests/components/balboa/conftest.py
+++ b/tests/components/balboa/conftest.py
@@ -68,4 +68,6 @@ def client_fixture() -> Generator[MagicMock]:
         client.pumps = []
         client.temperature_range.state = LowHighRange.LOW
 
+        client.fault = None
+
         yield client
diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr
new file mode 100644
index 00000000000..fc8f591a9fc
--- /dev/null
+++ b/tests/components/balboa/snapshots/test_event.ambr
@@ -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',
+  })
+# ---
diff --git a/tests/components/balboa/test_event.py b/tests/components/balboa/test_event.py
new file mode 100644
index 00000000000..04f25f6cfa0
--- /dev/null
+++ b/tests/components/balboa/test_event.py
@@ -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
-- 
GitLab