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
67 changes: 60 additions & 7 deletions custom_components/dahua/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, events: list, addres
self._profile_mode = "0"
self._preset_position = "0"
self._supports_profile_mode = False
# Newer cameras (e.g. dual-illuminator models) select the active day/night profile through
# the VideoInMode ConfigEx string instead of the Config[0] index. Detected during polling.
self._supports_config_ex = False
self._channel = channel
self._address = address
self._max_streams = 3 # 1 main stream + 2 sub-streams by default
Expand Down Expand Up @@ -362,6 +365,21 @@ async def _async_update_data(self):
except ClientError:
_LOGGER.debug("Cam does not support profile mode. Will use mode 0")
self._supports_profile_mode = False
if not self._supports_profile_mode:
# The legacy Lighting[0][2] probe is empty on some cams (e.g. white-light/dual
# illuminator models). Fall back to the VideoInMode API, which is the canonical
# source for the active day/night profile. Dual-illuminator models report an
# empty Config list and expose the profile through ConfigEx instead, so accept
# either key - otherwise supports_config_ex() stays False and the service
# wrongly falls back to writing Config[0], which these cameras reject.
try:
mode_data = await self.client.async_get_video_in_mode()
self._supports_profile_mode = (
"table.VideoInMode[0].Config[0]" in mode_data
or "table.VideoInMode[0].ConfigEx" in mode_data
)
except ClientError:
self._supports_profile_mode = False
_LOGGER.debug("Device supports profile mode=%s", self._supports_profile_mode)
else:
# Start the event listeners for doorbells (VTO)
Expand All @@ -386,9 +404,20 @@ async def _async_update_data(self):
try:
mode_data = await self.client.async_get_video_in_mode()
data.update(mode_data)
self._profile_mode = mode_data.get("table.VideoInMode[0].Config[0]", "0")
if not self._profile_mode:
self._profile_mode = "0"
# Newer cameras (e.g. dual-illuminator models) keep VideoInMode.Config as a
# static index list ([0,1]) and report the *active* day/night profile in the
# ConfigEx string ("Day"/"Night"). Older cameras put the active profile directly
# in Config[0]. Prefer ConfigEx when the camera reports it - on those cameras
# Config[0] is always 0 and would make us think we're permanently in day mode.
config_ex = mode_data.get("table.VideoInMode[0].ConfigEx")
if config_ex is not None:
self._supports_config_ex = True
self._profile_mode = "1" if str(config_ex).lower() == "night" else "0"
else:
self._supports_config_ex = False
self._profile_mode = mode_data.get("table.VideoInMode[0].Config[0]", "0")
if not self._profile_mode:
self._profile_mode = "0"
except Exception as exception:
# I believe this API is missing on some cameras so we'll just ignore it and move on
_LOGGER.debug("Could not get profile mode", exc_info=exception)
Expand Down Expand Up @@ -766,11 +795,28 @@ def get_infrared_brightness(self) -> int:
bri = self.data.get("table.Lighting[{0}][0].MiddleLight[0].Light".format(self._channel))
return dahua_utils.dahua_brightness_to_hass_brightness(bri)

def get_illuminator_index(self, profile_mode) -> int:
"""
Return the Lighting_V2 array index of the white-light illuminator for the given profile.

On single-illuminator cameras the white light is at index 0, but on dual-illuminator
models (e.g. the -IL series) index 0 is the InfraredLight and the white light lives at a
later index. We locate it by LightType so the right light is read/controlled regardless of
layout. Falls back to 0 when no WhiteLight entry is present (legacy behaviour).
"""
for index in range(0, 3):
light_type = self.data.get(
"table.Lighting_V2[{0}][{1}][{2}].LightType".format(self._channel, profile_mode, index))
if light_type == "WhiteLight":
return index
return 0

def is_illuminator_on(self) -> bool:
"""Return true if the illuminator light is on"""
# profile_mode 0=day, 1=night, 2=scene
profile_mode = self.get_profile_mode()
return self.data.get("table.Lighting_V2[{0}][{1}][0].Mode".format(self._channel, profile_mode), "") == "Manual"
profile_mode = self.get_profile_mode()
index = self.get_illuminator_index(profile_mode)
return self.data.get("table.Lighting_V2[{0}][{1}][{2}].Mode".format(self._channel, profile_mode, index), "") == "Manual"

def is_flood_light_on(self) -> bool:

Expand All @@ -789,8 +835,10 @@ def is_ring_light_on(self) -> bool:

def get_illuminator_brightness(self) -> int:
"""Return the brightness of the illuminator light, as reported by the camera itself, between 0..255 inclusive"""

bri = self.data.get("table.Lighting_V2[{0}][0][0].MiddleLight[0].Light".format(self._channel))
# profile_mode 0=day, 1=night, 2=scene
profile_mode = self.get_profile_mode()
index = self.get_illuminator_index(profile_mode)
bri = self.data.get("table.Lighting_V2[{0}][{1}][{2}].MiddleLight[0].Light".format(self._channel, profile_mode, index))
return dahua_utils.dahua_brightness_to_hass_brightness(bri)

def is_security_light_on(self) -> bool:
Expand All @@ -801,6 +849,11 @@ def get_profile_mode(self) -> str:
# profile_mode 0=day, 1=night, 2=scene
return self._profile_mode

def supports_config_ex(self) -> bool:
"""True if the camera selects the day/night profile via VideoInMode.ConfigEx (newer models)
rather than the Config[0] index. See _async_update_data for details."""
return self._supports_config_ex

def get_channel(self) -> int:
"""returns the channel index of this camera. 0 based. Channel index 0 is channel number 1"""
return self._channel
Expand Down
9 changes: 9 additions & 0 deletions custom_components/dahua/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,17 @@ async def async_set_video_profile_mode(self, mode: str):
# 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']):
await self._coordinator.client.async_set_night_switch_mode(channel, mode)
elif self._coordinator.supports_config_ex():
# Newer cameras select the profile via VideoInMode.ConfigEx + Mode=4 (pins the profile).
# Writing Config[0] (the old path) is rejected by these cameras - see
# async_set_video_profile_config_ex.
await self._coordinator.client.async_set_video_profile_config_ex(channel, mode)
else:
await self._coordinator.client.async_set_video_profile_mode(channel, mode)
# Refresh immediately so dependent entities (e.g. the illuminator light, whose state is
# derived from the active day/night profile) update right away instead of waiting for the
# next ~30s poll. The camera reflects the new profile in its config immediately.
await self._coordinator.async_refresh()

async def async_adjustfocus(self, focus: str, zoom: str):
""" Handles the service call from SERVICE_SET_INFRARED_MODE to set zoom and focus """
Expand Down
31 changes: 28 additions & 3 deletions custom_components/dahua/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,29 @@ async def async_set_video_profile_mode(self, channel: int, mode: str):
url = "/cgi-bin/configManager.cgi?action=setConfig&VideoInMode[{0}].Config[0]={1}".format(channel, mode)
return await self.get(url, True)

async def async_set_video_profile_config_ex(self, channel: int, mode: str):
"""
async_set_video_profile_config_ex selects the day/night profile on cameras that expose
VideoInMode.ConfigEx (e.g. newer dual-illuminator models). Mode should be one of: Day or Night.

These cameras keep VideoInMode.Config as a static index list and select the active profile
through the ConfigEx string ("Day"/"Night"). Writing Config[0] (the old path) is rejected by
this firmware, so we drive ConfigEx instead.

We also set VideoInMode.Mode=4, which pins the chosen profile so it is held instead of being
re-evaluated by an automatic day/night switch. Verified against the device via:
setConfig&VideoInMode[0].Mode=4&VideoInMode[0].ConfigEx=Day
setConfig&VideoInMode[0].Mode=4&VideoInMode[0].ConfigEx=Night
Both switch the profile correctly. (The camera's own web UI can mislabel this working mode,
but the API call itself works.)
"""

config_ex = "Night" if mode.lower() == "night" else "Day"
url = "/cgi-bin/configManager.cgi?action=setConfig&VideoInMode[{ch}].Mode=4&VideoInMode[{ch}].ConfigEx={cfg}".format(
ch=channel, cfg=config_ex
)
return await self.get(url, True)

async def async_adjustfocus_v1(self, focus: str, zoom: str):
"""
async_adjustfocus will set the zoom and focus
Expand Down Expand Up @@ -495,21 +518,23 @@ async def async_set_service_set_custom_overlay(self, channel: int, group: int, t
if "OK" not in value and "ok" not in value:
raise Exception("Could not set text")

async def async_set_lighting_v2(self, channel: int, enabled: bool, brightness: int, profile_mode: str) -> dict:
async def async_set_lighting_v2(self, channel: int, enabled: bool, brightness: int, profile_mode: str, index: int = 0) -> dict:
"""
async_set_lighting_v2 will turn on or off the white light on the camera. If turning on, the brightness will be used.
brightness is in the range of 0 to 100 inclusive where 100 is the brightest.
NOTE: this is not the same as the infrared (IR) light. This is the white visible light on the camera

profile_mode: 0=day, 1=night, 2=scene
index: the Lighting_V2 light index. White light is index 0 on single-illuminator cams but a
later index on dual-illuminator (-IL) models, so the caller passes the resolved index.
"""

# on = Manual, off = Off
mode = "Manual"
if not enabled:
mode = "Off"
url = "/cgi-bin/configManager.cgi?action=setConfig&Lighting_V2[{channel}][{profile_mode}][0].Mode={mode}&Lighting_V2[{channel}][{profile_mode}][0].MiddleLight[0].Light={brightness}".format(
channel=channel, profile_mode=profile_mode, mode=mode, brightness=brightness
url = "/cgi-bin/configManager.cgi?action=setConfig&Lighting_V2[{channel}][{profile_mode}][{index}].Mode={mode}&Lighting_V2[{channel}][{profile_mode}][{index}].MiddleLight[0].Light={brightness}".format(
channel=channel, profile_mode=profile_mode, index=index, mode=mode, brightness=brightness
)
_LOGGER.debug("Turning light on: %s", url)
return await self.get(url)
Expand Down
6 changes: 4 additions & 2 deletions custom_components/dahua/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ async def async_turn_on(self, **kwargs):
dahua_brightness = dahua_utils.hass_brightness_to_dahua_brightness(hass_brightness)
channel = self._coordinator.get_channel()
profile_mode = self._coordinator.get_profile_mode()
await self._coordinator.client.async_set_lighting_v2(channel, True, dahua_brightness, profile_mode)
index = self._coordinator.get_illuminator_index(profile_mode)
await self._coordinator.client.async_set_lighting_v2(channel, True, dahua_brightness, profile_mode, index)
await self._coordinator.async_refresh()

async def async_turn_off(self, **kwargs):
Expand All @@ -175,7 +176,8 @@ async def async_turn_off(self, **kwargs):
dahua_brightness = dahua_utils.hass_brightness_to_dahua_brightness(hass_brightness)
channel = self._coordinator.get_channel()
profile_mode = self._coordinator.get_profile_mode()
await self._coordinator.client.async_set_lighting_v2(channel, False, dahua_brightness, profile_mode)
index = self._coordinator.get_illuminator_index(profile_mode)
await self._coordinator.client.async_set_lighting_v2(channel, False, dahua_brightness, profile_mode, index)
await self._coordinator.async_refresh()


Expand Down