diff --git a/custom_components/dahua/__init__.py b/custom_components/dahua/__init__.py index 984e55e..92998c7 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 @@ -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) @@ -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) @@ -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: @@ -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: @@ -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 diff --git a/custom_components/dahua/camera.py b/custom_components/dahua/camera.py index ced874a..e85b375 100755 --- a/custom_components/dahua/camera.py +++ b/custom_components/dahua/camera.py @@ -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 """ diff --git a/custom_components/dahua/client.py b/custom_components/dahua/client.py index 7d573c8..5c5087e 100644 --- a/custom_components/dahua/client.py +++ b/custom_components/dahua/client.py @@ -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 @@ -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) 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()