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:
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user