1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-19 18:38:58 +00:00

Netatmo camera webhook refactor (#159359)

This commit is contained in:
Zoltán Farkasdi
2025-12-19 16:41:22 +01:00
committed by GitHub
parent de61a45de1
commit b2edf637cc
5 changed files with 213 additions and 64 deletions

View File

@@ -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()

View File

@@ -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]

View File

@@ -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)

View File

@@ -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")

View File

@@ -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: