Migrate waterfurnace to config flow (#159908)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Andres Ruiz
2026-01-26 09:05:43 -05:00
committed by GitHub
parent 6d064dfca0
commit 9fb1612dac
14 changed files with 555 additions and 37 deletions

1
CODEOWNERS generated
View File

@@ -1809,6 +1809,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/tests/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai

View File

@@ -1,4 +1,6 @@
"""Support for Waterfurnaces."""
"""Support for WaterFurnace geothermal systems."""
from __future__ import annotations
from datetime import timedelta
import logging
@@ -9,62 +11,121 @@ import voluptuous as vol
from waterfurnace.waterfurnace import WaterFurnace, WFCredentialError, WFException
from homeassistant.components import persistent_notification
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, INTEGRATION_TITLE, MAX_FAILS
_LOGGER = logging.getLogger(__name__)
DOMAIN = "waterfurnace"
PLATFORMS = [Platform.SENSOR]
UPDATE_TOPIC = f"{DOMAIN}_update"
SCAN_INTERVAL = timedelta(seconds=10)
ERROR_INTERVAL = timedelta(seconds=300)
MAX_FAILS = 10
NOTIFICATION_ID = "waterfurnace_website_notification"
NOTIFICATION_TITLE = "WaterFurnace website status"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
type WaterFurnaceConfigEntry = ConfigEntry[WaterFurnaceData]
def setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
"""Set up waterfurnace platform."""
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Import the WaterFurnace configuration from YAML."""
if DOMAIN not in config:
return True
config = base_config[DOMAIN]
hass.async_create_task(_async_setup(hass, config))
username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
return True
async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
async def async_setup_entry(
hass: HomeAssistant, entry: WaterFurnaceConfigEntry
) -> bool:
"""Set up WaterFurnace from a config entry."""
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
client = WaterFurnace(username, password)
wfconn = WaterFurnace(username, password)
# NOTE(sdague): login will throw an exception if this doesn't
# work, which will abort the setup.
try:
wfconn.login()
except WFCredentialError:
_LOGGER.error("Invalid credentials for waterfurnace login")
return False
await hass.async_add_executor_job(client.login)
except WFCredentialError as err:
raise ConfigEntryAuthFailed(
"Authentication failed. Please update your credentials."
) from err
hass.data[DOMAIN] = WaterFurnaceData(hass, wfconn)
hass.data[DOMAIN].start()
if not client.gwid:
raise ConfigEntryNotReady(
"Failed to connect to WaterFurnace service: No GWID found for device"
)
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
discovery.load_platform(hass, Platform.SENSOR, DOMAIN, {}, config)
return True
@@ -79,7 +140,7 @@ class WaterFurnaceData(threading.Thread):
to do.
"""
def __init__(self, hass, client):
def __init__(self, hass: HomeAssistant, client) -> None:
"""Initialize the data object."""
super().__init__()
self.hass = hass

View File

@@ -0,0 +1,105 @@
"""Config flow for WaterFurnace integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from waterfurnace.waterfurnace import WaterFurnace, WFCredentialError, WFException
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class WaterFurnaceConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for WaterFurnace."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
client = WaterFurnace(username, password)
try:
# Login is a blocking call, run in executor
await self.hass.async_add_executor_job(client.login)
except WFCredentialError:
errors["base"] = "invalid_auth"
except WFException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error connecting to WaterFurnace")
errors["base"] = "unknown"
gwid = client.gwid
if not gwid:
errors["base"] = "cannot_connect"
if not errors:
# Set unique ID based on GWID
await self.async_set_unique_id(gwid)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"WaterFurnace {username}",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML configuration."""
username = import_data[CONF_USERNAME]
password = import_data[CONF_PASSWORD]
client = WaterFurnace(username, password)
try:
# Login is a blocking call, run in executor
await self.hass.async_add_executor_job(client.login)
except WFCredentialError:
return self.async_abort(reason="invalid_auth")
except WFException:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected error importing WaterFurnace configuration")
return self.async_abort(reason="unknown")
gwid = client.gwid
if not gwid:
# This likely indicates a server-side change, or an implementation bug
return self.async_abort(reason="cannot_connect")
# Set unique ID based on GWID
await self.async_set_unique_id(gwid)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"WaterFurnace {username}",
data=import_data,
)

View File

@@ -0,0 +1,10 @@
"""Constants for the WaterFurnace integration."""
from typing import Final
DOMAIN: Final = "waterfurnace"
INTEGRATION_TITLE: Final = "WaterFurnace"
# Connection settings
MAX_FAILS: Final = 10

View File

@@ -2,6 +2,7 @@
"domain": "waterfurnace",
"name": "WaterFurnace",
"codeowners": ["@sdague", "@masterkoppa"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
"integration_type": "device",
"iot_class": "cloud_polling",

View File

@@ -12,11 +12,10 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, UnitOfPower, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from . import DOMAIN, UPDATE_TOPIC, WaterFurnaceData
from . import UPDATE_TOPIC, WaterFurnaceConfigEntry, WaterFurnaceData
SENSORS = [
SensorEntityDescription(name="Furnace Mode", key="mode", icon="mdi:gauge"),
@@ -106,19 +105,19 @@ SENSORS = [
]
def setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
config_entry: WaterFurnaceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Waterfurnace sensor."""
if discovery_info is None:
return
"""Set up Waterfurnace sensors from a config entry."""
client = hass.data[DOMAIN]
data_collector = WaterFurnaceData(hass, config_entry.runtime_data)
data_collector.start()
add_entities(WaterFurnaceSensor(client, description) for description in SENSORS)
async_add_entities(
WaterFurnaceSensor(data_collector, description) for description in SENSORS
)
class WaterFurnaceSensor(SensorEntity):

View File

@@ -0,0 +1,43 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"cannot_connect": "Please verify your credentials.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "Unexpected error, please try again."
},
"error": {
"cannot_connect": "Failed to connect to WaterFurnace service",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your WaterFurnace Symphony account password",
"username": "Your WaterFurnace Symphony account username"
},
"description": "Enter your WaterFurnace Symphony account credentials.",
"title": "Connect to WaterFurnace"
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, we could not connect to {integration_title}. Please check your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::waterfurnace::issues::deprecated_yaml_import_issue_unknown::title%]"
},
"deprecated_yaml_import_issue_invalid_auth": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::waterfurnace::issues::deprecated_yaml_import_issue_unknown::title%]"
},
"deprecated_yaml_import_issue_unknown": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration an unknown exception has been encountered. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "WaterFurnace YAML configuration import failed"
}
}
}

View File

@@ -756,6 +756,7 @@ FLOWS = {
"wake_on_lan",
"wallbox",
"waqi",
"waterfurnace",
"watergate",
"watts",
"watttime",

View File

@@ -7477,7 +7477,7 @@
"waterfurnace": {
"name": "WaterFurnace",
"integration_type": "device",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_polling"
},
"watergate": {

View File

@@ -2654,6 +2654,9 @@ wallbox==0.9.0
# homeassistant.components.folder_watcher
watchdog==6.0.0
# homeassistant.components.waterfurnace
waterfurnace==1.4.0
# homeassistant.components.watergate
watergate-local-api==2025.1.0

View File

@@ -0,0 +1 @@
"""Tests for the WaterFurnace integration."""

View File

@@ -0,0 +1,53 @@
"""Fixtures for WaterFurnace integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
import pytest
from waterfurnace.waterfurnace import WFReading
from homeassistant.components.waterfurnace.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.waterfurnace.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
@pytest.fixture
def mock_waterfurnace_client() -> Generator[Mock]:
"""Mock WaterFurnace client."""
with patch(
"homeassistant.components.waterfurnace.config_flow.WaterFurnace",
autospec=True,
) as mock_client_class:
mock_client = mock_client_class.return_value
mock_client.gwid = "TEST_GWID_12345"
device_data = WFReading(load_json_object_fixture("device_data.json", DOMAIN))
mock_client.read.return_value = device_data
yield mock_client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="WaterFurnace test_user",
data={
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
},
unique_id="TEST_GWID_12345",
)

View File

@@ -0,0 +1,16 @@
{
"modeofoperation": 6,
"totalunitpower": 1500,
"tstatactivesetpoint": 72,
"leavingairtemp": 110.5,
"tstatroomtemp": 70.2,
"enteringwatertemp": 42.8,
"tstathumidsetpoint": 45,
"tstatrelativehumidity": 43,
"compressorpower": 800,
"fanpower": 150,
"auxpower": 0,
"looppumppower": 50,
"actualcompressorspeed": 1200,
"airflowcurrentspeed": 850
}

View File

@@ -0,0 +1,224 @@
"""Test the WaterFurnace config flow."""
from unittest.mock import AsyncMock, Mock
import pytest
from waterfurnace.waterfurnace import WFCredentialError, WFException
from homeassistant.components.waterfurnace.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_user_flow_success(
hass: HomeAssistant, mock_waterfurnace_client: Mock, mock_setup_entry: AsyncMock
) -> None:
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test_user", CONF_PASSWORD: "test_password"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "WaterFurnace test_user"
assert result["data"] == {
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
}
assert result["result"].unique_id == "TEST_GWID_12345"
# Verify login was called (once during config flow, once during setup)
assert mock_waterfurnace_client.login.called
@pytest.mark.parametrize(
("exception", "error"),
[
(WFCredentialError("Invalid credentials"), "invalid_auth"),
(WFException("Connection failed"), "cannot_connect"),
(Exception("Unexpected error"), "unknown"),
],
)
async def test_user_flow_exceptions(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test user flow with invalid credentials."""
mock_waterfurnace_client.login.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "bad_user", CONF_PASSWORD: "bad_password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
# Verify we can recover from the error
mock_waterfurnace_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test_user", CONF_PASSWORD: "test_password"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_no_gwid(
hass: HomeAssistant, mock_waterfurnace_client: Mock, mock_setup_entry: AsyncMock
) -> None:
"""Test user flow with invalid credentials."""
mock_waterfurnace_client.gwid = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "bad_user",
CONF_PASSWORD: "bad_password",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
mock_waterfurnace_client.gwid = "TEST_GWID_12345"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_already_configured(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user flow when device is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_import_flow_success(
hass: HomeAssistant, mock_waterfurnace_client: Mock, mock_setup_entry: AsyncMock
) -> None:
"""Test successful import flow from YAML."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_USERNAME: "test_user", CONF_PASSWORD: "test_password"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "WaterFurnace test_user"
assert result["data"] == {
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
}
assert result["result"].unique_id == "TEST_GWID_12345"
async def test_import_flow_already_configured(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test import flow when device is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_USERNAME: "test_user", CONF_PASSWORD: "test_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("exception", "reason"),
[
(WFCredentialError("Invalid credentials"), "invalid_auth"),
(WFException("Connection failed"), "cannot_connect"),
(Exception("Unexpected error"), "unknown"),
],
)
async def test_import_flow_exceptions(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
exception: Exception,
reason: str,
) -> None:
"""Test import flow with connection error."""
mock_waterfurnace_client.login.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_USERNAME: "test_user", CONF_PASSWORD: "test_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
async def test_import_flow_no_gwid(
hass: HomeAssistant, mock_waterfurnace_client: Mock
) -> None:
"""Test import flow with connection error."""
mock_waterfurnace_client.gwid = None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_USERNAME: "test_user", CONF_PASSWORD: "test_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"