diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 8db73b74bbcf..2491fb10ed62 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -314,12 +314,6 @@ class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity): """Get sensor attributes from camera.""" return self._camera.fetch_attributes(self._sensor_type, self._channel) - @property - @override - def available(self) -> bool: - """Return true if the device's event stream is connected.""" - return self._camera.stream_connected - @property @override def is_on(self) -> bool: @@ -332,19 +326,3 @@ class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity): """Return the state attributes.""" attrs = self._get_sensor_attributes() return {ATTR_LAST_TRIP_TIME: attrs[3]} - - @override - async def async_added_to_hass(self) -> None: - """Register callback when entity is added.""" - await super().async_added_to_hass() - - # Register callback with pyhik - self._camera.add_update_callback(self._update_callback, self._callback_id) - - def _update_callback(self, msg: str) -> None: - """Update the sensor's state when callback is triggered. - - This is called from pyhik's event stream thread, so we use - schedule_update_ha_state which is thread-safe. - """ - self.schedule_update_ha_state() diff --git a/homeassistant/components/hikvision/camera.py b/homeassistant/components/hikvision/camera.py index b44a128f8771..d3215beca7c5 100644 --- a/homeassistant/components/hikvision/camera.py +++ b/homeassistant/components/hikvision/camera.py @@ -63,6 +63,10 @@ class HikvisionCamera(HikvisionEntity, Camera): # Build unique ID (unique per platform per integration) self._attr_unique_id = f"{self._data.device_id}_{channel.id}" + # No pyhik event is routed here; the registration exists so the + # camera is told when the event stream connects or drops. + self._callback_id = f"{self._data.device_id}.camera.{channel.id}" + @override async def async_camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/hikvision/entity.py b/homeassistant/components/hikvision/entity.py index 0723f685439c..f3f442e1284f 100644 --- a/homeassistant/components/hikvision/entity.py +++ b/homeassistant/components/hikvision/entity.py @@ -1,5 +1,7 @@ """Base entity for Hikvision integration.""" +from typing import override + from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -14,6 +16,12 @@ class HikvisionEntity(Entity): _attr_has_entity_name = True + # pyhik routes an update to the callbacks registered under this exact + # identifier and passes it back as the callback message. It also + # broadcasts to every registered callback when the event stream + # connects or drops, which is what drives availability. + _callback_id: str + def __init__( self, hass: HomeAssistant, @@ -49,3 +57,29 @@ class HikvisionEntity(Entity): manufacturer="Hikvision", model=self._data.device_type, ) + + @property + @override + def available(self) -> bool: + """Return true if the device's event stream is connected.""" + return self._camera.stream_connected + + @override + async def async_added_to_hass(self) -> None: + """Register callback when entity is added, and drop it on removal.""" + await super().async_added_to_hass() + + self._camera.add_update_callback(self._update_callback, self._callback_id) + self.async_on_remove( + lambda: self._camera.remove_update_callback( + self._update_callback, self._callback_id + ) + ) + + def _update_callback(self, msg: str) -> None: + """Update the entity state when the callback is triggered. + + This is called from pyhik event stream thread, so we use + schedule_update_ha_state which is thread-safe. + """ + self.schedule_update_ha_state() diff --git a/homeassistant/components/hikvision/event.py b/homeassistant/components/hikvision/event.py index 2a0a9125480f..3eaa467c5f33 100644 --- a/homeassistant/components/hikvision/event.py +++ b/homeassistant/components/hikvision/event.py @@ -135,19 +135,7 @@ class HikvisionEvent(HikvisionEntity, EventEntity): """Get sensor attributes from camera.""" return self._camera.fetch_attributes(self._sensor_type, self._channel) - @property @override - def available(self) -> bool: - """Return true if the device's event stream is connected.""" - return self._camera.stream_connected - - @override - async def async_added_to_hass(self) -> None: - """Register callback when entity is added.""" - await super().async_added_to_hass() - - self._camera.add_update_callback(self._update_callback, self._callback_id) - def _update_callback(self, msg: str) -> None: """Handle an update from pyhik's event stream thread.""" # Read the state on the callback thread: a trip that has already ended diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index 55d01b4e67c3..d2ac37df4071 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyhik"], - "requirements": ["pyHik==0.4.6"] + "requirements": ["pyHik==0.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index da131d72c85e..2e0c9e4d6153 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2038,7 +2038,7 @@ pyElectra==1.2.4 pyEmby==1.10 # homeassistant.components.hikvision -pyHik==0.4.6 +pyHik==0.4.7 # homeassistant.components.homee pyHomee==1.4.4 diff --git a/tests/components/hikvision/test_camera.py b/tests/components/hikvision/test_camera.py index 6e5076f6474d..98d1f37486ef 100644 --- a/tests/components/hikvision/test_camera.py +++ b/tests/components/hikvision/test_camera.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.camera import async_get_image, async_get_stream_source from homeassistant.components.hikvision.const import DOMAIN -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -167,3 +167,51 @@ async def test_camera_stream_source( # Verify get_stream_url was called with channel 1 mock_hikcamera.return_value.get_stream_url.assert_called_with(1) + + +async def test_camera_unavailable_when_stream_disconnected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test the camera goes unavailable when the event stream disconnects.""" + camera = mock_hikcamera.return_value + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("camera.front_camera") + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # pyhik notifies every registered callback when the stream drops + camera.stream_connected = False + callback_func = camera.add_update_callback.call_args_list[0][0][0] + callback_func("stream disconnected") + await hass.async_block_till_done() + + state = hass.states.get("camera.front_camera") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_camera_callback_removed_with_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test the pyhik callback is unregistered when the entity is removed.""" + camera = mock_hikcamera.return_value + await setup_integration(hass, mock_config_entry) + + added = [ + c.args + for c in camera.add_update_callback.call_args_list + if ".camera." in c.args[1] + ] + assert len(added) == 1 + camera.remove_update_callback.assert_not_called() + + entity_registry.async_remove("camera.front_camera") + await hass.async_block_till_done() + + camera.remove_update_callback.assert_called_once_with(*added[0])