diff --git a/README.md b/README.md index 947e491..89648ed 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The integration communicates directly with the spa controller over TCP on the lo - Local, asynchronous TCP communication with the spa controller - Home Assistant UI configuration flow - Automatic reconnection after network or bridge interruptions +- Optional Elfin EW11 software restart from Home Assistant - Current and target water temperature - Heating state and heat mode - Ready, Rest and Ready-in-Rest control @@ -82,6 +83,7 @@ The exact Elfin configuration interface varies by model and firmware. The adapte ├── config_flow.py ├── const.py ├── diagnostics.py + ├── elfin.py ├── event.py ├── manifest.json ├── models.py @@ -194,12 +196,15 @@ Some protocol-oriented entities are disabled by default and can be enabled from Management and diagnostic actions include: - Restart stream +- Restart Elfin bridge (disabled by default) - Synchronise clock - Refresh fault log - Refresh device configuration - Clear controller notification - Advance a pump, blower or light to its next state +The **Restart Elfin bridge** button sends the same local management request as the EW11 web interface's Restart control (`CID 20003`). It is separate from **Restart stream**, which only rebuilds Home Assistant's TCP connection. The current implementation uses the stock EW11 HTTP Basic credentials `admin` / `admin` and is disabled by default so installations using other bridge hardware are unaffected. + ### Fault-log event The fault-log event entity reports decoded controller fault information when a fault entry is received. Event attributes can include the message code, description, severity, controller time and age of the stored entry. @@ -257,6 +262,8 @@ Check that: Verify the serial wiring, polarity and bridge settings. A TCP connection alone does not prove that valid RS-485 data is reaching Home Assistant. +If an EW11 remains reachable on the network and port `4257` accepts a TCP connection but the incoming data is no longer valid `0x7E`-framed spa traffic, enable the diagnostic **Restart Elfin bridge** button and restart the adapter. This performs a software restart of the EW11 without factory-resetting its configuration. The normal background reconnect loop should reconnect to the spa stream once the adapter is back online. + Enable the diagnostic entities and inspect the integration logs: ```yaml diff --git a/custom_components/spa_pool/button.py b/custom_components/spa_pool/button.py index f9aea5b..61b2b04 100644 --- a/custom_components/spa_pool/button.py +++ b/custom_components/spa_pool/button.py @@ -11,9 +11,10 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import CONF_HOST, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -36,6 +37,7 @@ MAX_LIGHTS, MAX_PUMPS, ) +from .elfin import ElfinRestartError, async_restart_elfin from .models import SpaState from .protocol import ( SettingsCode, @@ -51,6 +53,7 @@ class SpaPoolButtonAction(Enum): """Action performed by a stateless Spa Pool button.""" RESTART_STREAM = auto() + RESTART_ELFIN_BRIDGE = auto() SYNC_CLOCK = auto() REFRESH_FAULT_LOG = auto() REFRESH_DEVICE_CONFIGURATION = auto() @@ -76,6 +79,15 @@ class SpaPoolButtonEntityDescription(ButtonEntityDescription): action=SpaPoolButtonAction.RESTART_STREAM, requires_stream=False, ), + SpaPoolButtonEntityDescription( + key="restart_elfin_bridge", + translation_key="restart_elfin_bridge", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + action=SpaPoolButtonAction.RESTART_ELFIN_BRIDGE, + requires_stream=False, + ), SpaPoolButtonEntityDescription( key="sync_clock", translation_key="sync_clock", @@ -164,7 +176,6 @@ async def async_setup_entry( SpaPoolToggleButtonEntity( entry=entry, name=f"Light {index + 1} next mode", - # Preserve the unique ID used by the previous light-mode button. unique_key=f"light_{index + 1}_next_mode", toggle_item=ToggleItem(ToggleItem.LIGHT_1 + index), icon="mdi:palette-outline", @@ -237,6 +248,10 @@ async def async_press(self) -> None: await self._async_restart_stream() return + if action is SpaPoolButtonAction.RESTART_ELFIN_BRIDGE: + await self._async_restart_elfin_bridge() + return + try: if action is SpaPoolButtonAction.SYNC_CLOCK: await self._async_sync_clock() @@ -288,6 +303,19 @@ async def _async_restart_stream(self) -> None: "Unable to restart the spa status stream" ) from err + async def _async_restart_elfin_bridge(self) -> None: + """Restart an Elfin EW11 through its management interface.""" + + try: + await async_restart_elfin( + async_get_clientsession(self.hass), + str(self._entry.data[CONF_HOST]), + ) + except ElfinRestartError as err: + raise HomeAssistantError( + "Unable to restart the Elfin bridge" + ) from err + async def _async_sync_clock(self) -> None: """Send the current Home Assistant local time to the spa.""" diff --git a/custom_components/spa_pool/elfin.py b/custom_components/spa_pool/elfin.py new file mode 100644 index 0000000..c6c367f --- /dev/null +++ b/custom_components/spa_pool/elfin.py @@ -0,0 +1,70 @@ +"""Elfin EW11 management helpers.""" + +from __future__ import annotations + +import asyncio +import json +import logging + +from aiohttp import BasicAuth, ClientConnectionError, ClientSession, ServerDisconnectedError + +_LOGGER = logging.getLogger(__name__) + +_ELFIN_RESTART_CID = 20003 +_ELFIN_WEB_PORT = 80 +_ELFIN_USERNAME = "admin" +_ELFIN_PASSWORD = "admin" +_ELFIN_REQUEST_TIMEOUT = 5.0 + + +class ElfinRestartError(Exception): + """Raised when the Elfin restart request cannot be sent.""" + + +async def async_restart_elfin( + session: ClientSession, + host: str, +) -> None: + """Restart an Elfin EW11 through its local management API. + + The request mirrors the EW11 web interface's Restart button: + ``POST /cmd`` with ``CID 20003`` and an empty payload. Stock EW11 + firmware protects the endpoint with HTTP Basic authentication. + + A server-side disconnect after the request has been submitted is expected: + the adapter may reboot before it finishes the HTTP response. + """ + + url = f"http://{host}:{_ELFIN_WEB_PORT}/cmd" + body = "msg=" + json.dumps( + {"CID": _ELFIN_RESTART_CID, "PL": {}}, + separators=(",", ":"), + ) + + try: + async with asyncio.timeout(_ELFIN_REQUEST_TIMEOUT): + async with session.post( + url, + data=body, + headers={"Content-Type": "application/json;charset=utf-8"}, + auth=BasicAuth(_ELFIN_USERNAME, _ELFIN_PASSWORD), + allow_redirects=False, + ) as response: + if response.status == 401: + raise ElfinRestartError( + "Elfin management credentials were rejected" + ) + if response.status >= 400: + raise ElfinRestartError( + f"Elfin restart request returned HTTP {response.status}" + ) + await response.read() + except ServerDisconnectedError: + _LOGGER.debug( + "Elfin %s disconnected while processing the restart request", + host, + ) + except (ClientConnectionError, TimeoutError) as err: + raise ElfinRestartError( + f"Unable to reach the Elfin management interface at {host}" + ) from err diff --git a/custom_components/spa_pool/strings.json b/custom_components/spa_pool/strings.json index c3b7dee..3c54127 100644 --- a/custom_components/spa_pool/strings.json +++ b/custom_components/spa_pool/strings.json @@ -115,6 +115,9 @@ "restart_stream": { "name": "Restart stream" }, + "restart_elfin_bridge": { + "name": "Restart Elfin bridge" + }, "sync_clock": { "name": "Synchronise clock" }, diff --git a/custom_components/spa_pool/translations/en.json b/custom_components/spa_pool/translations/en.json index c3b7dee..3c54127 100644 --- a/custom_components/spa_pool/translations/en.json +++ b/custom_components/spa_pool/translations/en.json @@ -115,6 +115,9 @@ "restart_stream": { "name": "Restart stream" }, + "restart_elfin_bridge": { + "name": "Restart Elfin bridge" + }, "sync_clock": { "name": "Synchronise clock" },