Skip to content

Commit 62e047a

Browse files
committed
Migrate Waterfurnace to UI based config
Modernize the existing waterfurnace integration and allow a UI based configuration option. This initial implementation includes unit testing for the config flow. For codeowners I have included myself, and the original author of the integration (and owner of the waterfurnace python library). Co-authored-by: Claude Sonnet 4.5 [email protected]
1 parent bcdcc12 commit 62e047a

File tree

14 files changed

+784
-37
lines changed

14 files changed

+784
-37
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/components/waterfurnace/__init__.py

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
"""Support for Waterfurnaces."""
1+
"""Support for WaterFurnace geothermal systems."""
2+
3+
from __future__ import annotations
24

35
from datetime import timedelta
46
import logging
57
import threading
68
import time
79

8-
import voluptuous as vol
910
from waterfurnace.waterfurnace import WaterFurnace, WFCredentialError, WFException
1011

1112
from homeassistant.components import persistent_notification
@@ -16,12 +17,16 @@
1617
Platform,
1718
)
1819
from homeassistant.core import HomeAssistant, callback
19-
from homeassistant.helpers import config_validation as cv, discovery
20+
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
21+
from homeassistant.helpers import discovery
2022
from homeassistant.helpers.dispatcher import dispatcher_send
21-
from homeassistant.helpers.typing import ConfigType
23+
24+
from .models import WaterFurnaceConfigEntry
2225

2326
_LOGGER = logging.getLogger(__name__)
2427

28+
PLATFORMS = [Platform.SENSOR]
29+
2530
DOMAIN = "waterfurnace"
2631
UPDATE_TOPIC = f"{DOMAIN}_update"
2732
SCAN_INTERVAL = timedelta(seconds=10)
@@ -31,40 +36,36 @@
3136
NOTIFICATION_TITLE = "WaterFurnace website status"
3237

3338

34-
CONFIG_SCHEMA = vol.Schema(
35-
{
36-
DOMAIN: vol.Schema(
37-
{
38-
vol.Required(CONF_PASSWORD): cv.string,
39-
vol.Required(CONF_USERNAME): cv.string,
40-
}
41-
)
42-
},
43-
extra=vol.ALLOW_EXTRA,
44-
)
45-
39+
async def async_setup_entry(
40+
hass: HomeAssistant, entry: WaterFurnaceConfigEntry
41+
) -> bool:
42+
"""Set up WaterFurnace from a config entry."""
43+
username = entry.data[CONF_USERNAME]
44+
password = entry.data[CONF_PASSWORD]
4645

47-
def setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
48-
"""Set up waterfurnace platform."""
46+
client = WaterFurnace(username, password)
4947

50-
config = base_config[DOMAIN]
51-
52-
username = config[CONF_USERNAME]
53-
password = config[CONF_PASSWORD]
54-
55-
wfconn = WaterFurnace(username, password)
56-
# NOTE(sdague): login will throw an exception if this doesn't
57-
# work, which will abort the setup.
5848
try:
59-
wfconn.login()
60-
except WFCredentialError:
61-
_LOGGER.error("Invalid credentials for waterfurnace login")
62-
return False
63-
64-
hass.data[DOMAIN] = WaterFurnaceData(hass, wfconn)
49+
await hass.async_add_executor_job(client.login)
50+
except WFCredentialError as err:
51+
_LOGGER.error("Invalid credentials for WaterFurnace device")
52+
raise ConfigEntryAuthFailed(
53+
"Authentication failed. Please update your credentials."
54+
) from err
55+
except WFException as err:
56+
_LOGGER.error("Failed to connect to WaterFurnace service: %s", err)
57+
raise ConfigEntryNotReady(
58+
f"Failed to connect to WaterFurnace service: {err}"
59+
) from err
60+
except Exception as err:
61+
_LOGGER.exception("Unexpected error during WaterFurnace setup")
62+
raise ConfigEntryNotReady(f"Unexpected error during setup: {err}") from err
63+
64+
hass.data[DOMAIN] = WaterFurnaceData(hass, client)
6565
hass.data[DOMAIN].start()
6666

67-
discovery.load_platform(hass, Platform.SENSOR, DOMAIN, {}, config)
67+
discovery.load_platform(hass, Platform.SENSOR, DOMAIN, {}, entry.as_dict())
68+
6869
return True
6970

7071

@@ -79,7 +80,7 @@ class WaterFurnaceData(threading.Thread):
7980
to do.
8081
"""
8182

82-
def __init__(self, hass, client):
83+
def __init__(self, hass: HomeAssistant, client) -> None:
8384
"""Initialize the data object."""
8485
super().__init__()
8586
self.hass = hass
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Config flow for WaterFurnace integration."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
import logging
7+
from typing import Any
8+
9+
import voluptuous as vol
10+
from waterfurnace.waterfurnace import WaterFurnace, WFCredentialError, WFException
11+
12+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
13+
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.exceptions import HomeAssistantError
16+
17+
from .const import DOMAIN
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
STEP_USER_DATA_SCHEMA = vol.Schema(
22+
{
23+
vol.Required(CONF_USERNAME): str,
24+
vol.Required(CONF_PASSWORD): str,
25+
}
26+
)
27+
28+
29+
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
30+
"""Validate the user input allows us to connect.
31+
32+
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
33+
"""
34+
username = data[CONF_USERNAME]
35+
password = data[CONF_PASSWORD]
36+
37+
client = WaterFurnace(username, password)
38+
39+
try:
40+
# Login is a blocking call, run in executor
41+
await hass.async_add_executor_job(client.login)
42+
except WFCredentialError as err:
43+
_LOGGER.error("Invalid credentials for WaterFurnace login")
44+
raise InvalidAuth from err
45+
except WFException as err:
46+
_LOGGER.error("Failed to connect to WaterFurnace service: %s", err)
47+
raise CannotConnect from err
48+
except Exception as err:
49+
_LOGGER.exception("Unexpected error connecting to WaterFurnace")
50+
raise CannotConnect from err
51+
52+
gwid = client.gwid
53+
if not gwid:
54+
_LOGGER.error("No GWID found for device")
55+
raise CannotConnect
56+
57+
return {
58+
"title": f"WaterFurnace {gwid}",
59+
"gwid": gwid,
60+
}
61+
62+
63+
class WaterFurnaceConfigFlow(ConfigFlow, domain=DOMAIN):
64+
"""Handle a config flow for WaterFurnace."""
65+
66+
VERSION = 1
67+
MINOR_VERSION = 1
68+
69+
def __init__(self) -> None:
70+
"""Initialize the config flow."""
71+
self._username: str | None = None
72+
73+
async def async_step_user(
74+
self, user_input: dict[str, Any] | None = None
75+
) -> ConfigFlowResult:
76+
"""Handle the initial step."""
77+
errors: dict[str, str] = {}
78+
79+
if user_input is not None:
80+
try:
81+
info = await validate_input(self.hass, user_input)
82+
except CannotConnect:
83+
errors["base"] = "cannot_connect"
84+
except InvalidAuth:
85+
errors["base"] = "invalid_auth"
86+
except Exception:
87+
_LOGGER.exception("Unexpected exception")
88+
errors["base"] = "unknown"
89+
else:
90+
# Set unique ID based on GWID
91+
await self.async_set_unique_id(info["gwid"])
92+
self._abort_if_unique_id_configured()
93+
94+
return self.async_create_entry(
95+
title=info["title"],
96+
data=user_input,
97+
)
98+
99+
return self.async_show_form(
100+
step_id="user",
101+
data_schema=STEP_USER_DATA_SCHEMA,
102+
errors=errors,
103+
)
104+
105+
async def async_step_reauth(
106+
self, entry_data: Mapping[str, Any]
107+
) -> ConfigFlowResult:
108+
"""Handle reauth flow when credentials expire."""
109+
self._username = entry_data.get(CONF_USERNAME)
110+
return await self.async_step_reauth_confirm()
111+
112+
async def async_step_reauth_confirm(
113+
self, user_input: dict[str, Any] | None = None
114+
) -> ConfigFlowResult:
115+
"""Handle reauth confirmation step."""
116+
errors: dict[str, str] = {}
117+
118+
if user_input is not None:
119+
# Get the existing entry
120+
entry = self._get_reauth_entry()
121+
122+
# Merge existing entry data with new credentials
123+
full_input = {**entry.data, **user_input}
124+
125+
try:
126+
info = await validate_input(self.hass, full_input)
127+
except CannotConnect:
128+
errors["base"] = "cannot_connect"
129+
except InvalidAuth:
130+
errors["base"] = "invalid_auth"
131+
except Exception:
132+
_LOGGER.exception("Unexpected exception")
133+
errors["base"] = "unknown"
134+
else:
135+
# Verify the GWID matches the existing entry
136+
await self.async_set_unique_id(info["gwid"])
137+
self._abort_if_unique_id_mismatch(reason="wrong_account")
138+
139+
return self.async_update_reload_and_abort(
140+
entry,
141+
data_updates=user_input,
142+
)
143+
144+
return self.async_show_form(
145+
step_id="reauth_confirm",
146+
data_schema=vol.Schema(
147+
{
148+
vol.Required(CONF_USERNAME, default=self._username): str,
149+
vol.Required(CONF_PASSWORD): str,
150+
}
151+
),
152+
errors=errors,
153+
)
154+
155+
156+
class CannotConnect(HomeAssistantError):
157+
"""Error to indicate we cannot connect."""
158+
159+
160+
class InvalidAuth(HomeAssistantError):
161+
"""Error to indicate there is invalid auth."""
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Constants for the WaterFurnace integration."""
2+
3+
from datetime import timedelta
4+
from typing import Final
5+
6+
DOMAIN: Final = "waterfurnace"
7+
8+
# Update intervals
9+
SCAN_INTERVAL: Final = timedelta(seconds=10)
10+
ERROR_INTERVAL: Final = timedelta(seconds=300)
11+
12+
# Connection settings
13+
MAX_FAILS: Final = 10

homeassistant/components/waterfurnace/manifest.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
{
22
"domain": "waterfurnace",
33
"name": "WaterFurnace",
4-
"codeowners": [],
4+
"codeowners": ["@sdague", "@masterkoppa"],
5+
"config_flow": true,
56
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
7+
"integration_type": "device",
68
"iot_class": "cloud_polling",
79
"loggers": ["waterfurnace"],
810
"quality_scale": "legacy",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Models for the WaterFurnace integration."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
from waterfurnace.waterfurnace import WaterFurnace
8+
9+
from homeassistant.config_entries import ConfigEntry
10+
11+
12+
@dataclass
13+
class WaterFurnaceData:
14+
"""Data for the WaterFurnace integration."""
15+
16+
client: WaterFurnace
17+
gwid: str
18+
19+
20+
type WaterFurnaceConfigEntry = ConfigEntry[WaterFurnaceData]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"config": {
3+
"abort": {
4+
"already_configured": "This device is already configured",
5+
"no_devices": "No devices found on this account",
6+
"reauth_successful": "Reauthentication successful",
7+
"wrong_account": "The credentials provided are for a different account"
8+
},
9+
"error": {
10+
"cannot_connect": "Failed to connect to WaterFurnace service",
11+
"invalid_auth": "Invalid username or password",
12+
"unknown": "An unexpected error occurred"
13+
},
14+
"step": {
15+
"reauth_confirm": {
16+
"data": {
17+
"password": "Password",
18+
"username": "Username"
19+
},
20+
"data_description": {
21+
"password": "Your new WaterFurnace Symphony account password",
22+
"username": "Your WaterFurnace Symphony account username"
23+
},
24+
"description": "The credentials for your WaterFurnace account need to be updated.",
25+
"title": "Reauthenticate WaterFurnace"
26+
},
27+
"user": {
28+
"data": {
29+
"password": "Password",
30+
"username": "Username"
31+
},
32+
"data_description": {
33+
"password": "Your WaterFurnace Symphony account password",
34+
"username": "Your WaterFurnace Symphony account username"
35+
},
36+
"description": "Enter your WaterFurnace Symphony account credentials.",
37+
"title": "Connect to WaterFurnace"
38+
}
39+
}
40+
}
41+
}

homeassistant/generated/config_flows.py

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/generated/integrations.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7462,8 +7462,8 @@
74627462
},
74637463
"waterfurnace": {
74647464
"name": "WaterFurnace",
7465-
"integration_type": "hub",
7466-
"config_flow": false,
7465+
"integration_type": "device",
7466+
"config_flow": true,
74677467
"iot_class": "cloud_polling"
74687468
},
74697469
"watergate": {

requirements_test_all.txt

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)