Add support for login credentials to homeworks (#122877)

* Add support for login credentials to homeworks

* Store credentials in config entry data
This commit is contained in:
Erik Montnemery 2024-07-31 10:35:05 +02:00 committed by GitHub
parent 718bc61c88
commit f6f7459c36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 331 additions and 58 deletions

View File

@ -9,7 +9,12 @@ import logging
from typing import Any
from pyhomeworks import exceptions as hw_exceptions
from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED, Homeworks
from pyhomeworks.pyhomeworks import (
HW_BUTTON_PRESSED,
HW_BUTTON_RELEASED,
HW_LOGIN_INCORRECT,
Homeworks,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@ -17,7 +22,9 @@ from homeassistant.const import (
CONF_HOST,
CONF_ID,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
@ -137,12 +144,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def hw_callback(msg_type: Any, values: Any) -> None:
"""Dispatch state changes."""
_LOGGER.debug("callback: %s, %s", msg_type, values)
if msg_type == HW_LOGIN_INCORRECT:
_LOGGER.debug("login incorrect")
return
addr = values[0]
signal = f"homeworks_entity_{controller_id}_{addr}"
dispatcher_send(hass, signal, msg_type, values)
config = entry.options
controller = Homeworks(config[CONF_HOST], config[CONF_PORT], hw_callback)
controller = Homeworks(
config[CONF_HOST],
config[CONF_PORT],
hw_callback,
entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD),
)
try:
await hass.async_add_executor_job(controller.connect)
except hw_exceptions.HomeworksException as err:

View File

@ -14,7 +14,13 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
)
from homeassistant.core import async_get_hass, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import (
@ -62,6 +68,10 @@ CONTROLLER_EDIT = {
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_USERNAME): selector.TextSelector(),
vol.Optional(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
}
LIGHT_EDIT: VolDictType = {
@ -92,10 +102,17 @@ BUTTON_EDIT: VolDictType = {
validate_addr = cv.matches_regex(r"\[(?:\d\d:){2,4}\d\d\]")
def _validate_credentials(user_input: dict[str, Any]) -> None:
"""Validate credentials."""
if CONF_PASSWORD in user_input and CONF_USERNAME not in user_input:
raise SchemaFlowError("need_username_with_password")
async def validate_add_controller(
handler: ConfigFlow | SchemaOptionsFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate controller setup."""
_validate_credentials(user_input)
user_input[CONF_CONTROLLER_ID] = slugify(user_input[CONF_NAME])
user_input[CONF_PORT] = int(user_input[CONF_PORT])
try:
@ -128,7 +145,13 @@ async def _try_connection(user_input: dict[str, Any]) -> None:
_LOGGER.debug(
"Trying to connect to %s:%s", user_input[CONF_HOST], user_input[CONF_PORT]
)
controller = Homeworks(host, port, lambda msg_types, values: None)
controller = Homeworks(
host,
port,
lambda msg_types, values: None,
user_input.get(CONF_USERNAME),
user_input.get(CONF_PASSWORD),
)
controller.connect()
controller.close()
@ -138,7 +161,14 @@ async def _try_connection(user_input: dict[str, Any]) -> None:
_try_connect, user_input[CONF_HOST], user_input[CONF_PORT]
)
except hw_exceptions.HomeworksConnectionFailed as err:
_LOGGER.debug("Caught HomeworksConnectionFailed")
raise SchemaFlowError("connection_error") from err
except hw_exceptions.HomeworksInvalidCredentialsProvided as err:
_LOGGER.debug("Caught HomeworksInvalidCredentialsProvided")
raise SchemaFlowError("invalid_credentials") from err
except hw_exceptions.HomeworksNoCredentialsProvided as err:
_LOGGER.debug("Caught HomeworksNoCredentialsProvided")
raise SchemaFlowError("credentials_needed") from err
except Exception as err:
_LOGGER.exception("Caught unexpected exception %s")
raise SchemaFlowError("unknown_error") from err
@ -529,6 +559,7 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate controller setup."""
_validate_credentials(user_input)
user_input[CONF_PORT] = int(user_input[CONF_PORT])
our_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
@ -569,12 +600,19 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN):
except SchemaFlowError as err:
errors["base"] = str(err)
else:
password = user_input.pop(CONF_PASSWORD, None)
username = user_input.pop(CONF_USERNAME, None)
new_data = entry.data | {
CONF_PASSWORD: password,
CONF_USERNAME: username,
}
new_options = entry.options | {
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
}
return self.async_update_reload_and_abort(
entry,
data=new_data,
options=new_options,
reason="reconfigure_successful",
reload_even_if_entry_is_unchanged=False,
@ -603,8 +641,14 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN):
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
name = user_input.pop(CONF_NAME)
password = user_input.pop(CONF_PASSWORD, None)
username = user_input.pop(CONF_USERNAME, None)
user_input |= {CONF_DIMMERS: [], CONF_KEYPADS: []}
return self.async_create_entry(title=name, data={}, options=user_input)
return self.async_create_entry(
title=name,
data={CONF_PASSWORD: password, CONF_USERNAME: username},
options=user_input,
)
return self.async_show_form(
step_id="user",

View File

@ -2,8 +2,11 @@
"config": {
"error": {
"connection_error": "Could not connect to the controller.",
"credentials_needed": "The controller needs credentials.",
"duplicated_controller_id": "The controller name is already in use.",
"duplicated_host_port": "The specified host and port is already configured.",
"invalid_credentials": "The provided credentials are not valid.",
"need_username_with_password": "Credentials must be either a username and a password or only a username.",
"unknown_error": "[%key:common::config_flow::error::unknown%]"
},
"step": {
@ -22,7 +25,13 @@
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "Optional password, leave blank if your system does not need credentials or only needs a single credential",
"username": "Optional username, leave blank if your system does not need login credentials"
},
"description": "Modify a Lutron Homeworks controller connection settings"
},
@ -30,10 +39,14 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"name": "Controller name",
"port": "[%key:common::config_flow::data::port%]"
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"name": "A unique name identifying the Lutron Homeworks controller"
"name": "A unique name identifying the Lutron Homeworks controller",
"password": "[%key:component::homeworks::config::step::reconfigure::data_description::password%]",
"username": "[%key:component::homeworks::config::step::reconfigure::data_description::username%]"
},
"description": "Add a Lutron Homeworks controller"
}

View File

@ -17,10 +17,55 @@ from homeassistant.components.homeworks.const import (
CONF_RELEASE_DELAY,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
)
from tests.common import MockConfigEntry
CONFIG_ENTRY_OPTIONS = {
CONF_CONTROLLER_ID: "main_controller",
CONF_HOST: "192.168.0.1",
CONF_PORT: 1234,
CONF_DIMMERS: [
{
CONF_ADDR: "[02:08:01:01]",
CONF_NAME: "Foyer Sconces",
CONF_RATE: 1.0,
}
],
CONF_KEYPADS: [
{
CONF_ADDR: "[02:08:02:01]",
CONF_NAME: "Foyer Keypad",
CONF_BUTTONS: [
{
CONF_NAME: "Morning",
CONF_NUMBER: 1,
CONF_LED: True,
CONF_RELEASE_DELAY: None,
},
{
CONF_NAME: "Relax",
CONF_NUMBER: 2,
CONF_LED: True,
CONF_RELEASE_DELAY: None,
},
{
CONF_NAME: "Dim up",
CONF_NUMBER: 3,
CONF_LED: False,
CONF_RELEASE_DELAY: 0.2,
},
],
}
],
}
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
@ -28,45 +73,19 @@ def mock_config_entry() -> MockConfigEntry:
return MockConfigEntry(
title="Lutron Homeworks",
domain=DOMAIN,
data={},
options={
CONF_CONTROLLER_ID: "main_controller",
CONF_HOST: "192.168.0.1",
CONF_PORT: 1234,
CONF_DIMMERS: [
{
CONF_ADDR: "[02:08:01:01]",
CONF_NAME: "Foyer Sconces",
CONF_RATE: 1.0,
}
],
CONF_KEYPADS: [
{
CONF_ADDR: "[02:08:02:01]",
CONF_NAME: "Foyer Keypad",
CONF_BUTTONS: [
{
CONF_NAME: "Morning",
CONF_NUMBER: 1,
CONF_LED: True,
CONF_RELEASE_DELAY: None,
},
{
CONF_NAME: "Relax",
CONF_NUMBER: 2,
CONF_LED: True,
CONF_RELEASE_DELAY: None,
},
{
CONF_NAME: "Dim up",
CONF_NUMBER: 3,
CONF_LED: False,
CONF_RELEASE_DELAY: 0.2,
},
],
}
],
},
data={CONF_PASSWORD: None, CONF_USERNAME: None},
options=CONFIG_ENTRY_OPTIONS,
)
@pytest.fixture
def mock_config_entry_username_password() -> MockConfigEntry:
"""Return the default mocked config entry with credentials."""
return MockConfigEntry(
title="Lutron Homeworks",
domain=DOMAIN,
data={CONF_PASSWORD: "hunter2", CONF_USERNAME: "username"},
options=CONFIG_ENTRY_OPTIONS,
)

View File

@ -30,7 +30,7 @@ async def test_binary_sensor_attributes_state_update(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY)
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, None, None)
hw_callback = mock_homeworks.mock_calls[0][1][2]
assert entity_id in hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)

View File

@ -18,7 +18,13 @@ from homeassistant.components.homeworks.const import (
DOMAIN,
)
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -46,7 +52,7 @@ async def test_user_flow(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Main controller"
assert result["data"] == {}
assert result["data"] == {"password": None, "username": None}
assert result["options"] == {
"controller_id": "main_controller",
"dimmers": [],
@ -54,11 +60,109 @@ async def test_user_flow(
"keypads": [],
"port": 1234,
}
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY)
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, None, None)
mock_controller.close.assert_called_once_with()
mock_controller.join.assert_not_called()
async def test_user_flow_credentials(
hass: HomeAssistant, mock_homeworks: MagicMock, mock_setup_entry
) -> None:
"""Test the user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
mock_controller = MagicMock()
mock_homeworks.return_value = mock_controller
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.0.1",
CONF_NAME: "Main controller",
CONF_PASSWORD: "hunter2",
CONF_PORT: 1234,
CONF_USERNAME: "username",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Main controller"
assert result["data"] == {"password": "hunter2", "username": "username"}
assert result["options"] == {
"controller_id": "main_controller",
"dimmers": [],
"host": "192.168.0.1",
"keypads": [],
"port": 1234,
}
mock_homeworks.assert_called_once_with(
"192.168.0.1", 1234, ANY, "username", "hunter2"
)
mock_controller.close.assert_called_once_with()
mock_controller.join.assert_not_called()
async def test_user_flow_credentials_user_only(
hass: HomeAssistant, mock_homeworks: MagicMock, mock_setup_entry
) -> None:
"""Test the user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
mock_controller = MagicMock()
mock_homeworks.return_value = mock_controller
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.0.1",
CONF_NAME: "Main controller",
CONF_PORT: 1234,
CONF_USERNAME: "username",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Main controller"
assert result["data"] == {"password": None, "username": "username"}
assert result["options"] == {
"controller_id": "main_controller",
"dimmers": [],
"host": "192.168.0.1",
"keypads": [],
"port": 1234,
}
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, "username", None)
mock_controller.close.assert_called_once_with()
mock_controller.join.assert_not_called()
async def test_user_flow_credentials_password_only(
hass: HomeAssistant, mock_homeworks: MagicMock, mock_setup_entry
) -> None:
"""Test the user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
mock_controller = MagicMock()
mock_homeworks.return_value = mock_controller
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.0.1",
CONF_NAME: "Main controller",
CONF_PASSWORD: "hunter2",
CONF_PORT: 1234,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "need_username_with_password"}
async def test_user_flow_already_exists(
hass: HomeAssistant, mock_empty_config_entry: MockConfigEntry, mock_setup_entry
) -> None:
@ -99,6 +203,8 @@ async def test_user_flow_already_exists(
("side_effect", "error"),
[
(hw_exceptions.HomeworksConnectionFailed, "connection_error"),
(hw_exceptions.HomeworksInvalidCredentialsProvided, "invalid_credentials"),
(hw_exceptions.HomeworksNoCredentialsProvided, "credentials_needed"),
(Exception, "unknown_error"),
],
)
@ -270,6 +376,32 @@ async def test_reconfigure_flow_flow_no_change(
}
async def test_reconfigure_flow_credentials_password_only(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock
) -> None:
"""Test reconfigure flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.0.2",
CONF_PASSWORD: "hunter2",
CONF_PORT: 1234,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": "need_username_with_password"}
async def test_options_add_light_flow(
hass: HomeAssistant,
mock_empty_config_entry: MockConfigEntry,

View File

@ -3,7 +3,11 @@
from unittest.mock import ANY, MagicMock
from pyhomeworks import exceptions as hw_exceptions
from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED
from pyhomeworks.pyhomeworks import (
HW_BUTTON_PRESSED,
HW_BUTTON_RELEASED,
HW_LOGIN_INCORRECT,
)
import pytest
from homeassistant.components.homeworks import EVENT_BUTTON_PRESS, EVENT_BUTTON_RELEASE
@ -27,7 +31,7 @@ async def test_load_unload_config_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY)
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, None, None)
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
@ -36,6 +40,51 @@ async def test_load_unload_config_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_load_config_entry_with_credentials(
hass: HomeAssistant,
mock_config_entry_username_password: MockConfigEntry,
mock_homeworks: MagicMock,
) -> None:
"""Test the Homeworks configuration entry loading/unloading."""
mock_config_entry_username_password.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_username_password.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_username_password.state is ConfigEntryState.LOADED
mock_homeworks.assert_called_once_with(
"192.168.0.1", 1234, ANY, "username", "hunter2"
)
await hass.config_entries.async_unload(mock_config_entry_username_password.entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)
assert mock_config_entry_username_password.state is ConfigEntryState.NOT_LOADED
async def test_controller_credentials_changed(
hass: HomeAssistant,
mock_config_entry_username_password: MockConfigEntry,
mock_homeworks: MagicMock,
) -> None:
"""Test controller credentials changed.
Note: This just ensures we don't blow up when credentials changed, in the future a
reauth flow should be added.
"""
mock_config_entry_username_password.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_username_password.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_username_password.state is ConfigEntryState.LOADED
mock_homeworks.assert_called_once_with(
"192.168.0.1", 1234, ANY, "username", "hunter2"
)
hw_callback = mock_homeworks.mock_calls[0][1][2]
hw_callback(HW_LOGIN_INCORRECT, [])
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@ -66,7 +115,7 @@ async def test_keypad_events(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY)
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, None, None)
hw_callback = mock_homeworks.mock_calls[0][1][2]
hw_callback(HW_BUTTON_PRESSED, ["[02:08:02:01]", 1])
@ -184,7 +233,7 @@ async def test_cleanup_on_ha_shutdown(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY)
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, None, None)
mock_controller.stop.assert_not_called()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)

View File

@ -35,7 +35,7 @@ async def test_light_attributes_state_update(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY)
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, None, None)
hw_callback = mock_homeworks.mock_calls[0][1][2]
assert len(mock_controller.request_dimmer_level.mock_calls) == 1
@ -106,7 +106,7 @@ async def test_light_restore_brightness(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY)
mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY, None, None)
hw_callback = mock_homeworks.mock_calls[0][1][2]
assert hass.states.async_entity_ids("light") == unordered([entity_id])