Deprecate Netatmo webhook services (#175372)

This commit is contained in:
Tobias Sauerwein
2026-07-03 11:18:16 +02:00
committed by GitHub
parent bd4f6d4fd7
commit e7125448fc
11 changed files with 291 additions and 115 deletions
+13 -90
View File
@@ -1,19 +1,14 @@
"""The Netatmo integration."""
import logging
import secrets
from typing import Any
from aiohttp import ClientError
import pyatmo
from homeassistant.components import cloud
from homeassistant.components.webhook import (
async_generate_url as webhook_generate_url,
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant.components.webhook import async_unregister as webhook_unregister
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
@@ -35,7 +30,6 @@ from homeassistant.helpers.typing import ConfigType
from . import api
from .const import (
CONF_CLOUDHOOK_URL,
DATA_CAMERAS,
DATA_DEVICE_IDS,
DATA_EVENTS,
@@ -44,11 +38,10 @@ from .const import (
DATA_SCHEDULES,
DOMAIN,
PLATFORMS,
WEBHOOK_DEACTIVATION,
WEBHOOK_PUSH_TYPE,
)
from .data_handler import NetatmoConfigEntry, NetatmoDataHandler
from .webhook import async_handle_webhook
from .services import async_setup_services
from .webhook import async_register_webhook, async_unregister_webhook
_LOGGER = logging.getLogger(__name__)
@@ -70,6 +63,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DATA_CAMERAS: {},
}
async_setup_services(hass)
return True
@@ -111,106 +106,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> b
entry.runtime_data = data_handler
await data_handler.async_setup()
async def unregister_webhook(
_: Any,
) -> None:
if CONF_WEBHOOK_ID not in entry.data:
return
_LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID])
async_dispatcher_send(
hass,
f"signal-{DOMAIN}-webhook-None",
{"type": "None", "data": {WEBHOOK_PUSH_TYPE: WEBHOOK_DEACTIVATION}},
)
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
try:
await entry.runtime_data.auth.async_dropwebhook()
except pyatmo.ApiError:
_LOGGER.debug(
"No webhook to be dropped for %s", entry.data[CONF_WEBHOOK_ID]
)
async def register_webhook(_: Any = None) -> None:
await async_register_webhook(hass, entry)
async def register_webhook(
_: Any,
) -> None:
if CONF_WEBHOOK_ID not in entry.data:
data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()}
hass.config_entries.async_update_entry(entry, data=data)
if cloud.async_active_subscription(hass):
webhook_url = await async_cloudhook_generate_url(hass, entry)
else:
webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID])
if entry.data[
"auth_implementation"
] == cloud.DOMAIN and not webhook_url.startswith("https://"):
_LOGGER.warning(
"Webhook not registered - "
"https and port 443 is required to register the webhook"
)
return
webhook_register(
hass,
DOMAIN,
"Netatmo",
entry.data[CONF_WEBHOOK_ID],
async_handle_webhook,
)
try:
await entry.runtime_data.auth.async_addwebhook(webhook_url)
_LOGGER.debug("Register Netatmo webhook: %s", webhook_url)
# pylint: disable-next=home-assistant-action-swallowed-exception
except pyatmo.ApiError as err:
_LOGGER.error("Error during webhook registration - %s", err)
else:
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
)
async def unregister_webhook(_: Any = None) -> None:
await async_unregister_webhook(hass, entry)
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if state is cloud.CloudConnectionState.CLOUD_CONNECTED:
await register_webhook(None)
await register_webhook()
if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED:
await unregister_webhook(None)
await unregister_webhook()
entry.async_on_unload(async_call_later(hass, 30, register_webhook))
if cloud.async_active_subscription(hass):
if cloud.async_is_connected(hass):
await register_webhook(None)
await register_webhook()
entry.async_on_unload(
cloud.async_listen_connection_change(hass, manage_cloudhook)
)
else:
entry.async_on_unload(async_at_started(hass, register_webhook))
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(DOMAIN, "register_webhook", register_webhook)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook)
entry.async_on_unload(entry.add_update_listener(async_config_entry_updated))
return True
async def async_cloudhook_generate_url(
hass: HomeAssistant, entry: NetatmoConfigEntry
) -> str:
"""Generate the full URL for a webhook_id."""
if CONF_CLOUDHOOK_URL not in entry.data:
webhook_url = await cloud.async_create_cloudhook(
hass, entry.data[CONF_WEBHOOK_ID]
)
data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
hass.config_entries.async_update_entry(entry, data=data)
return webhook_url
return str(entry.data[CONF_CLOUDHOOK_URL])
async def async_config_entry_updated(
hass: HomeAssistant, entry: NetatmoConfigEntry
) -> None:
@@ -0,0 +1,64 @@
"""Services for the Netatmo integration."""
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN
from .data_handler import NetatmoConfigEntry
from .webhook import async_register_webhook, async_unregister_webhook
SERVICE_REGISTER_WEBHOOK = "register_webhook"
SERVICE_UNREGISTER_WEBHOOK = "unregister_webhook"
def _get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry:
"""Return the loaded config entry or raise if unavailable."""
entry: NetatmoConfigEntry | None = (
hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN)
)
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
return entry
def _deprecate(hass: HomeAssistant, service: str) -> None:
"""Warn and raise a repair issue for the deprecated webhook actions."""
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_service_{service}",
breaks_in_ha_version="2027.2.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_webhook_service",
)
async def _register_webhook(call: ServiceCall) -> None:
"""Handle the deprecated register_webhook action."""
_deprecate(call.hass, SERVICE_REGISTER_WEBHOOK)
entry = _get_loaded_entry(call.hass)
# Drop any existing registration first so re-registering an already-active
# webhook does not raise "Handler is already defined!".
await async_unregister_webhook(call.hass, entry)
await async_register_webhook(call.hass, entry)
async def _unregister_webhook(call: ServiceCall) -> None:
"""Handle the deprecated unregister_webhook action."""
_deprecate(call.hass, SERVICE_UNREGISTER_WEBHOOK)
await async_unregister_webhook(call.hass, _get_loaded_entry(call.hass))
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Netatmo services."""
hass.services.async_register(DOMAIN, SERVICE_REGISTER_WEBHOOK, _register_webhook)
hass.services.async_register(
DOMAIN, SERVICE_UNREGISTER_WEBHOOK, _unregister_webhook
)
@@ -119,5 +119,6 @@ set_person_away:
selector:
text:
# Deprecated: the webhook is managed automatically. Scheduled for removal in 2027.2.0.
register_webhook:
unregister_webhook:
+11 -2
View File
@@ -156,10 +156,19 @@
}
},
"exceptions": {
"entry_not_loaded": {
"message": "The Netatmo integration is not loaded. Please set it up before calling this action."
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"issues": {
"deprecated_webhook_service": {
"description": "The `register_webhook` and `unregister_webhook` actions are deprecated and will be removed in a future release. Netatmo manages the webhook automatically, so these actions are no longer needed. Please remove them from your automations and scripts.",
"title": "Netatmo webhook actions are deprecated"
}
},
"options": {
"step": {
"public_weather": {
@@ -191,7 +200,7 @@
"name": "Clear temperature setting"
},
"register_webhook": {
"description": "Registers the webhook to the Netatmo backend.",
"description": "Registers the webhook to the Netatmo backend. Deprecated: the webhook is managed automatically and this action will be removed in 2027.2.0.",
"name": "Register webhook"
},
"set_camera_light": {
@@ -277,7 +286,7 @@
"name": "Set temperature with time period"
},
"unregister_webhook": {
"description": "Unregisters the webhook from the Netatmo backend.",
"description": "Unregisters the webhook from the Netatmo backend. Deprecated: the webhook is managed automatically and this action will be removed in 2027.2.0.",
"name": "Unregister webhook"
}
}
+101 -2
View File
@@ -1,12 +1,28 @@
"""The Netatmo integration."""
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import logging
import secrets
from aiohttp.web import Request
import pyatmo
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME, ATTR_PERSONS
from homeassistant.core import HomeAssistant
from homeassistant.components import cloud
from homeassistant.components.webhook import (
async_generate_url as webhook_generate_url,
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ID,
ATTR_NAME,
ATTR_PERSONS,
CONF_WEBHOOK_ID,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
@@ -14,13 +30,17 @@ from .const import (
ATTR_FACE_URL,
ATTR_HOME_ID,
ATTR_IS_KNOWN,
CONF_CLOUDHOOK_URL,
DATA_DEVICE_IDS,
DATA_PERSONS,
DEFAULT_PERSON,
DOMAIN,
EVENT_ID_MAP,
NETATMO_EVENT,
WEBHOOK_DEACTIVATION,
WEBHOOK_PUSH_TYPE,
)
from .data_handler import NetatmoConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -98,3 +118,82 @@ def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None:
event_type=NETATMO_EVENT,
event_data=event_data,
)
async def async_cloudhook_generate_url(
hass: HomeAssistant, entry: NetatmoConfigEntry
) -> str:
"""Generate the full URL for a webhook_id."""
if CONF_CLOUDHOOK_URL not in entry.data:
webhook_url = await cloud.async_create_cloudhook(
hass, entry.data[CONF_WEBHOOK_ID]
)
data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
hass.config_entries.async_update_entry(entry, data=data)
return webhook_url
return str(entry.data[CONF_CLOUDHOOK_URL])
async def async_unregister_webhook(
hass: HomeAssistant, entry: NetatmoConfigEntry
) -> None:
"""Unregister the webhook from the Netatmo backend."""
if CONF_WEBHOOK_ID not in entry.data:
return
_LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID])
async_dispatcher_send(
hass,
f"signal-{DOMAIN}-webhook-None",
{"type": "None", "data": {WEBHOOK_PUSH_TYPE: WEBHOOK_DEACTIVATION}},
)
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
try:
await entry.runtime_data.auth.async_dropwebhook()
except pyatmo.ApiError:
_LOGGER.debug("No webhook to be dropped for %s", entry.data[CONF_WEBHOOK_ID])
async def async_register_webhook(
hass: HomeAssistant, entry: NetatmoConfigEntry
) -> None:
"""Register the webhook with the Netatmo backend."""
if CONF_WEBHOOK_ID not in entry.data:
data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()}
hass.config_entries.async_update_entry(entry, data=data)
if cloud.async_active_subscription(hass):
webhook_url = await async_cloudhook_generate_url(hass, entry)
else:
webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID])
if entry.data["auth_implementation"] == cloud.DOMAIN and not webhook_url.startswith(
"https://"
):
_LOGGER.warning(
"Webhook not registered - "
"https and port 443 is required to register the webhook"
)
return
webhook_register(
hass,
DOMAIN,
"Netatmo",
entry.data[CONF_WEBHOOK_ID],
async_handle_webhook,
)
async def _handle_stop(_: Event) -> None:
await async_unregister_webhook(hass, entry)
try:
await entry.runtime_data.auth.async_addwebhook(webhook_url)
_LOGGER.debug("Register Netatmo webhook: %s", webhook_url)
# pylint: disable-next=home-assistant-action-swallowed-exception
except pyatmo.ApiError as err:
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
_LOGGER.error("Error during webhook registration - %s", err)
else:
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_stop)
)
+1 -1
View File
@@ -126,7 +126,7 @@ def selected_platforms(platforms: list[Platform]) -> Iterator[None]:
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
yield
@@ -67,7 +67,7 @@ async def test_doortag_setup(
return_value=AsyncMock(),
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post
@@ -179,7 +179,7 @@ async def test_doortag_opening_status_change(
return_value=AsyncMock(),
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = fake_tag_post
@@ -306,7 +306,7 @@ async def test_doortag_opening_category(
return_value=AsyncMock(),
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = fake_tag_post
+4 -4
View File
@@ -479,7 +479,7 @@ async def test_camera_reconnect_webhook(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post
@@ -592,7 +592,7 @@ async def test_camera_webhook_consistency(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post
@@ -712,7 +712,7 @@ async def test_setup_component_no_devices(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post_no_data
@@ -755,7 +755,7 @@ async def test_camera_image_raises_exception(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post
+1 -1
View File
@@ -32,7 +32,7 @@ async def test_entry_diagnostics(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
+91 -11
View File
@@ -10,13 +10,20 @@ from pyatmo.const import ALL_SCOPES
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import cloud
from homeassistant.components import cloud, webhook
from homeassistant.components.netatmo import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_WEBHOOK_ID, Platform
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import OAuth2TokenRequestReauthError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.exceptions import (
OAuth2TokenRequestReauthError,
ServiceValidationError,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
@@ -71,7 +78,9 @@ async def test_setup_component(
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch("homeassistant.components.netatmo.webhook_generate_url") as mock_webhook,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
@@ -114,7 +123,9 @@ async def test_setup_component_with_config(
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch("homeassistant.components.netatmo.webhook_generate_url") as mock_webhook,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth,
@@ -171,6 +182,69 @@ async def test_setup_component_with_webhook(
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
async def test_no_deprecation_issue_on_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
netatmo_auth: AsyncMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test the automatic webhook lifecycle does not raise the deprecation issue."""
with selected_platforms([Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert not issue_registry.async_get_issue(
DOMAIN, "deprecated_service_register_webhook"
)
assert not issue_registry.async_get_issue(
DOMAIN, "deprecated_service_unregister_webhook"
)
@pytest.mark.parametrize(
("service", "expected_registered"),
[
pytest.param("register_webhook", True, id="register"),
pytest.param("unregister_webhook", False, id="unregister"),
],
)
async def test_deprecated_webhook_service(
hass: HomeAssistant,
config_entry: MockConfigEntry,
netatmo_auth: AsyncMock,
issue_registry: ir.IssueRegistry,
service: str,
expected_registered: bool,
) -> None:
"""Test the deprecated webhook actions still work and raise a repair issue."""
with selected_platforms([Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
assert webhook_id in hass.data[webhook.DOMAIN]
# register_webhook re-registers the already-active webhook without
# raising; unregister_webhook tears it down.
await hass.services.async_call(DOMAIN, service, blocking=True)
assert (webhook_id in hass.data[webhook.DOMAIN]) is expected_registered
assert issue_registry.async_get_issue(DOMAIN, f"deprecated_service_{service}")
@pytest.mark.parametrize("service", ["register_webhook", "unregister_webhook"])
async def test_deprecated_webhook_service_not_loaded(
hass: HomeAssistant,
service: str,
) -> None:
"""Test calling a webhook action without a loaded entry raises."""
await async_setup_component(hass, DOMAIN, {})
with pytest.raises(ServiceValidationError):
await hass.services.async_call(DOMAIN, service, blocking=True)
async def test_setup_without_https(
hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture
) -> None:
@@ -188,7 +262,7 @@ async def test_setup_without_https(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url"
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_async_generate_url,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
@@ -232,7 +306,7 @@ async def test_setup_with_cloud(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
@@ -302,7 +376,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None:
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
@@ -346,7 +420,9 @@ async def test_setup_component_with_delay(
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch("homeassistant.components.netatmo.webhook_generate_url") as mock_webhook,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
patch(
"pyatmo.AbstractAsyncAuth.async_post_api_request",
side_effect=partial(fake_post_request, hass),
@@ -416,7 +492,9 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None:
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch("homeassistant.components.netatmo.webhook_generate_url") as mock_webhook,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
@@ -466,7 +544,9 @@ async def test_setup_component_invalid_token(
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch("homeassistant.components.netatmo.webhook_generate_url") as mock_webhook,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
patch("homeassistant.components.netatmo.OAuth2Session") as mock_session,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
+1 -1
View File
@@ -132,7 +132,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) ->
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook_generate_url",
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = (