From 2379cda5bd4747d8fcc12237e3d4b9ab39377abf Mon Sep 17 00:00:00 2001 From: tex mex Date: Mon, 1 Jun 2026 11:35:11 +0000 Subject: [PATCH 1/3] Fixed light state/control on dual illuminator camera --- custom_components/dahua/__init__.py | 36 +++++++++++++++++++++++++---- custom_components/dahua/camera.py | 4 ++++ custom_components/dahua/client.py | 8 ++++--- custom_components/dahua/light.py | 6 +++-- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/custom_components/dahua/__init__.py b/custom_components/dahua/__init__.py index 984e55e..bb912c2 100755 --- a/custom_components/dahua/__init__.py +++ b/custom_components/dahua/__init__.py @@ -362,6 +362,15 @@ 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. + try: + mode_data = await self.client.async_get_video_in_mode() + self._supports_profile_mode = "table.VideoInMode[0].Config[0]" 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) @@ -766,11 +775,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: @@ -789,8 +815,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: diff --git a/custom_components/dahua/camera.py b/custom_components/dahua/camera.py index ced874a..8cc4c9a 100755 --- a/custom_components/dahua/camera.py +++ b/custom_components/dahua/camera.py @@ -335,6 +335,10 @@ async def async_set_video_profile_mode(self, mode: str): await self._coordinator.client.async_set_night_switch_mode(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 """ diff --git a/custom_components/dahua/client.py b/custom_components/dahua/client.py index 7d573c8..138b23e 100644 --- a/custom_components/dahua/client.py +++ b/custom_components/dahua/client.py @@ -495,21 +495,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) diff --git a/custom_components/dahua/light.py b/custom_components/dahua/light.py index a844366..c3c99d4 100755 --- a/custom_components/dahua/light.py +++ b/custom_components/dahua/light.py @@ -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): @@ -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() From 3fe5b8dbf9b146bebf4b947c93024f4e24543249 Mon Sep 17 00:00:00 2001 From: tex mex Date: Mon, 1 Jun 2026 23:32:01 +0000 Subject: [PATCH 2/3] Fix day/night profile oscillation on dual illuminator (IL) cameras --- custom_components/dahua/__init__.py | 25 ++++++++++++++++++++++--- custom_components/dahua/camera.py | 5 +++++ custom_components/dahua/client.py | 24 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/custom_components/dahua/__init__.py b/custom_components/dahua/__init__.py index bb912c2..5296cb2 100755 --- a/custom_components/dahua/__init__.py +++ b/custom_components/dahua/__init__.py @@ -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 @@ -395,9 +398,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) @@ -829,6 +843,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 diff --git a/custom_components/dahua/camera.py b/custom_components/dahua/camera.py index 8cc4c9a..edaac50 100755 --- a/custom_components/dahua/camera.py +++ b/custom_components/dahua/camera.py @@ -333,6 +333,11 @@ 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 (manual). Writing + # Config[0] (the old path) is ignored by these cameras, so the profile never sticks and + # the brightness auto-switch keeps flipping it - 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 diff --git a/custom_components/dahua/client.py b/custom_components/dahua/client.py index 138b23e..26b250e 100644 --- a/custom_components/dahua/client.py +++ b/custom_components/dahua/client.py @@ -393,6 +393,30 @@ 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 forces 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 ([0,1]) and select the profile + through the ConfigEx string. The integration used to write Config[0], which does nothing + useful here and is ignored by the firmware. + + We must also set VideoInMode.Mode=4 ("manual"), which is exactly what the camera's own web UI + sends when you toggle day/night by hand. If the camera is left on an automatic switch mode + (e.g. Mode=2 = by brightness) it re-evaluates and reverts the forced profile within seconds; + when a profile also turns the white illuminator on, the light fools the brightness sensor and + the camera oscillates between day and night endlessly. Mode=4 pins the requested profile so + it sticks. Verified against the device: Mode=2 + forced Night reverts to Day in ~8s, while + Mode=4 + forced Night holds indefinitely. + """ + + 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 From 05c118ed83f3f7a73b40548e4bfa321ca4326de9 Mon Sep 17 00:00:00 2001 From: tex mex Date: Wed, 3 Jun 2026 14:23:10 +0000 Subject: [PATCH 3/3] Fix profile mode handling for dual-illuminator cameras by supporting ConfigEx --- custom_components/dahua/__init__.py | 10 ++++++++-- custom_components/dahua/camera.py | 6 +++--- custom_components/dahua/client.py | 23 +++++++++++------------ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/custom_components/dahua/__init__.py b/custom_components/dahua/__init__.py index 5296cb2..92998c7 100755 --- a/custom_components/dahua/__init__.py +++ b/custom_components/dahua/__init__.py @@ -368,10 +368,16 @@ async def _async_update_data(self): 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. + # 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 + 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) diff --git a/custom_components/dahua/camera.py b/custom_components/dahua/camera.py index edaac50..e85b375 100755 --- a/custom_components/dahua/camera.py +++ b/custom_components/dahua/camera.py @@ -334,9 +334,9 @@ async def async_set_video_profile_mode(self, mode: str): 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 (manual). Writing - # Config[0] (the old path) is ignored by these cameras, so the profile never sticks and - # the brightness auto-switch keeps flipping it - see async_set_video_profile_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) diff --git a/custom_components/dahua/client.py b/custom_components/dahua/client.py index 26b250e..5c5087e 100644 --- a/custom_components/dahua/client.py +++ b/custom_components/dahua/client.py @@ -395,20 +395,19 @@ async def async_set_video_profile_mode(self, channel: int, mode: str): async def async_set_video_profile_config_ex(self, channel: int, mode: str): """ - async_set_video_profile_config_ex forces the day/night profile on cameras that expose + 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 ([0,1]) and select the profile - through the ConfigEx string. The integration used to write Config[0], which does nothing - useful here and is ignored by the firmware. - - We must also set VideoInMode.Mode=4 ("manual"), which is exactly what the camera's own web UI - sends when you toggle day/night by hand. If the camera is left on an automatic switch mode - (e.g. Mode=2 = by brightness) it re-evaluates and reverts the forced profile within seconds; - when a profile also turns the white illuminator on, the light fools the brightness sensor and - the camera oscillates between day and night endlessly. Mode=4 pins the requested profile so - it sticks. Verified against the device: Mode=2 + forced Night reverts to Day in ~8s, while - Mode=4 + forced Night holds indefinitely. + 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"