IntelliFire Config API Token Config Update (#68134)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jeef 2022-04-21 10:14:13 -06:00 committed by GitHub
parent 73a368c242
commit 4d09078114
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 338 additions and 69 deletions

View file

@ -1,11 +1,14 @@
"""The IntelliFire integration."""
from __future__ import annotations
from intellifire4py import IntellifireAsync
from aiohttp import ClientConnectionError
from intellifire4py import IntellifireAsync, IntellifireControlAsync
from intellifire4py.exceptions import LoginException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN, LOGGER
from .coordinator import IntellifireDataUpdateCoordinator
@ -17,17 +20,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up IntelliFire from a config entry."""
LOGGER.debug("Setting up config entry: %s", entry.unique_id)
# Define the API Object
api_object = IntellifireAsync(entry.data[CONF_HOST])
if CONF_USERNAME not in entry.data:
LOGGER.debug("Old config entry format detected: %s", entry.unique_id)
raise ConfigEntryAuthFailed
# Define the API Objects
read_object = IntellifireAsync(entry.data[CONF_HOST])
ift_control = IntellifireControlAsync(
fireplace_ip=entry.data[CONF_HOST],
)
try:
await ift_control.login(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
except (ConnectionError, ClientConnectionError) as err:
raise ConfigEntryNotReady from err
except LoginException as err:
raise ConfigEntryAuthFailed(err) from err
finally:
await ift_control.close()
# Define the update coordinator
coordinator = IntellifireDataUpdateCoordinator(
hass=hass,
api=api_object,
hass=hass, read_api=read_object, control_api=ift_control
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True

View file

@ -164,4 +164,4 @@ class IntellifireBinarySensor(IntellifireEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Use this to get the correct value."""
return self.entity_description.value_fn(self.coordinator.api.data)
return self.entity_description.value_fn(self.coordinator.read_api.data)

View file

@ -5,12 +5,17 @@ from dataclasses import dataclass
from typing import Any
from aiohttp import ClientConnectionError
from intellifire4py import AsyncUDPFireplaceFinder, IntellifireAsync
from intellifire4py import (
AsyncUDPFireplaceFinder,
IntellifireAsync,
IntellifireControlAsync,
)
from intellifire4py.exceptions import LoginException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN, LOGGER
@ -48,9 +53,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self):
"""Initialize the Config Flow Handler."""
self._config_context = {}
self._host: str = ""
self._serial: str = ""
self._not_configured_hosts: list[DiscoveredHostInfo] = []
self._discovered_host: DiscoveredHostInfo
self._reauth_needed: DiscoveredHostInfo
async def _find_fireplaces(self):
"""Perform UDP discovery."""
@ -71,31 +78,102 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
LOGGER.debug("Configured Hosts: %s", configured_hosts)
LOGGER.debug("Not Configured Hosts: %s", self._not_configured_hosts)
async def _async_validate_and_create_entry(self, host: str) -> FlowResult:
"""Validate and create the entry."""
self._async_abort_entries_match({CONF_HOST: host})
serial = await validate_host_input(host)
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
return self.async_create_entry(
title=f"Fireplace {serial}",
data={CONF_HOST: host},
async def validate_api_access_and_create_or_update(
self, *, host: str, username: str, password: str, serial: str
):
"""Validate username/password against api."""
ift_control = IntellifireControlAsync(fireplace_ip=host)
LOGGER.debug("Attempting login to iftapi with: %s", username)
# This can throw an error which will be handled above
try:
await ift_control.login(username=username, password=password)
await ift_control.get_username()
finally:
await ift_control.close()
data = {CONF_HOST: host, CONF_PASSWORD: password, CONF_USERNAME: username}
# Update or Create
existing_entry = await self.async_set_unique_id(serial)
if existing_entry:
self.hass.config_entries.async_update_entry(existing_entry, data=data)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=f"Fireplace {serial}", data=data)
async def async_step_api_config(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Configure API access."""
errors = {}
control_schema = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
if user_input is not None:
control_schema = vol.Schema(
{
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
): str,
vol.Required(
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
): str,
}
)
if user_input[CONF_USERNAME] != "":
try:
return await self.validate_api_access_and_create_or_update(
host=self._host,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
serial=self._serial,
)
except (ConnectionError, ClientConnectionError):
errors["base"] = "iftapi_connect"
LOGGER.info("ERROR: iftapi_connect")
except LoginException:
errors["base"] = "api_error"
LOGGER.info("ERROR: api_error")
return self.async_show_form(
step_id="api_config", errors=errors, data_schema=control_schema
)
async def _async_validate_ip_and_continue(self, host: str) -> FlowResult:
"""Validate local config and continue."""
self._async_abort_entries_match({CONF_HOST: host})
self._serial = await validate_host_input(host)
await self.async_set_unique_id(self._serial, raise_on_progress=False)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
# Store current data and jump to next stage
self._host = host
return await self.async_step_api_config()
async def async_step_manual_device_entry(self, user_input=None):
"""Handle manual input of local IP configuration."""
LOGGER.debug("STEP: manual_device_entry")
errors = {}
host = user_input.get(CONF_HOST) if user_input else None
self._host = user_input.get(CONF_HOST) if user_input else None
if user_input is not None:
try:
return await self._async_validate_and_create_entry(host)
return await self._async_validate_ip_and_continue(self._host)
except (ConnectionError, ClientConnectionError):
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="manual_device_entry",
errors=errors,
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
data_schema=vol.Schema({vol.Required(CONF_HOST, default=self._host): str}),
)
async def async_step_pick_device(
@ -103,15 +181,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Pick which device to configure."""
errors = {}
LOGGER.debug("STEP: pick_device")
if user_input is not None:
if user_input[CONF_HOST] == MANUAL_ENTRY_STRING:
return await self.async_step_manual_device_entry()
try:
return await self._async_validate_and_create_entry(
user_input[CONF_HOST]
)
return await self._async_validate_ip_and_continue(user_input[CONF_HOST])
except (ConnectionError, ClientConnectionError):
errors["base"] = "cannot_connect"
@ -135,30 +212,44 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# Launch fireplaces discovery
await self._find_fireplaces()
LOGGER.debug("STEP: user")
if self._not_configured_hosts:
LOGGER.debug("Running Step: pick_device")
return await self.async_step_pick_device()
LOGGER.debug("Running Step: manual_device_entry")
return await self.async_step_manual_device_entry()
async def async_step_reauth(self, user_input=None):
"""Perform reauth upon an API authentication error."""
LOGGER.debug("STEP: reauth")
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
# populate the expected vars
self._serial = entry.unique_id
self._host = entry.data[CONF_HOST]
placeholders = {CONF_HOST: self._host, "serial": self._serial}
self.context["title_placeholders"] = placeholders
return await self.async_step_api_config()
async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult:
"""Handle DHCP Discovery."""
LOGGER.debug("STEP: dhcp")
# Run validation logic on ip
host = discovery_info.ip
self._async_abort_entries_match({CONF_HOST: host})
try:
serial = await validate_host_input(host)
self._serial = await validate_host_input(host)
except (ConnectionError, ClientConnectionError):
return self.async_abort(reason="not_intellifire_device")
await self.async_set_unique_id(serial)
await self.async_set_unique_id(self._serial)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._discovered_host = DiscoveredHostInfo(ip=host, serial=serial)
self._discovered_host = DiscoveredHostInfo(ip=host, serial=self._serial)
placeholders = {CONF_HOST: host, "serial": serial}
placeholders = {CONF_HOST: host, "serial": self._serial}
self.context["title_placeholders"] = placeholders
self._set_confirm_only()
@ -167,6 +258,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_dhcp_confirm(self, user_input=None):
"""Attempt to confirm."""
LOGGER.debug("STEP: dhcp_confirm")
# Add the hosts one by one
host = self._discovered_host.ip
serial = self._discovered_host.serial

View file

@ -6,3 +6,5 @@ import logging
DOMAIN = "intellifire"
LOGGER = logging.getLogger(__package__)
CONF_SERIAL = "serial"

View file

@ -5,7 +5,11 @@ from datetime import timedelta
from aiohttp import ClientConnectionError
from async_timeout import timeout
from intellifire4py import IntellifireAsync, IntellifirePollData
from intellifire4py import (
IntellifireAsync,
IntellifireControlAsync,
IntellifirePollData,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
@ -17,7 +21,12 @@ from .const import DOMAIN, LOGGER
class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData]):
"""Class to manage the polling of the fireplace API."""
def __init__(self, hass: HomeAssistant, api: IntellifireAsync) -> None:
def __init__(
self,
hass: HomeAssistant,
read_api: IntellifireAsync,
control_api: IntellifireControlAsync,
) -> None:
"""Initialize the Coordinator."""
super().__init__(
hass,
@ -25,21 +34,27 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData
name=DOMAIN,
update_interval=timedelta(seconds=15),
)
self._api = api
self._read_api = read_api
self._control_api = control_api
async def _async_update_data(self) -> IntellifirePollData:
LOGGER.debug("Calling update loop on IntelliFire")
async with timeout(100):
try:
await self._api.poll()
await self._read_api.poll()
except (ConnectionError, ClientConnectionError) as exception:
raise UpdateFailed from exception
return self._api.data
return self._read_api.data
@property
def api(self) -> IntellifireAsync:
"""Return the API pointer."""
return self._api
def read_api(self) -> IntellifireAsync:
"""Return the Status API pointer."""
return self._read_api
@property
def control_api(self) -> IntellifireControlAsync:
"""Return the control API."""
return self._control_api
@property
def device_info(self) -> DeviceInfo:
@ -48,6 +63,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData
manufacturer="Hearth and Home",
model="IFT-WFM",
name="IntelliFire Fireplace",
identifiers={("IntelliFire", f"{self.api.data.serial}]")},
sw_version=self.api.data.fw_ver_str,
identifiers={("IntelliFire", f"{self.read_api.data.serial}]")},
sw_version=self.read_api.data.fw_ver_str,
configuration_url=f"http://{self.read_api.ip}/poll",
)

View file

@ -22,6 +22,6 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]):
self.entity_description = description
# Set the Display name the User will see
self._attr_name = f"Fireplace {description.name}"
self._attr_unique_id = f"{description.key}_{coordinator.api.data.serial}"
self._attr_unique_id = f"{description.key}_{coordinator.read_api.data.serial}"
# Configure the Device Info
self._attr_device_info = self.coordinator.device_info

View file

@ -150,4 +150,4 @@ class IntellifireSensor(IntellifireEntity, SensorEntity):
@property
def native_value(self) -> int | str | datetime | None:
"""Return the state."""
return self.entity_description.value_fn(self.coordinator.api.data)
return self.entity_description.value_fn(self.coordinator.read_api.data)

View file

@ -5,23 +5,34 @@
"manual_device_entry": {
"description": "Local Configuration",
"data": {
"host": "[%key:common::config_flow::data::host%]"
"host": "[%key:common::config_flow::data::host%] (IP Address)"
}
},
"api_config": {
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"dhcp_confirm": {
"description": "Do you want to setup {host}\nSerial: {serial}?"
},
"pick_device": {
"title": "Device Selection",
"description": "The following IntelliFire devices were discovered. Please select which you wish to configure.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"api_error": "Login failed",
"iftapi_connect": "Error conecting to iftapi.net"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"not_intellifire_device": "Not an IntelliFire Device."
}
}

View file

@ -2,26 +2,39 @@
"config": {
"abort": {
"already_configured": "Device is already configured",
"not_intellifire_device": "Not an IntelliFire Device."
"not_intellifire_device": "Not an IntelliFire Device.",
"reauth_successful": "Re-authentication was successful"
},
"error": {
"cannot_connect": "Failed to connect"
"api_error": "Login failed",
"cannot_connect": "Could not connect to a fireplace endpoint at url: http://{host}/poll\nVerify IP address and try again",
"iftapi_connect": "Error conecting to iftapi.net"
},
"flow_title": "{serial} ({host})",
"step": {
"api_config": {
"data": {
"password": "Password",
"username": "Username (Email)"
},
"description": "IntelliFire will need to reach out to [iftapi.net](https://iftapi.net/webaccess/login.html) in order to obtain an API key. Once it has obtained this API key, the rest of its interactions will occur completely within the local network. If the API key were to expire it would again need to reach out to https://iftapi.net/webaccess/login.html\n\nUsername and Password are the same information used in your IntelliFire Android/iOS application. ",
"title": "IntelliFire - API Configuration"
},
"dhcp_confirm": {
"description": "Do you want to setup {host}\nSerial: {serial}?"
},
"manual_device_entry": {
"data": {
"host": "Host"
"host": "Host (IP Address)"
},
"description": "Local Configuration"
},
"pick_device": {
"data": {
"host": "Host"
}
},
"description": "The following IntelliFire devices were discovered. Please select which you wish to configure.",
"title": "Device Selection"
}
}
}

View file

@ -2,6 +2,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from aiohttp.client_reqrep import ConnectionKey
import pytest
@ -49,3 +50,10 @@ def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]:
intellifire = intellifire_mock.return_value
intellifire.data = data_mock
yield intellifire
def mock_api_connection_error() -> ConnectionError:
"""Return a fake a ConnectionError for iftapi.net."""
ret = ConnectionError()
ret.args = [ConnectionKey("iftapi.net", 443, False, None, None, None, None)]
return ret

View file

@ -1,17 +1,29 @@
"""Test the IntelliFire config flow."""
from unittest.mock import AsyncMock, MagicMock, patch
from intellifire4py.exceptions import LoginException
from homeassistant import config_entries
from homeassistant.components import dhcp
from homeassistant.components.intellifire.config_flow import MANUAL_ENTRY_STRING
from homeassistant.components.intellifire.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from tests.common import MockConfigEntry
from tests.components.intellifire.conftest import mock_api_connection_error
@patch.multiple(
"homeassistant.components.intellifire.config_flow.IntellifireControlAsync",
login=AsyncMock(),
get_username=AsyncMock(return_value="intellifire"),
)
async def test_no_discovery(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
@ -36,12 +48,31 @@ async def test_no_discovery(
},
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "Fireplace 12345"
assert result2["data"] == {CONF_HOST: "1.1.1.1"}
assert result2["type"] == RESULT_TYPE_FORM
assert result2["step_id"] == "api_config"
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"},
)
await hass.async_block_till_done()
assert result3["type"] == RESULT_TYPE_CREATE_ENTRY
assert result3["title"] == "Fireplace 12345"
assert result3["data"] == {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "test",
CONF_PASSWORD: "AROONIE",
}
assert len(mock_setup_entry.mock_calls) == 1
@patch.multiple(
"homeassistant.components.intellifire.config_flow.IntellifireControlAsync",
login=AsyncMock(side_effect=mock_api_connection_error()),
get_username=AsyncMock(return_value="intellifire"),
)
async def test_single_discovery(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
@ -56,16 +87,48 @@ async def test_single_discovery(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.69"}
)
await hass.async_block_till_done()
print("Result:", result)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"},
)
await hass.async_block_till_done()
assert result3["type"] == RESULT_TYPE_FORM
assert result3["errors"] == {"base": "iftapi_connect"}
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "Fireplace 12345"
assert result2["data"] == {CONF_HOST: "192.168.1.69"}
assert len(mock_setup_entry.mock_calls) == 1
@patch.multiple(
"homeassistant.components.intellifire.config_flow.IntellifireControlAsync",
login=AsyncMock(side_effect=LoginException()),
)
async def test_single_discovery_loign_error(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test single fireplace UDP discovery."""
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=["192.168.1.69"],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.69"}
)
await hass.async_block_till_done()
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"},
)
await hass.async_block_till_done()
assert result3["type"] == RESULT_TYPE_FORM
assert result3["errors"] == {"base": "api_error"}
async def test_manual_entry(
@ -73,7 +136,7 @@ async def test_manual_entry(
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test for multiple firepalce discovery - involing a pick_device step."""
"""Test for multiple Fireplace discovery - involving a pick_device step."""
with patch(
"homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace",
return_value=["192.168.1.69", "192.168.1.33", "192.168.169"],
@ -107,15 +170,12 @@ async def test_multi_discovery(
)
assert result["step_id"] == "pick_device"
result2 = await hass.config_entries.flow.async_configure(
await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "192.168.1.33"}
)
await hass.async_block_till_done()
assert result["step_id"] == "pick_device"
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
async def test_multi_discovery_cannot_connect(
hass: HomeAssistant,
@ -200,10 +260,56 @@ async def test_picker_already_discovered(
CONF_HOST: "192.168.1.4",
},
)
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "Fireplace 12345"
assert result2["data"] == {CONF_HOST: "192.168.1.4"}
assert len(mock_setup_entry.mock_calls) == 2
assert result2["type"] == RESULT_TYPE_FORM
assert len(mock_setup_entry.mock_calls) == 0
@patch.multiple(
"homeassistant.components.intellifire.config_flow.IntellifireControlAsync",
login=AsyncMock(),
get_username=AsyncMock(return_value="intellifire"),
)
async def test_reauth_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_intellifire_config_flow: MagicMock,
) -> None:
"""Test the reauth flow."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "192.168.1.3",
},
title="Fireplace 1234",
version=1,
unique_id="4444",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": "reauth",
"unique_id": entry.unique_id,
"entry_id": entry.entry_id,
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_config"
with patch(
"homeassistant.config_entries.ConfigFlow.async_set_unique_id",
return_value=entry,
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"},
)
await hass.async_block_till_done()
assert result3["type"] == RESULT_TYPE_ABORT
async def test_dhcp_discovery_intellifire_device(