diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 56c95115cb9..90abe39f075 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -31,6 +31,7 @@ from motioneye_client.const import ( ) from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.webhook import ( async_generate_id, @@ -82,7 +83,7 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [CAMERA_DOMAIN, SWITCH_DOMAIN] +PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] def create_motioneye_client( @@ -478,3 +479,8 @@ class MotionEyeEntity(CoordinatorEntity): def device_info(self) -> DeviceInfo: """Return the device information.""" return DeviceInfo(identifiers={self._device_identifier}) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._camera is not None and super().available diff --git a/homeassistant/components/motioneye/const.py b/homeassistant/components/motioneye/const.py index f9f25a3b7ee..4e30cfb8514 100644 --- a/homeassistant/components/motioneye/const.py +++ b/homeassistant/components/motioneye/const.py @@ -84,6 +84,7 @@ MOTIONEYE_MANUFACTURER: Final = "motionEye" SIGNAL_CAMERA_ADD: Final = f"{DOMAIN}_camera_add_signal." "{}" SIGNAL_CAMERA_REMOVE: Final = f"{DOMAIN}_camera_remove_signal." "{}" +TYPE_MOTIONEYE_ACTION_SENSOR = f"{DOMAIN}_action_sensor" TYPE_MOTIONEYE_MJPEG_CAMERA: Final = "motioneye_mjpeg_camera" TYPE_MOTIONEYE_SWITCH_BASE: Final = f"{DOMAIN}_switch" diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py new file mode 100644 index 00000000000..c8b7679149c --- /dev/null +++ b/homeassistant/components/motioneye/sensor.py @@ -0,0 +1,94 @@ +"""Sensor platform for motionEye.""" +from __future__ import annotations + +import logging +from types import MappingProxyType +from typing import Any + +from motioneye_client.client import MotionEyeClient +from motioneye_client.const import KEY_ACTIONS, KEY_NAME + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import MotionEyeEntity, get_camera_from_cameras, listen_for_new_cameras +from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up motionEye from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + + @callback + def camera_add(camera: dict[str, Any]) -> None: + """Add a new motionEye camera.""" + async_add_entities( + [ + MotionEyeActionSensor( + entry.entry_id, + camera, + entry_data[CONF_CLIENT], + entry_data[CONF_COORDINATOR], + entry.options, + ) + ] + ) + + listen_for_new_cameras(hass, entry, camera_add) + + +class MotionEyeActionSensor(MotionEyeEntity, SensorEntity): + """motionEye action sensor camera.""" + + def __init__( + self, + config_entry_id: str, + camera: dict[str, Any], + client: MotionEyeClient, + coordinator: DataUpdateCoordinator, + options: MappingProxyType[str, str], + ) -> None: + """Initialize an action sensor.""" + super().__init__( + config_entry_id, + TYPE_MOTIONEYE_ACTION_SENSOR, + camera, + client, + coordinator, + options, + SensorEntityDescription( + key=TYPE_MOTIONEYE_ACTION_SENSOR, entity_registry_enabled_default=False + ), + ) + + @property + def name(self) -> str: + """Return the name of the sensor.""" + camera_prepend = f"{self._camera[KEY_NAME]} " if self._camera else "" + return f"{camera_prepend}Actions" + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return len(self._camera.get(KEY_ACTIONS, [])) if self._camera else 0 + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Add actions as attribute.""" + if actions := (self._camera.get(KEY_ACTIONS) if self._camera else None): + return {KEY_ACTIONS: actions} + return None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._camera = get_camera_from_cameras(self._camera_id, self.coordinator.data) + super()._handle_coordinator_update() diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index dcc030e7e5b..c695313c084 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, Mock, patch from motioneye_client.const import DEFAULT_PORT +from homeassistant.components.motioneye import get_motioneye_entity_unique_id from homeassistant.components.motioneye.const import DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -23,7 +25,7 @@ TEST_CAMERA_DEVICE_IDENTIFIER = (DOMAIN, f"{TEST_CONFIG_ENTRY_ID}_{TEST_CAMERA_I TEST_CAMERA = { "show_frame_changes": False, "framerate": 25, - "actions": [], + "actions": ["one", "two", "three"], "preserve_movies": 0, "auto_threshold_tuning": True, "recording_mode": "motion-triggered", @@ -133,6 +135,7 @@ TEST_CAMERA = { } TEST_CAMERAS = {"cameras": [TEST_CAMERA]} TEST_SURVEILLANCE_USERNAME = "surveillance_username" +TEST_SENSOR_ACTION_ENTITY_ID = "sensor.test_camera_actions" TEST_SWITCH_ENTITY_ID_BASE = "switch.test_camera" TEST_SWITCH_MOTION_DETECTION_ENTITY_ID = ( f"{TEST_SWITCH_ENTITY_ID_BASE}_motion_detection" @@ -189,3 +192,23 @@ async def setup_mock_motioneye_config_entry( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry + + +def register_test_entity( + hass: HomeAssistant, platform: str, camera_id: int, type_name: str, entity_id: str +) -> None: + """Register a test entity.""" + + unique_id = get_motioneye_entity_unique_id( + TEST_CONFIG_ENTRY_ID, camera_id, type_name + ) + entity_id = entity_id.split(".")[1] + + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + platform, + DOMAIN, + unique_id, + suggested_object_id=entity_id, + disabled_by=None, + ) diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index c14290afdde..6a669adc65f 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -222,12 +222,12 @@ async def test_get_still_image_from_camera( server = await aiohttp_server(app) client = create_mock_motioneye_client() client.get_camera_snapshot_url = Mock( - return_value=f"http://localhost:{server.port}/foo" + return_value=f"http://127.0.0.1:{server.port}/foo" ) config_entry = create_mock_motioneye_config_entry( hass, data={ - CONF_URL: f"http://localhost:{server.port}", + CONF_URL: f"http://127.0.0.1:{server.port}", CONF_SURVEILLANCE_USERNAME: TEST_SURVEILLANCE_USERNAME, }, ) diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py new file mode 100644 index 00000000000..474b8690308 --- /dev/null +++ b/tests/components/motioneye/test_sensor.py @@ -0,0 +1,132 @@ +"""Tests for the motionEye switch platform.""" +import copy +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from motioneye_client.const import KEY_ACTIONS + +from homeassistant.components.motioneye import get_motioneye_device_identifier +from homeassistant.components.motioneye.const import ( + DEFAULT_SCAN_INTERVAL, + TYPE_MOTIONEYE_ACTION_SENSOR, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util + +from . import ( + TEST_CAMERA, + TEST_CAMERA_ID, + TEST_SENSOR_ACTION_ENTITY_ID, + create_mock_motioneye_client, + register_test_entity, + setup_mock_motioneye_config_entry, +) + +from tests.common import async_fire_time_changed + + +async def test_sensor_actions(hass: HomeAssistant) -> None: + """Test the actions sensor.""" + register_test_entity( + hass, + SENSOR_DOMAIN, + TEST_CAMERA_ID, + TYPE_MOTIONEYE_ACTION_SENSOR, + TEST_SENSOR_ACTION_ENTITY_ID, + ) + + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "3" + assert entity_state.attributes.get(KEY_ACTIONS) == ["one", "two", "three"] + + updated_camera = copy.deepcopy(TEST_CAMERA) + updated_camera[KEY_ACTIONS] = ["one"] + + # When the next refresh is called return the updated values. + client.async_get_cameras = AsyncMock(return_value={"cameras": [updated_camera]}) + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "1" + assert entity_state.attributes.get(KEY_ACTIONS) == ["one"] + + del updated_camera[KEY_ACTIONS] + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "0" + assert entity_state.attributes.get(KEY_ACTIONS) is None + + +async def test_sensor_device_info(hass: HomeAssistant) -> None: + """Verify device information includes expected details.""" + + # Enable the action sensor (it is disabled by default). + register_test_entity( + hass, + SENSOR_DOMAIN, + TEST_CAMERA_ID, + TYPE_MOTIONEYE_ACTION_SENSOR, + TEST_SENSOR_ACTION_ENTITY_ID, + ) + + config_entry = await setup_mock_motioneye_config_entry(hass) + + device_identifer = get_motioneye_device_identifier( + config_entry.entry_id, TEST_CAMERA_ID + ) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({device_identifer}) + assert device + + entity_registry = await er.async_get_registry(hass) + entities_from_device = [ + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, device.id) + ] + assert TEST_SENSOR_ACTION_ENTITY_ID in entities_from_device + + +async def test_sensor_actions_can_be_enabled(hass: HomeAssistant) -> None: + """Verify the action sensor can be enabled.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert not entity_state + + with patch( + "homeassistant.components.motioneye.MotionEyeClient", + return_value=client, + ): + updated_entry = entity_registry.async_update_entity( + TEST_SENSOR_ACTION_ENTITY_ID, disabled_by=None + ) + assert not updated_entry.disabled + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) + assert entity_state