From b2edf637cc6726c77580ce43ac1648480a218974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Farkasdi?= <93778865+farkasdi@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:41:22 +0100 Subject: [PATCH] Netatmo camera webhook refactor (#159359) --- homeassistant/components/netatmo/camera.py | 73 +++++++---- homeassistant/components/netatmo/const.py | 78 +++++++----- .../components/netatmo/data_handler.py | 4 +- homeassistant/components/netatmo/light.py | 5 +- tests/components/netatmo/test_camera.py | 117 +++++++++++++++++- 5 files changed, 213 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index c8eab26d992..e0d84784ee8 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -20,9 +20,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_CAMERA_LIGHT_MODE, + ATTR_EVENT_TYPE, ATTR_PERSON, ATTR_PERSONS, CAMERA_LIGHT_MODES, + CAMERA_TRIGGERS, CONF_URL_SECURITY, DATA_CAMERAS, DATA_EVENTS, @@ -37,8 +39,6 @@ from .const import ( SERVICE_SET_CAMERA_LIGHT, SERVICE_SET_PERSON_AWAY, SERVICE_SET_PERSONS_HOME, - WEBHOOK_LIGHT_MODE, - WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_PUSH_TYPE, ) from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice @@ -125,13 +125,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): """Entity created.""" await super().async_added_to_hass() - for event_type in ( - EVENT_TYPE_LIGHT_MODE, - EVENT_TYPE_OFF, - EVENT_TYPE_ON, - EVENT_TYPE_CONNECTION, - EVENT_TYPE_DISCONNECTION, - ): + for event_type in CAMERA_TRIGGERS: self.async_on_remove( async_dispatcher_connect( self.hass, @@ -146,34 +140,63 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] + event_type = data.get(ATTR_EVENT_TYPE) + push_type = data.get(WEBHOOK_PUSH_TYPE) + + if not push_type: + _LOGGER.debug("Event has no push_type, returning") + return if not data.get("camera_id"): + _LOGGER.debug("Event %s has no camera ID, returning", event_type) return if ( data["home_id"] == self.home.entity_id and data["camera_id"] == self.device.entity_id ): - if data[WEBHOOK_PUSH_TYPE] in ( - "NACamera-off", - "NOCamera-off", - "NACamera-disconnection", - "NOCamera-disconnection", - ): + # device_type to be stripped "DeviceType." + device_push_type = f"{self.device_type.name}-{event_type}" + if push_type != device_push_type: + _LOGGER.debug( + "Event push_type %s does not match device push_type %s, returning", + push_type, + device_push_type, + ) + return + + if event_type in [EVENT_TYPE_DISCONNECTION, EVENT_TYPE_OFF]: + _LOGGER.debug( + "Camera %s has received %s event, turning off and idleing streaming", + data["camera_id"], + event_type, + ) self._attr_is_streaming = False self._monitoring = False - elif data[WEBHOOK_PUSH_TYPE] in ( - "NACamera-on", - "NOCamera-on", - WEBHOOK_NACAMERA_CONNECTION, - "NOCamera-connection", - ): + elif event_type in [EVENT_TYPE_CONNECTION, EVENT_TYPE_ON]: + _LOGGER.debug( + "Camera %s has received %s event, turning on and enabling streaming", + data["camera_id"], + event_type, + ) self._attr_is_streaming = True self._monitoring = True - elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE: - self._light_state = data["sub_type"] - self._attr_extra_state_attributes.update( - {"light_state": self._light_state} + elif event_type == EVENT_TYPE_LIGHT_MODE: + if data.get("sub_type"): + self._light_state = data["sub_type"] + self._attr_extra_state_attributes.update( + {"light_state": self._light_state} + ) + else: + _LOGGER.debug( + "Camera %s has received light mode event without sub_type", + data["camera_id"], + ) + else: + _LOGGER.debug( + "Camera %s has received unexpected event as type %s", + data["camera_id"], + event_type, ) self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index bdd7fb99e7f..4207ec4eef1 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -114,41 +114,51 @@ EVENT_TYPE_SCHEDULE = "schedule" EVENT_TYPE_SET_POINT = "set_point" EVENT_TYPE_THERM_MODE = "therm_mode" # Camera events -EVENT_TYPE_CAMERA_ANIMAL = "animal" -EVENT_TYPE_CAMERA_HUMAN = "human" -EVENT_TYPE_CAMERA_MOVEMENT = "movement" -EVENT_TYPE_CAMERA_OUTDOOR = "outdoor" -EVENT_TYPE_CAMERA_PERSON = "person" -EVENT_TYPE_CAMERA_PERSON_AWAY = "person_away" -EVENT_TYPE_CAMERA_VEHICLE = "vehicle" +EVENT_TYPE_ANIMAL = "animal" +EVENT_TYPE_HUMAN = "human" +EVENT_TYPE_MOVEMENT = "movement" +EVENT_TYPE_OUTDOOR = "outdoor" +EVENT_TYPE_PERSON = "person" +EVENT_TYPE_PERSON_AWAY = "person_away" +EVENT_TYPE_VEHICLE = "vehicle" EVENT_TYPE_LIGHT_MODE = "light_mode" # Door tags EVENT_TYPE_ALARM_STARTED = "alarm_started" -EVENT_TYPE_DOOR_TAG_BIG_MOVE = "tag_big_move" -EVENT_TYPE_DOOR_TAG_OPEN = "tag_open" -EVENT_TYPE_DOOR_TAG_SMALL_MOVE = "tag_small_move" +EVENT_TYPE_TAG_BIG_MOVE = "tag_big_move" +EVENT_TYPE_TAG_OPEN = "tag_open" +EVENT_TYPE_TAG_SMALL_MOVE = "tag_small_move" # Generic events EVENT_TYPE_CONNECTION = "connection" EVENT_TYPE_DISCONNECTION = "disconnection" +EVENT_TYPE_MODULE_CONNECT = "module_connect" +EVENT_TYPE_MODULE_DISCONNECT = "module_disconnect" EVENT_TYPE_OFF = "off" EVENT_TYPE_ON = "on" +CAMERA_TRIGGERS = [ + EVENT_TYPE_CONNECTION, + EVENT_TYPE_DISCONNECTION, + EVENT_TYPE_LIGHT_MODE, + EVENT_TYPE_OFF, + EVENT_TYPE_ON, +] + OUTDOOR_CAMERA_TRIGGERS = [ - EVENT_TYPE_CAMERA_ANIMAL, - EVENT_TYPE_CAMERA_HUMAN, - EVENT_TYPE_CAMERA_OUTDOOR, - EVENT_TYPE_CAMERA_VEHICLE, + EVENT_TYPE_ANIMAL, + EVENT_TYPE_HUMAN, + EVENT_TYPE_OUTDOOR, + EVENT_TYPE_VEHICLE, ] INDOOR_CAMERA_TRIGGERS = [ EVENT_TYPE_ALARM_STARTED, - EVENT_TYPE_CAMERA_MOVEMENT, - EVENT_TYPE_CAMERA_PERSON_AWAY, - EVENT_TYPE_CAMERA_PERSON, + EVENT_TYPE_MOVEMENT, + EVENT_TYPE_PERSON_AWAY, + EVENT_TYPE_PERSON, ] DOOR_TAG_TRIGGERS = [ - EVENT_TYPE_DOOR_TAG_BIG_MOVE, - EVENT_TYPE_DOOR_TAG_OPEN, - EVENT_TYPE_DOOR_TAG_SMALL_MOVE, + EVENT_TYPE_TAG_BIG_MOVE, + EVENT_TYPE_TAG_OPEN, + EVENT_TYPE_TAG_SMALL_MOVE, ] CLIMATE_TRIGGERS = [ EVENT_TYPE_CANCEL_SET_POINT, @@ -157,18 +167,20 @@ CLIMATE_TRIGGERS = [ ] EVENT_ID_MAP = { EVENT_TYPE_ALARM_STARTED: "device_id", - EVENT_TYPE_CAMERA_ANIMAL: "device_id", - EVENT_TYPE_CAMERA_HUMAN: "device_id", - EVENT_TYPE_CAMERA_MOVEMENT: "device_id", - EVENT_TYPE_CAMERA_OUTDOOR: "device_id", - EVENT_TYPE_CAMERA_PERSON_AWAY: "device_id", - EVENT_TYPE_CAMERA_PERSON: "device_id", - EVENT_TYPE_CAMERA_VEHICLE: "device_id", + EVENT_TYPE_ANIMAL: "device_id", + EVENT_TYPE_HUMAN: "device_id", + EVENT_TYPE_MOVEMENT: "device_id", + EVENT_TYPE_OUTDOOR: "device_id", + EVENT_TYPE_PERSON_AWAY: "device_id", + EVENT_TYPE_PERSON: "device_id", + EVENT_TYPE_VEHICLE: "device_id", EVENT_TYPE_CANCEL_SET_POINT: "room_id", - EVENT_TYPE_DOOR_TAG_BIG_MOVE: "device_id", - EVENT_TYPE_DOOR_TAG_OPEN: "device_id", - EVENT_TYPE_DOOR_TAG_SMALL_MOVE: "device_id", + EVENT_TYPE_TAG_BIG_MOVE: "device_id", + EVENT_TYPE_TAG_OPEN: "device_id", + EVENT_TYPE_TAG_SMALL_MOVE: "device_id", EVENT_TYPE_LIGHT_MODE: "device_id", + EVENT_TYPE_MODULE_CONNECT: "module_id", + EVENT_TYPE_MODULE_DISCONNECT: "module_id", EVENT_TYPE_SET_POINT: "room_id", EVENT_TYPE_THERM_MODE: "home_id", } @@ -178,8 +190,12 @@ MODE_LIGHT_OFF = "off" MODE_LIGHT_ON = "on" CAMERA_LIGHT_MODES = [MODE_LIGHT_ON, MODE_LIGHT_OFF, MODE_LIGHT_AUTO] +# Webhook push_types MUST follow exactly Netatmo's naming on products! +# See https://dev.netatmo.com/apidocumentation +# e.g. cameras: NACamera, NOC, etc. WEBHOOK_ACTIVATION = "webhook_activation" WEBHOOK_DEACTIVATION = "webhook_deactivation" -WEBHOOK_LIGHT_MODE = "NOC-light_mode" WEBHOOK_NACAMERA_CONNECTION = "NACamera-connection" +WEBHOOK_NOCAMERA_CONNECTION = "NOC-connection" WEBHOOK_PUSH_TYPE = "push_type" +CAMERA_CONNECTION_WEBHOOKS = [WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NOCAMERA_CONNECTION] diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 0164d673619..f6479d391fb 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -28,6 +28,7 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( AUTH, + CAMERA_CONNECTION_WEBHOOKS, DATA_PERSONS, DATA_SCHEDULES, DOMAIN, @@ -48,7 +49,6 @@ from .const import ( PLATFORMS, WEBHOOK_ACTIVATION, WEBHOOK_DEACTIVATION, - WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_PUSH_TYPE, ) @@ -223,7 +223,7 @@ class NetatmoDataHandler: _LOGGER.debug("%s webhook unregistered", MANUFACTURER) self._webhook = False - elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION: + elif event["data"][WEBHOOK_PUSH_TYPE] in CAMERA_CONNECTION_WEBHOOKS: _LOGGER.debug("%s camera reconnected", MANUFACTURER) self.async_force_update(ACCOUNT) diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index ce28c455dea..4d4c4ba9509 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -14,14 +14,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( + ATTR_EVENT_TYPE, CONF_URL_CONTROL, CONF_URL_SECURITY, DOMAIN, EVENT_TYPE_LIGHT_MODE, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_LIGHT, - WEBHOOK_LIGHT_MODE, - WEBHOOK_PUSH_TYPE, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice from .entity import NetatmoModuleEntity @@ -114,7 +113,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity): if ( data["home_id"] == self.home.entity_id and data["camera_id"] == self.device.entity_id - and data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE + and data[ATTR_EVENT_TYPE] == EVENT_TYPE_LIGHT_MODE ): self._attr_is_on = bool(data["sub_type"] == "on") diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 2f6c958e082..75dfe3aa62b 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -1,4 +1,7 @@ """The tests for Netatmo camera.""" +# Webhook push_types MUST follow exactly Netatmo's naming on products! +# See https://dev.netatmo.com/apidocumentation +# e.g. cameras: NACamera, NOC, etc. from datetime import timedelta from typing import Any @@ -96,7 +99,7 @@ async def test_setup_component_with_webhook( "device_id": "12:34:56:10:b9:0e", "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", - "push_type": "NOCamera-off", + "push_type": "NOC-off", } await simulate_webhook(hass, webhook_id, response) @@ -107,7 +110,7 @@ async def test_setup_component_with_webhook( "device_id": "12:34:56:10:b9:0e", "camera_id": "12:34:56:10:b9:0e", "event_id": "646227f1dc0dfa000ec5f350", - "push_type": "NOCamera-on", + "push_type": "NOC-on", } await simulate_webhook(hass, webhook_id, response) @@ -141,6 +144,7 @@ async def test_setup_component_with_webhook( response = { "event_type": "light_mode", "device_id": "12:34:56:10:b9:0e", + "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", } @@ -428,7 +432,7 @@ async def test_service_set_camera_light_invalid_type( ("camera_type", "camera_id", "camera_entity"), [ ("NACamera", "12:34:56:00:f1:62", "camera.hall"), - ("NOCamera", "12:34:56:10:b9:0e", "camera.front"), + ("NOC", "12:34:56:10:b9:0e", "camera.front"), ], ) async def test_camera_reconnect_webhook( @@ -519,6 +523,113 @@ async def test_camera_reconnect_webhook( assert hass.states.get(camera_entity).state == "streaming" +@pytest.mark.parametrize( + ("camera_type", "camera_id", "camera_entity", "home_id"), + [ + # From the fixture the following combination is the only right one + # camera_type, camera_id, camera_entity, home_id + # "NOC", "12:34:56:10:b9:0e", "camera.front", "91763b24c43d3e344f424e8b" + # will test all the wrong combinations to be sure that the validation works + # Test1: wrong home_id + ("NOC", "12:34:56:10:b9:0e", "camera.front", "91763b24c43d3e344f424e80"), + # Test2: wrong camera_type (will result incorrect push_type) + ("NACamera", "12:34:56:10:b9:0e", "camera.front", "91763b24c43d3e344f424e8b"), + # Test3: wrong camera_id (id of NACamera) + ("NOC", "12:34:56:00:f1:62", "camera.front", "91763b24c43d3e344f424e8b"), + # Test4: missing camera_type (will result missing push_type) + (None, "12:34:56:10:b9:0e", "camera.front", "91763b24c43d3e344f424e8b"), + # Test5: missing camera_id + ("NOC", None, "camera.front", "91763b24c43d3e344f424e8b"), + # Note: missing home_id is not possible as it's mandatory in the webhook payload + # (by experience it is filled by some logic even if missing) + ], +) +async def test_camera_webhook_consistency( + hass: HomeAssistant, + config_entry: MockConfigEntry, + camera_type: str, + camera_id: str, + camera_entity: str, + home_id: str, +) -> None: + """Test webhook event on camera reconnect.""" + fake_post_hits = 0 + + async def fake_post(*args: Any, **kwargs: Any): + """Fake error during requesting backend data.""" + nonlocal fake_post_hits + fake_post_hits += 1 + return await fake_post_request(hass, *args, **kwargs) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.netatmo.webhook_generate_url", + ) as mock_webhook, + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + mock_webhook.return_value = "https://example.com" + 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] + + # Fake webhook activation + response = { + "push_type": "webhook_activation", + } + await simulate_webhook(hass, webhook_id, response) + await hass.async_block_till_done() + + assert fake_post_hits == 8 + + calls = fake_post_hits + + # Fake camera reconnect + if camera_type is None: + response = { + "event_type": "disconnection", + "home_id": home_id, + "device_id": camera_id, + "camera_id": camera_id, + } + elif camera_id is None: + response = { + "event_type": "disconnection", + "home_id": home_id, + "device_id": camera_id, + "push_type": f"{camera_type}-disconnection", + } + else: + response = { + "event_type": "disconnection", + "home_id": home_id, + "device_id": camera_id, + "camera_id": camera_id, + "push_type": f"{camera_type}-disconnection", + } + await simulate_webhook(hass, webhook_id, response) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=60), + ) + await hass.async_block_till_done() + assert fake_post_hits >= calls + + assert hass.states.get(camera_entity).state == "streaming" + + async def test_webhook_person_event( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: