Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions custom_components/dahua/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from . import dahua_utils
from .client import DahuaClient
from .model_profiles import is_sdt4e425

from .const import (
CONF_EVENTS,
Expand Down Expand Up @@ -304,13 +305,17 @@ async def _async_update_data(self):
self._supports_event_notifications = False
_LOGGER.debug("Device supports event notifications=%s", self._supports_event_notifications)

# PTZ
# The following lines are for Dahua devices
try:
await self.client.async_get_ptz_position()
self._supports_ptz_position = True
except ClientError:
# PTZ position readback. The SDT4E425 PTZ sensor is controllable,
# but firmware V3.200.0000027.6.R returns HTTP 400 for CGI getStatus.
# Do not conflate PTZ/preset control with CGI position readback.
if is_sdt4e425(self.model):
self._supports_ptz_position = False
else:
try:
await self.client.async_get_ptz_position()
self._supports_ptz_position = True
except ClientError:
self._supports_ptz_position = False
_LOGGER.debug("Device supports PTZ position=%s", self._supports_ptz_position)

# Smart motion detection is enabled/disabled/fetched differently on Dahua devices compared to Amcrest
Expand Down
121 changes: 84 additions & 37 deletions custom_components/dahua/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from custom_components.dahua import DahuaDataUpdateCoordinator
from custom_components.dahua.entity import DahuaBaseEntity
from custom_components.dahua.model_profiles import is_sdt4e425

from .const import (
DOMAIN,
Expand Down Expand Up @@ -44,19 +45,48 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie
"""Add a Dahua IP camera from a config entry."""

coordinator: DahuaDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
max_streams = coordinator.get_max_streams()

# Note the stream_index is 0 based. The main stream is index 0
for stream_index in range(max_streams):
async_add_entities(
[
DahuaCamera(
coordinator,
stream_index,
config_entry,
)
]
if is_sdt4e425(coordinator.get_model()):
# This physical camera exposes two sensors. Preserve RRoller's native
# Main/Sub/Sub_2 creation for each media channel from one config entry.
sensors = (
# logical channel, media channel, display name, unique-id prefix
(0, 1, "Panorama", ""),
(1, 2, "PTZ", "1_"),
)
entities = []
for logical_channel, media_channel, sensor_name, unique_prefix in sensors:
for stream_index in range(coordinator.get_max_streams()):
stream_name = coordinator.client.to_stream_name(stream_index)
display_name = (
sensor_name
if stream_index == 0
else f"{sensor_name} {stream_name}"
)
entities.append(
DahuaCamera(
coordinator,
stream_index,
config_entry,
logical_channel=logical_channel,
media_channel=media_channel,
display_name=display_name,
unique_suffix=f"{unique_prefix}{stream_name}",
)
)
async_add_entities(entities)
else:
max_streams = coordinator.get_max_streams()
# Note the stream_index is 0 based. The main stream is index 0
for stream_index in range(max_streams):
async_add_entities(
[
DahuaCamera(
coordinator,
stream_index,
config_entry,
)
]
)

platform = entity_platform.async_get_current_platform()

Expand Down Expand Up @@ -236,19 +266,33 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie
class DahuaCamera(DahuaBaseEntity, Camera):
"""An implementation of a Dahua IP camera."""

def __init__(self, coordinator: DahuaDataUpdateCoordinator, stream_index: int, config_entry):
def __init__(
self, coordinator: DahuaDataUpdateCoordinator, stream_index: int, config_entry,
*, logical_channel: int | None = None, media_channel: int | None = None,
display_name: str | None = None, unique_suffix: str | None = None,
):
"""Initialize the Dahua camera."""
DahuaBaseEntity.__init__(self, coordinator, config_entry)
Camera.__init__(self)

name = coordinator.client.to_stream_name(stream_index)
self._channel_number = coordinator.get_channel_number()
stream_name = coordinator.client.to_stream_name(stream_index)
self._logical_channel = (
coordinator.get_channel() if logical_channel is None else logical_channel
)
self._channel_number = (
coordinator.get_channel_number() if media_channel is None else media_channel
)
self._coordinator = coordinator
self._name = "{0} {1}".format(config_entry.title, name)
self._unique_id = coordinator.get_serial_number() + "_" + name
self._name = (
f"{config_entry.title} {display_name}"
if display_name else f"{config_entry.title} {stream_name}"
)
suffix = unique_suffix or stream_name
self._unique_id = coordinator.get_serial_number() + "_" + suffix
self._stream_index = stream_index
self._motion_status = False
self._stream_source = coordinator.client.get_rtsp_stream_url(self._channel_number, stream_index)
self._stream_source = coordinator.client.get_rtsp_stream_url(
self._channel_number, stream_index
)
self._attr_frontend_stream_type = StreamType.WEB_RTC

@property
Expand Down Expand Up @@ -278,7 +322,7 @@ def motion_detection_enabled(self):
async def async_enable_motion_detection(self):
"""Enable motion detection in camera."""
try:
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.enable_motion_detection(channel, True)
await self._coordinator.async_refresh()
except TypeError:
Expand All @@ -287,7 +331,7 @@ async def async_enable_motion_detection(self):
async def async_disable_motion_detection(self):
"""Disable motion detection."""
try:
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.enable_motion_detection(channel, False)
await self._coordinator.async_refresh()
except TypeError:
Expand All @@ -300,19 +344,22 @@ def name(self):

async def async_set_infrared_mode(self, mode: str, brightness: int):
""" Handles the service call from SERVICE_SET_INFRARED_MODE to set infrared mode and brightness """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_lighting_v1_mode(channel, mode, brightness)
await self._coordinator.async_refresh()

async def async_goto_preset_position(self, position: int):
""" Handles the service call from SERVICE_GOTO_PRESET_POSITION to go to a specific preset position """
channel = self._coordinator.get_channel()
await self._coordinator.client.async_goto_preset_position(channel, position)
"""Go to a preset, using RPC2 only for the SDT4E425."""
channel = self._logical_channel
if is_sdt4e425(self._coordinator.get_model()):
await self._coordinator.client.async_goto_preset_rpc2(1, position)
else:
await self._coordinator.client.async_goto_preset_position(channel, position)
await self._coordinator.async_refresh()

async def async_set_video_in_day_night_mode(self, config_type: str, mode: str):
""" Handles the service call from SERVICE_SET_DAY_NIGHT_MODE to set the day/night color mode """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_video_in_day_night_mode(channel, config_type, mode)
await self._coordinator.async_refresh()

Expand All @@ -322,13 +369,13 @@ async def async_reboot(self):

async def async_set_record_mode(self, mode: str):
""" Handles the service call from SERVICE_SET_RECORD_MODE to set the record mode """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_record_mode(channel, mode)
await self._coordinator.async_refresh()

async def async_set_video_profile_mode(self, mode: str):
""" Handles the service call from SERVICE_SET_VIDEO_PROFILE_MODE to set profile mode to day/night """
channel = self._coordinator.get_channel()
channel = self._logical_channel
model = self._coordinator.get_model()
# Some NVRs like the Lorex DHI-NVR4108HS-8P-4KS2 change the day/night mode through a switch
if any(substring in model for substring in ['NVR4108HS', 'IPC-Color4K']):
Expand All @@ -347,32 +394,32 @@ async def async_set_privacy_masking(self, index: int, enabled: bool):

async def async_set_enable_channel_title(self, enabled: bool):
""" Handles the service call from SERVICE_ENABLE_CHANNEL_TITLE """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_enable_channel_title(channel, enabled)

async def async_set_enable_time_overlay(self, enabled: bool):
""" Handles the service call from SERVICE_ENABLE_TIME_OVERLAY """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_enable_time_overlay(channel, enabled)

async def async_set_enable_text_overlay(self, group: int, enabled: bool):
""" Handles the service call from SERVICE_ENABLE_TEXT_OVERLAY """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_enable_text_overlay(channel, group, enabled)

async def async_set_enable_custom_overlay(self, group: int, enabled: bool):
""" Handles the service call from SERVICE_ENABLE_CUSTOM_OVERLAY """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_enable_custom_overlay(channel, group, enabled)

async def async_set_enable_all_ivs_rules(self, enabled: bool):
""" Handles the service call from SERVICE_ENABLE_ALL_IVS_RULES """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_all_ivs_rules(channel, enabled)

async def async_enable_ivs_rule(self, index: int, enabled: bool):
""" Handles the service call from SERVICE_ENABLE_IVS_RULE """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_ivs_rule(channel, index, enabled)

async def async_vto_open_door(self, door_id: int):
Expand All @@ -385,16 +432,16 @@ async def async_vto_cancel_call(self):

async def async_set_service_set_channel_title(self, text1: str, text2: str):
""" Handles the service call from SERVICE_SET_CHANNEL_TITLE to set profile mode to day/night """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_service_set_channel_title(channel, text1, text2)

async def async_set_service_set_text_overlay(self, group: int, text1: str, text2: str, text3: str,
text4: str):
""" Handles the service call from SERVICE_SET_TEXT_OVERLAY to set profile mode to day/night """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_service_set_text_overlay(channel, group, text1, text2, text3, text4)

async def async_set_service_set_custom_overlay(self, group: int, text1: str, text2: str):
""" Handles the service call from SERVICE_SET_CUSTOM_OVERLAY to set profile mode to day/night """
channel = self._coordinator.get_channel()
channel = self._logical_channel
await self._coordinator.client.async_set_service_set_custom_overlay(channel, group, text1, text2)
73 changes: 73 additions & 0 deletions custom_components/dahua/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import async_timeout

from .digest import DigestAuth
from .rpc2 import DahuaRpc2Client
from hashlib import md5
from urllib.parse import quote

Expand Down Expand Up @@ -316,6 +317,78 @@ async def async_get_ptz_position(self) -> dict:
url = "/cgi-bin/ptz.cgi?action=getStatus"
return await self.get(url)

@staticmethod
def parse_ptz_preset_ids(data: list) -> list[int]:
"""Return sorted positive preset IDs from ptz.getPresets."""
preset_ids: set[int] = set()
if not isinstance(data, list):
return []
for preset in data:
if not isinstance(preset, dict):
continue
value = preset.get("Index")
if isinstance(value, bool):
continue
try:
preset_id = int(value)
except (TypeError, ValueError):
continue
if preset_id > 0:
preset_ids.add(preset_id)
return sorted(preset_ids)

@staticmethod
def _new_rpc2_session() -> aiohttp.ClientSession:
"""Use an isolated RPC2 session whose cookie jar accepts IP hosts."""
return aiohttp.ClientSession(
connector=aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=False),
cookie_jar=aiohttp.CookieJar(unsafe=True),
)

async def async_get_ptz_preset_ids(self, channel_index: int) -> list[int]:
"""Read the real preset IDs exposed by Web5.0 RPC2."""
async with self._new_rpc2_session() as session:
rpc2 = DahuaRpc2Client(
self._username, self._password, self._address, self._port,
self._rtsp_port, session
)
try:
async with async_timeout.timeout(5):
presets = await rpc2.async_get_ptz_presets(channel_index)
ids = self.parse_ptz_preset_ids(presets)
if presets and not ids:
raise ValueError("Dahua RPC2 preset response contains no valid IDs")
return ids
finally:
try:
async with async_timeout.timeout(3):
logout_ok = await rpc2.logout()
if not logout_ok:
_LOGGER.debug(
"RPC2 logout reported failure after preset discovery"
)
except Exception:
_LOGGER.debug("RPC2 logout failed after preset discovery", exc_info=True)

async def async_goto_preset_rpc2(self, channel: int, position: int) -> dict:
"""Go to a real preset through the hardware-validated RPC2 contract."""
async with self._new_rpc2_session() as session:
rpc2 = DahuaRpc2Client(
self._username, self._password, self._address, self._port,
self._rtsp_port, session
)
try:
async with async_timeout.timeout(5):
return await rpc2.async_goto_preset_position(channel, position)
finally:
try:
async with async_timeout.timeout(3):
logout_ok = await rpc2.logout()
if not logout_ok:
_LOGGER.debug("RPC2 logout reported failure after GotoPreset")
except Exception:
_LOGGER.debug("RPC2 logout failed after GotoPreset", exc_info=True)

async def async_get_light_global_enabled(self) -> dict:
"""
Returns the state of the Amcrest blue ring light (if it's on or off)
Expand Down
14 changes: 14 additions & 0 deletions custom_components/dahua/model_profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Small model-specific helpers for upstream Dahua compatibility."""
from __future__ import annotations

import re

_SDT4E425_RE = re.compile(
r"^(?:DH-)?SDT4E425-4F-GB-A-PV1(?:-.+)?$",
re.IGNORECASE,
)


def is_sdt4e425(model: str | None) -> bool:
"""Return True for the hardware-validated Dahua SDT4E425 model family."""
return bool(_SDT4E425_RE.match((model or "").strip()))
Loading