Require newly configured esphome device to allow Home Assistant service calls (#95143)

* Require esphome service calls to be enabled

For existing devices, calling Home Assistant services continues
to be allowed.

For newly configured devices, it must now be enabled in the options
flow

* fix

* adjust

* coverage

* adjust

* fix test

* Update homeassistant/components/esphome/strings.json

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Update homeassistant/components/esphome/strings.json

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Update homeassistant/components/esphome/strings.json

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Update homeassistant/components/esphome/__init__.py

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Update homeassistant/components/esphome/__init__.py

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>

* Update homeassistant/components/esphome/__init__.py

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
pull/95220/head
J. Nick Koston 2023-06-25 20:18:21 -05:00 committed by GitHub
parent f4756fe1f9
commit 85d6e03dd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 217 additions and 30 deletions

View File

@ -49,7 +49,11 @@ from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
from .bluetooth import async_connect_scanner
from .const import DOMAIN
from .const import (
CONF_ALLOW_SERVICE_CALLS,
DEFAULT_ALLOW_SERVICE_CALLS,
DOMAIN,
)
from .dashboard import async_get_dashboard, async_setup as async_setup_dashboard
from .domain_data import DomainData
@ -154,11 +158,16 @@ async def async_setup_entry( # noqa: C901
noise_psk=noise_psk,
)
services_issue = f"service_calls_not_enabled-{entry.unique_id}"
if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS):
async_delete_issue(hass, DOMAIN, services_issue)
domain_data = DomainData.get(hass)
entry_data = RuntimeEntryData(
client=cli,
entry_id=entry.entry_id,
store=domain_data.get_or_create_store(hass, entry),
original_options=dict(entry.options),
)
domain_data.set_entry_data(entry, entry_data)
@ -177,6 +186,8 @@ async def async_setup_entry( # noqa: C901
@callback
def async_on_service_call(service: HomeassistantServiceCall) -> None:
"""Call service when user automation in ESPHome config is triggered."""
device_info = entry_data.device_info
assert device_info is not None
domain, service_name = service.service.split(".", 1)
service_data = service.data
@ -194,7 +205,7 @@ async def async_setup_entry( # noqa: C901
return
if service.is_event:
# ESPHome uses servicecall packet for both events and service calls
# ESPHome uses service call packet for both events and service calls
# Ensure the user can only send events of form 'esphome.xyz'
if domain != "esphome":
_LOGGER.error(
@ -215,12 +226,34 @@ async def async_setup_entry( # noqa: C901
**service_data,
},
)
else:
elif entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS):
hass.async_create_task(
hass.services.async_call(
domain, service_name, service_data, blocking=True
)
)
else:
async_create_issue(
hass,
DOMAIN,
services_issue,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="service_calls_not_allowed",
translation_placeholders={
"name": device_info.friendly_name or device_info.name,
},
)
_LOGGER.error(
"%s: Service call %s.%s: with data %s rejected; "
"If you trust this device and want to allow access for it to make "
"Home Assistant service calls, you can enable this "
"functionality in the options flow",
device_info.friendly_name or device_info.name,
domain,
service_name,
service_data,
)
async def _send_home_assistant_state(
entity_id: str, attribute: str | None, state: State | None
@ -449,6 +482,8 @@ async def async_setup_entry( # noqa: C901
await reconnect_logic.start()
entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback)
entry.async_on_unload(entry.add_update_listener(entry_data.async_update_listener))
return True

View File

@ -20,14 +20,19 @@ import voluptuous as vol
from homeassistant.components import dhcp, zeroconf
from homeassistant.components.hassio import HassioServiceInfo
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.device_registry import format_mac
from . import CONF_DEVICE_NAME, CONF_NOISE_PSK
from .const import DOMAIN
from .const import (
CONF_ALLOW_SERVICE_CALLS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
)
from .dashboard import async_get_dashboard, async_set_dashboard_info
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
@ -237,6 +242,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_NOISE_PSK: self._noise_psk or "",
CONF_DEVICE_NAME: self._device_name,
}
config_options = {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
}
if self._reauth_entry:
entry = self._reauth_entry
self.hass.config_entries.async_update_entry(
@ -253,6 +261,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=self._name,
data=config_data,
options=config_options,
)
async def async_step_encryption_key(
@ -388,3 +397,38 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._noise_psk = noise_psk
return True
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(OptionsFlow):
"""Handle a option flow for esphome."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
data_schema = vol.Schema(
{
vol.Required(
CONF_ALLOW_SERVICE_CALLS,
default=self.config_entry.options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
): bool,
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)

View File

@ -1,3 +1,7 @@
"""ESPHome constants."""
DOMAIN = "esphome"
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False

View File

@ -109,6 +109,7 @@ class RuntimeEntryData:
entity_info_callbacks: dict[
type[EntityInfo], list[Callable[[list[EntityInfo]], None]]
] = field(default_factory=dict)
original_options: dict[str, Any] = field(default_factory=dict)
@property
def name(self) -> str:
@ -365,3 +366,11 @@ class RuntimeEntryData:
return store_data
self.store.async_delay_save(_memorized_storage, SAVE_DELAY)
async def async_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
if self.original_options == entry.options:
return
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))

View File

@ -46,6 +46,15 @@
},
"flow_title": "{name}"
},
"options": {
"step": {
"init": {
"data": {
"allow_service_calls": "Allow the device to make Home Assistant service calls."
}
}
}
},
"entity": {
"binary_sensor": {
"assist_in_progress": {
@ -69,6 +78,10 @@
"api_password_deprecated": {
"title": "API Password deprecated on {name}",
"description": "The API password for ESPHome is deprecated and the use of an API encryption key is recommended instead.\n\nRemove the API password and add an encryption key to your ESPHome device to resolve this issue."
},
"service_calls_not_allowed": {
"title": "{name} is not permitted to call Home Assistant services",
"description": "The ESPHome device attempted to make a Home Assistant service call, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to make Home Assistant service calls, you can enable this functionality in the options flow."
}
}
}

View File

@ -24,6 +24,10 @@ from homeassistant.components.esphome import (
DOMAIN,
dashboard,
)
from homeassistant.components.esphome.const import (
CONF_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@ -173,6 +177,9 @@ async def _mock_generic_device_entry(
CONF_PORT: 6053,
CONF_PASSWORD: "",
},
options={
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS
},
)
entry.add_to_hass(hass)
mock_device = MockESPHomeDevice(entry)
@ -208,7 +215,7 @@ async def _mock_generic_device_entry(
return result
with patch.object(ReconnectLogic, "_try_connect", mock_try_connect):
await hass.config_entries.async_setup(entry.entry_id)
assert await hass.config_entries.async_setup(entry.entry_id)
await try_connect_done.wait()
await hass.async_block_till_done()

View File

@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, MagicMock, patch
from aioesphomeapi import (
APIClient,
APIConnectionError,
DeviceInfo,
InvalidAuthAPIError,
@ -20,6 +21,10 @@ from homeassistant.components.esphome import (
DomainData,
dashboard,
)
from homeassistant.components.esphome.const import (
CONF_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
)
from homeassistant.components.hassio import HassioServiceInfo
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
@ -32,7 +37,7 @@ from tests.common import MockConfigEntry
INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM="
@pytest.fixture(autouse=True)
@pytest.fixture(autouse=False)
def mock_setup_entry():
"""Mock setting up a config entry."""
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
@ -40,7 +45,7 @@ def mock_setup_entry():
async def test_user_connection_works(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test we can finish a config flow."""
result = await hass.config_entries.flow.async_init(
@ -66,6 +71,9 @@ async def test_user_connection_works(
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test",
}
assert result["options"] == {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS
}
assert result["title"] == "test"
assert result["result"].unique_id == "11:22:33:44:55:aa"
@ -79,7 +87,7 @@ async def test_user_connection_works(
async def test_user_connection_updates_host(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test setup up the same name updates the host."""
entry = MockConfigEntry(
@ -108,7 +116,7 @@ async def test_user_connection_updates_host(
async def test_user_resolve_error(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test user step with IP resolve error."""
@ -133,7 +141,7 @@ async def test_user_resolve_error(
async def test_user_connection_error(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test user step with connection error."""
mock_client.device_info.side_effect = APIConnectionError
@ -154,7 +162,7 @@ async def test_user_connection_error(
async def test_user_with_password(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test user step with password."""
mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test")
@ -210,7 +218,7 @@ async def test_user_invalid_password(
async def test_login_connection_error(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test user step with connection error on login attempt."""
mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test")
@ -236,7 +244,7 @@ async def test_login_connection_error(
async def test_discovery_initiation(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test discovery importing works."""
service_info = zeroconf.ZeroconfServiceInfo(
@ -268,7 +276,7 @@ async def test_discovery_initiation(
async def test_discovery_no_mac(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test discovery aborted if old ESPHome without mac in zeroconf."""
service_info = zeroconf.ZeroconfServiceInfo(
@ -287,7 +295,9 @@ async def test_discovery_no_mac(
assert flow["reason"] == "mdns_missing_mac"
async def test_discovery_already_configured(hass: HomeAssistant, mock_client) -> None:
async def test_discovery_already_configured(
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None:
"""Test discovery aborts if already configured via hostname."""
entry = MockConfigEntry(
domain=DOMAIN,
@ -314,7 +324,9 @@ async def test_discovery_already_configured(hass: HomeAssistant, mock_client) ->
assert result["reason"] == "already_configured"
async def test_discovery_duplicate_data(hass: HomeAssistant, mock_client) -> None:
async def test_discovery_duplicate_data(
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None:
"""Test discovery aborts if same mDNS packet arrives."""
service_info = zeroconf.ZeroconfServiceInfo(
host="192.168.43.183",
@ -339,7 +351,9 @@ async def test_discovery_duplicate_data(hass: HomeAssistant, mock_client) -> Non
assert result["reason"] == "already_in_progress"
async def test_discovery_updates_unique_id(hass: HomeAssistant, mock_client) -> None:
async def test_discovery_updates_unique_id(
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None:
"""Test a duplicate discovery host aborts and updates existing entry."""
entry = MockConfigEntry(
domain=DOMAIN,
@ -369,7 +383,7 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant, mock_client) ->
async def test_user_requires_psk(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test user step with requiring encryption key."""
mock_client.device_info.side_effect = RequiresEncryptionAPIError
@ -390,7 +404,7 @@ async def test_user_requires_psk(
async def test_encryption_key_valid_psk(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test encryption key step with valid key."""
@ -424,7 +438,7 @@ async def test_encryption_key_valid_psk(
async def test_encryption_key_invalid_psk(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test encryption key step with invalid key."""
@ -473,7 +487,7 @@ async def test_reauth_initiation(
async def test_reauth_confirm_valid(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test reauth initiation with valid PSK."""
entry = MockConfigEntry(
@ -502,7 +516,11 @@ async def test_reauth_confirm_valid(
async def test_reauth_fixed_via_dashboard(
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard
hass: HomeAssistant,
mock_client,
mock_zeroconf: None,
mock_dashboard,
mock_setup_entry: None,
) -> None:
"""Test reauth fixed automatically via dashboard."""
@ -554,6 +572,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password(
mock_zeroconf: None,
mock_dashboard,
mock_config_entry,
mock_setup_entry: None,
) -> None:
"""Test reauth fixed automatically via dashboard with password removed."""
mock_client.device_info.side_effect = (
@ -596,6 +615,7 @@ async def test_reauth_fixed_via_remove_password(
mock_client,
mock_config_entry,
mock_dashboard,
mock_setup_entry: None,
) -> None:
"""Test reauth fixed automatically by seeing password removed."""
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
@ -615,7 +635,11 @@ async def test_reauth_fixed_via_remove_password(
async def test_reauth_fixed_via_dashboard_at_confirm(
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard
hass: HomeAssistant,
mock_client,
mock_zeroconf: None,
mock_dashboard,
mock_setup_entry: None,
) -> None:
"""Test reauth fixed automatically via dashboard at confirm step."""
@ -668,7 +692,7 @@ async def test_reauth_fixed_via_dashboard_at_confirm(
async def test_reauth_confirm_invalid(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test reauth initiation with invalid PSK."""
entry = MockConfigEntry(
@ -709,7 +733,7 @@ async def test_reauth_confirm_invalid(
async def test_reauth_confirm_invalid_with_unique_id(
hass: HomeAssistant, mock_client, mock_zeroconf: None
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
"""Test reauth initiation with invalid PSK."""
entry = MockConfigEntry(
@ -750,7 +774,9 @@ async def test_reauth_confirm_invalid_with_unique_id(
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
async def test_discovery_dhcp_updates_host(hass: HomeAssistant, mock_client) -> None:
async def test_discovery_dhcp_updates_host(
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None:
"""Test dhcp discovery updates host and aborts."""
entry = MockConfigEntry(
domain=DOMAIN,
@ -774,7 +800,9 @@ async def test_discovery_dhcp_updates_host(hass: HomeAssistant, mock_client) ->
assert entry.data[CONF_HOST] == "192.168.43.184"
async def test_discovery_dhcp_no_changes(hass: HomeAssistant, mock_client) -> None:
async def test_discovery_dhcp_no_changes(
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None:
"""Test dhcp discovery updates host and aborts."""
entry = MockConfigEntry(
domain=DOMAIN,
@ -827,7 +855,11 @@ async def test_discovery_hassio(hass: HomeAssistant, mock_dashboard) -> None:
async def test_zeroconf_encryption_key_via_dashboard(
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard
hass: HomeAssistant,
mock_client,
mock_zeroconf: None,
mock_dashboard,
mock_setup_entry: None,
) -> None:
"""Test encryption key retrieved from dashboard."""
service_info = zeroconf.ZeroconfServiceInfo(
@ -889,7 +921,11 @@ async def test_zeroconf_encryption_key_via_dashboard(
async def test_zeroconf_no_encryption_key_via_dashboard(
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_dashboard
hass: HomeAssistant,
mock_client,
mock_zeroconf: None,
mock_dashboard,
mock_setup_entry: None,
) -> None:
"""Test encryption key not retrieved from dashboard."""
service_info = zeroconf.ZeroconfServiceInfo(
@ -920,3 +956,42 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "encryption_key"
@pytest.mark.parametrize("option_value", [True, False])
async def test_option_flow(
hass: HomeAssistant,
option_value: bool,
mock_client: APIClient,
mock_generic_device_entry,
) -> None:
"""Test config flow options."""
entry = await mock_generic_device_entry(
mock_client=mock_client,
entity_info=[],
user_service=[],
states=[],
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
assert result["data_schema"]({}) == {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS
}
with patch(
"homeassistant.components.esphome.async_setup_entry", return_value=True
) as mock_reload:
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_ALLOW_SERVICE_CALLS: option_value,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value}
assert len(mock_reload.mock_calls) == int(option_value)