Hoist Hikvision entity availability to the base entity (#182306)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Tarjan
2026-09-24 15:53:35 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 407307ce30
commit e8e5a55055
7 changed files with 89 additions and 37 deletions
@@ -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()
@@ -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
@@ -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()
@@ -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
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"requirements": ["pyHik==0.4.6"]
"requirements": ["pyHik==0.4.7"]
}
+1 -1
View File
@@ -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
+49 -1
View File
@@ -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])