diff --git a/custom_components/frigate/__init__.py b/custom_components/frigate/__init__.py index 83e0d092..b4ad5177 100644 --- a/custom_components/frigate/__init__.py +++ b/custom_components/frigate/__init__.py @@ -414,7 +414,7 @@ def __init__(self, config_entry: ConfigEntry): @property def available(self) -> bool: """Return the availability of the entity.""" - return self._available + return self._available and super().available def _get_model(self) -> str: """Get the Frigate device model string.""" @@ -451,11 +451,13 @@ async def async_added_to_hass(self) -> None: self._topic_map, ) self._sub_state = await async_subscribe_topics(self.hass, state) + await super().async_added_to_hass() async def async_will_remove_from_hass(self) -> None: """Cleanup prior to hass removal.""" async_unsubscribe_topics(self.hass, self._sub_state) self._sub_state = None + await super().async_will_remove_from_hass() @callback # type: ignore[misc] def _availability_message_received(self, msg: ReceiveMessage) -> None: diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index 132f958b..51dd6a83 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -20,8 +20,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ( + FrigateDataUpdateCoordinator, FrigateEntity, FrigateMQTTEntity, ReceiveMessage, @@ -33,6 +35,7 @@ from .const import ( ATTR_CLIENT, ATTR_CONFIG, + ATTR_COORDINATOR, ATTR_EVENT_ID, ATTR_FAVORITE, ATTR_PTZ_ACTION, @@ -60,6 +63,7 @@ async def async_setup_entry( frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] frigate_client = hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT] client_id = get_frigate_instance_id_for_config_entry(hass, entry) + coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] async_add_entities( [ @@ -68,6 +72,7 @@ async def async_setup_entry( cam_name, frigate_client, client_id, + coordinator, frigate_config, camera_config, ) @@ -104,7 +109,7 @@ async def async_setup_entry( ) -class FrigateCamera(FrigateMQTTEntity, Camera): # type: ignore[misc] +class FrigateCamera(FrigateMQTTEntity, CoordinatorEntity, Camera): # type: ignore[misc] """Representation of a Frigate camera.""" # sets the entity name to same as device name ex: camera.front_doorbell @@ -116,6 +121,7 @@ def __init__( cam_name: str, frigate_client: FrigateApiClient, frigate_client_id: Any | None, + coordinator: FrigateDataUpdateCoordinator, frigate_config: dict[str, Any], camera_config: dict[str, Any], ) -> None: @@ -150,6 +156,7 @@ def __init__( }, ) FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) Camera.__init__(self) self._url = config_entry.data[CONF_URL] self._attr_is_on = True @@ -227,6 +234,14 @@ def _motion_message_received(self, msg: ReceiveMessage) -> None: self._attr_motion_detection_enabled = msg.payload.decode("utf-8") == "ON" self.async_write_ha_state() + @property + def available(self) -> bool: + """Signal when frigate loses connection to camera.""" + if self.coordinator.data: + if self.coordinator.data.get(self._cam_name, {}).get("camera_fps", 0) == 0: + return False + return super().available + @property def unique_id(self) -> str: """Return a unique ID to use for this entity.""" diff --git a/tests/test_camera.py b/tests/test_camera.py index 44c60040..5d1de217 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -7,8 +7,12 @@ from unittest.mock import AsyncMock import pytest -from pytest_homeassistant_custom_component.common import async_fire_mqtt_message +from pytest_homeassistant_custom_component.common import ( + async_fire_mqtt_message, + async_fire_time_changed, +) +from custom_components.frigate import SCAN_INTERVAL from custom_components.frigate.const import ( ATTR_EVENT_ID, ATTR_FAVORITE, @@ -31,6 +35,7 @@ from homeassistant.const import ATTR_ENTITY_ID 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_BIRDSEYE_ENTITY_ID, @@ -40,6 +45,7 @@ TEST_CONFIG_ENTRY_ID, TEST_FRIGATE_INSTANCE_ID, TEST_SERVER_VERSION, + TEST_STATS, create_mock_frigate_client, create_mock_frigate_config_entry, setup_mock_frigate_config_entry, @@ -355,6 +361,27 @@ async def test_camera_disable_motion_detection( ) +async def test_camera_unavailable(hass: HomeAssistant) -> None: + """Test that camera is marked as unavailable.""" + client = create_mock_frigate_client() + stats: dict[str, Any] = copy.deepcopy(TEST_STATS) + client.async_get_stats = AsyncMock(return_value=stats) + await setup_mock_frigate_config_entry(hass, client=client) + + entity_state = hass.states.get(TEST_CAMERA_FRONT_DOOR_ENTITY_ID) + assert entity_state + assert entity_state.state == "streaming" + + stats["front_door"]["camera_fps"] = 0.0 + + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_CAMERA_FRONT_DOOR_ENTITY_ID) + assert entity_state + assert entity_state.state == "unavailable" + + @pytest.mark.parametrize( "entityid_to_uniqueid", [ diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 6baeacce..fc14b122 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -284,13 +284,18 @@ async def test_status_sensor_error(hass: HomeAssistant) -> None: await setup_mock_frigate_config_entry(hass, client=client) await enable_and_load_entity(hass, client, TEST_SENSOR_FRIGATE_STATUS_ENTITY_ID) + async_fire_mqtt_message(hass, "frigate/available", "online") + await hass.async_block_till_done() + client.async_get_stats = AsyncMock(side_effect=FrigateApiClientError) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() entity_state = hass.states.get(TEST_SENSOR_FRIGATE_STATUS_ENTITY_ID) assert entity_state - assert entity_state.state == "error" + + # The update coordinator will treat the error as unavailability. + assert entity_state.state == "unavailable" assert entity_state.attributes["icon"] == ICON_SERVER