diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 9e54010e3e5..8196ed48bd9 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( @@ -23,6 +24,7 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_USERNAME, + EntityCategory, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -42,29 +44,118 @@ CONF_IGNORED = "ignored" DEFAULT_DELAY = 0 DEFAULT_IGNORED = False -# Device class mapping for Hikvision event types -DEVICE_CLASS_MAP: dict[str, BinarySensorDeviceClass | None] = { - "Motion": BinarySensorDeviceClass.MOTION, - "Line Crossing": BinarySensorDeviceClass.MOTION, - "Field Detection": BinarySensorDeviceClass.MOTION, - "Tamper Detection": BinarySensorDeviceClass.MOTION, - "Shelter Alarm": None, - "Disk Full": None, - "Disk Error": None, - "Net Interface Broken": BinarySensorDeviceClass.CONNECTIVITY, - "IP Conflict": BinarySensorDeviceClass.CONNECTIVITY, - "Illegal Access": None, - "Video Mismatch": None, - "Bad Video": None, - "PIR Alarm": BinarySensorDeviceClass.MOTION, - "Face Detection": BinarySensorDeviceClass.MOTION, - "Scene Change Detection": BinarySensorDeviceClass.MOTION, - "I/O": None, - "Unattended Baggage": BinarySensorDeviceClass.MOTION, - "Attended Baggage": BinarySensorDeviceClass.MOTION, - "Recording Failure": None, - "Exiting Region": BinarySensorDeviceClass.MOTION, - "Entering Region": BinarySensorDeviceClass.MOTION, + +# Entity descriptions for known Hikvision event types +# The key matches the sensor_type from pyhik (the friendly name from SENSOR_MAP) +BINARY_SENSOR_DESCRIPTIONS: dict[str, BinarySensorEntityDescription] = { + "Motion": BinarySensorEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Line Crossing": BinarySensorEntityDescription( + key="line_crossing", + translation_key="line_crossing", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Field Detection": BinarySensorEntityDescription( + key="field_detection", + translation_key="field_detection", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Tamper Detection": BinarySensorEntityDescription( + key="tamper_detection", + device_class=BinarySensorDeviceClass.TAMPER, + ), + "Shelter Alarm": BinarySensorEntityDescription( + key="shelter_alarm", + translation_key="shelter_alarm", + ), + "Disk Full": BinarySensorEntityDescription( + key="disk_full", + translation_key="disk_full", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "Disk Error": BinarySensorEntityDescription( + key="disk_error", + translation_key="disk_error", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "Net Interface Broken": BinarySensorEntityDescription( + key="net_interface_broken", + translation_key="net_interface_broken", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "IP Conflict": BinarySensorEntityDescription( + key="ip_conflict", + translation_key="ip_conflict", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "Illegal Access": BinarySensorEntityDescription( + key="illegal_access", + translation_key="illegal_access", + device_class=BinarySensorDeviceClass.SAFETY, + ), + "Video Mismatch": BinarySensorEntityDescription( + key="video_mismatch", + translation_key="video_mismatch", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "Bad Video": BinarySensorEntityDescription( + key="bad_video", + translation_key="bad_video", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "PIR Alarm": BinarySensorEntityDescription( + key="pir_alarm", + translation_key="pir_alarm", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Face Detection": BinarySensorEntityDescription( + key="face_detection", + translation_key="face_detection", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Scene Change Detection": BinarySensorEntityDescription( + key="scene_change_detection", + translation_key="scene_change_detection", + device_class=BinarySensorDeviceClass.MOTION, + ), + "I/O": BinarySensorEntityDescription( + key="io", + translation_key="io", + ), + "Unattended Baggage": BinarySensorEntityDescription( + key="unattended_baggage", + translation_key="unattended_baggage", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Attended Baggage": BinarySensorEntityDescription( + key="attended_baggage", + translation_key="attended_baggage", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Recording Failure": BinarySensorEntityDescription( + key="recording_failure", + translation_key="recording_failure", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "Exiting Region": BinarySensorEntityDescription( + key="exiting_region", + translation_key="exiting_region", + device_class=BinarySensorDeviceClass.MOTION, + ), + "Entering Region": BinarySensorEntityDescription( + key="entering_region", + translation_key="entering_region", + device_class=BinarySensorDeviceClass.MOTION, + ), } _LOGGER = logging.getLogger(__name__) @@ -158,13 +249,24 @@ async def async_setup_entry( ) return + # Log warnings for unknown sensor types and skip them + for sensor_type in sensors: + if sensor_type not in BINARY_SENSOR_DESCRIPTIONS: + _LOGGER.warning( + "Unknown Hikvision sensor type '%s', please report this at " + "https://github.com/home-assistant/core/issues", + sensor_type, + ) + async_add_entities( HikvisionBinarySensor( entry=entry, + description=BINARY_SENSOR_DESCRIPTIONS[sensor_type], sensor_type=sensor_type, channel=channel_info[1], ) for sensor_type, channel_list in sensors.items() + if sensor_type in BINARY_SENSOR_DESCRIPTIONS for channel_info in channel_list ) @@ -177,20 +279,18 @@ class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity): def __init__( self, entry: HikvisionConfigEntry, + description: BinarySensorEntityDescription, sensor_type: str, channel: int, ) -> None: """Initialize the binary sensor.""" super().__init__(entry, channel) + self.entity_description = description self._sensor_type = sensor_type # Build unique ID (includes sensor_type for uniqueness per sensor) self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}" - # Set entity name and device class - self._attr_name = sensor_type - self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type) - # Callback ID for pyhik self._callback_id = f"{self._data.device_id}.{sensor_type}.{channel}" diff --git a/homeassistant/components/hikvision/strings.json b/homeassistant/components/hikvision/strings.json index 0b5241bdd29..a6b279bcdec 100644 --- a/homeassistant/components/hikvision/strings.json +++ b/homeassistant/components/hikvision/strings.json @@ -34,6 +34,67 @@ "name": "{device_name} channel {channel_number}" } }, + "entity": { + "binary_sensor": { + "attended_baggage": { + "name": "Attended baggage" + }, + "bad_video": { + "name": "Bad video" + }, + "disk_error": { + "name": "Disk error" + }, + "disk_full": { + "name": "Disk full" + }, + "entering_region": { + "name": "Entering region" + }, + "exiting_region": { + "name": "Exiting region" + }, + "face_detection": { + "name": "Face detection" + }, + "field_detection": { + "name": "Field detection" + }, + "illegal_access": { + "name": "Illegal access" + }, + "io": { + "name": "I/O alarm" + }, + "ip_conflict": { + "name": "IP conflict" + }, + "line_crossing": { + "name": "Line crossing" + }, + "net_interface_broken": { + "name": "Network interface broken" + }, + "pir_alarm": { + "name": "PIR alarm" + }, + "recording_failure": { + "name": "Recording failure" + }, + "scene_change_detection": { + "name": "Scene change detection" + }, + "shelter_alarm": { + "name": "Shelter alarm" + }, + "unattended_baggage": { + "name": "Unattended baggage" + }, + "video_mismatch": { + "name": "Video mismatch" + } + } + }, "issues": { "deprecated_yaml_import_issue": { "description": "Configuring {integration_title} using YAML is deprecated and the import failed. Please remove the `{domain}` entry from your `configuration.yaml` file and set up the integration manually.", diff --git a/tests/components/hikvision/snapshots/test_binary_sensor.ambr b/tests/components/hikvision/snapshots/test_binary_sensor.ambr index 564e025e445..d915b63e87b 100644 --- a/tests/components/hikvision/snapshots/test_binary_sensor.ambr +++ b/tests/components/hikvision/snapshots/test_binary_sensor.ambr @@ -20,17 +20,17 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Line Crossing', + 'object_id_base': 'Line crossing', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Line Crossing', + 'original_name': 'Line crossing', 'platform': 'hikvision', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'line_crossing', 'unique_id': 'DS-2CD2142FWD-I20170101AAAA_Line Crossing_1', 'unit_of_measurement': None, }) @@ -39,7 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'motion', - 'friendly_name': 'Front Camera Line Crossing', + 'friendly_name': 'Front Camera Line crossing', 'last_tripped_time': '2024-01-01T00:00:00Z', }), 'context': , diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py index 73a27b332b3..09fa8d1f26d 100644 --- a/tests/components/hikvision/test_binary_sensor.py +++ b/tests/components/hikvision/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test Hikvision binary sensors.""" +import logging from unittest.mock import MagicMock import pytest @@ -115,22 +116,28 @@ async def test_binary_sensor_no_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hikcamera: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when device has no sensors.""" mock_hikcamera.return_value.current_event_states = None - await setup_integration(hass, mock_config_entry) + with caplog.at_level(logging.WARNING): + await setup_integration(hass, mock_config_entry) # No binary sensors should be created states = hass.states.async_entity_ids("binary_sensor") assert len(states) == 0 + # Verify warning was logged + assert "has no sensors available" in caplog.text + @pytest.mark.parametrize("amount_of_channels", [2]) async def test_binary_sensor_nvr_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hikcamera: MagicMock, + device_registry: dr.DeviceRegistry, ) -> None: """Test binary sensor naming for NVR devices.""" mock_hikcamera.return_value.get_type = "NVR" @@ -140,12 +147,22 @@ async def test_binary_sensor_nvr_device( await setup_integration(hass, mock_config_entry) - # NVR sensors are on per-channel devices - state = hass.states.get("binary_sensor.front_camera_channel_1_motion") - assert state is not None + # Verify NVR channel devices are created with via_device linking + channel_1_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{TEST_DEVICE_ID}_1")} + ) + assert channel_1_device is not None + assert channel_1_device.via_device_id is not None - state = hass.states.get("binary_sensor.front_camera_channel_2_motion") - assert state is not None + channel_2_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{TEST_DEVICE_ID}_2")} + ) + assert channel_2_device is not None + assert channel_2_device.via_device_id is not None + + # Verify sensors are created (entity IDs depend on translation loading) + states = hass.states.async_entity_ids("binary_sensor") + assert len(states) == 2 async def test_binary_sensor_state_on( @@ -172,17 +189,22 @@ async def test_binary_sensor_device_class_unknown( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hikcamera: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test binary sensor with unknown device class.""" + """Test unknown sensor types are logged and skipped.""" mock_hikcamera.return_value.current_event_states = { "Unknown Event": [(False, 1)], } - await setup_integration(hass, mock_config_entry) + with caplog.at_level(logging.WARNING): + await setup_integration(hass, mock_config_entry) - state = hass.states.get("binary_sensor.front_camera_unknown_event") - assert state is not None - assert state.attributes.get(ATTR_DEVICE_CLASS) is None + # No entity should be created for unknown sensor types + states = hass.states.async_entity_ids("binary_sensor") + assert len(states) == 0 + + # Verify warning was logged for unknown sensor type + assert "Unknown Hikvision sensor type 'Unknown Event'" in caplog.text async def test_yaml_import_creates_deprecation_issue(