Skip to content
Merged

Alpha #782

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
81604ed
Merge pull request #672 from azerty9971/testing
azerty9971 Dec 30, 2025
1e7356c
Merge pull request #695 from azerty9971/testing
azerty9971 Jan 7, 2026
ee08ac2
Merge pull request #730 from azerty9971/testing
azerty9971 Feb 5, 2026
161b7cd
Merge pull request #749 from azerty9971/testing
azerty9971 Feb 10, 2026
567b07a
Merge pull request #752 from azerty9971/testing
azerty9971 Feb 11, 2026
d81ac55
Merge pull request #758 from azerty9971/testing
azerty9971 Feb 11, 2026
0a4c1d7
Update climate.py
azerty9971 Feb 13, 2026
e2ba11a
Update climate.py
azerty9971 Feb 13, 2026
c993771
Update climate.py
azerty9971 Feb 13, 2026
784c9ea
Update climate.py
azerty9971 Feb 13, 2026
d01cd8c
Update climate.py
azerty9971 Feb 13, 2026
9bd6923
Update climate.py
azerty9971 Feb 13, 2026
d299642
Update climate.py
azerty9971 Feb 13, 2026
2536a47
Update climate.py
azerty9971 Feb 13, 2026
2f49e21
Update climate.py
azerty9971 Feb 13, 2026
7de83e7
Update climate.py
azerty9971 Feb 13, 2026
6787a9e
feat: add UI-based per-device target_temperature_step override
retrozenith Feb 18, 2026
0f6edb2
fix: add missing strings.json entries for options flow menu and steps
retrozenith Feb 18, 2026
ba09b30
fix: update translations/en.json with new options flow menu steps
retrozenith Feb 18, 2026
38657b7
Merge pull request #774 from retrozenith/feat/ui-target-temperature-s…
azerty9971 Feb 23, 2026
d8d1306
Merge pull request #778 from azerty9971/main
azerty9971 Feb 23, 2026
7028663
Update entity.py
azerty9971 Feb 23, 2026
bea7438
Initial option flow redesign
azerty9971 Feb 23, 2026
b735b7a
Update config_flow.py
azerty9971 Feb 23, 2026
b464adb
Update config_flow.py
azerty9971 Feb 23, 2026
ffb9a7e
Update config_flow.py
azerty9971 Feb 23, 2026
4f6cfd0
Update config_flow.py
azerty9971 Feb 23, 2026
263f0ba
More work on config flows
azerty9971 Feb 24, 2026
01f150e
Update config_flow.py
azerty9971 Feb 24, 2026
76ee627
Update config_flow.py
azerty9971 Feb 24, 2026
bbb57fb
Update multi_manager.py
azerty9971 Feb 24, 2026
6333f89
Update config_flow.py
azerty9971 Feb 24, 2026
70d823d
Update en.json
azerty9971 Feb 24, 2026
97772a4
Update en.json
azerty9971 Feb 24, 2026
f702f13
Update en.json
azerty9971 Feb 24, 2026
e74442a
Update config_flow.py
azerty9971 Feb 24, 2026
5b5e56d
force IPv4 if we're connecting to something with tuya or tinytuya in …
dziban303 Feb 24, 2026
f70c362
Merge pull request #779 from dziban303/patch
azerty9971 Feb 24, 2026
a2ed539
Merge pull request #780 from azerty9971/main
azerty9971 Feb 24, 2026
978bad0
Update __init__.py
azerty9971 Feb 24, 2026
84844b0
Update __init__.py
azerty9971 Feb 24, 2026
82ac212
Fix for HA2026.3 beta
azerty9971 Feb 27, 2026
6c87a12
Update config_flow.py
azerty9971 Feb 27, 2026
2786f99
Update tuya_integration_imports_no_cc.py
azerty9971 Feb 27, 2026
ebf909d
Make camera fails output debug logs
azerty9971 Feb 27, 2026
978671b
Merge branch 'testing' into alpha
azerty9971 Feb 28, 2026
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
1 change: 0 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
{
"python-envs.pythonProjects": []
}
29 changes: 29 additions & 0 deletions custom_components/xtend_tuya/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,35 @@
from .multi_manager.shared.tuya_patches.tuya_patches import (
XTTuyaPatcher,
)
import socket

# save the original DNS lookup function
_original_getaddrinfo = socket.getaddrinfo


def _getaddrinfo_ipv4_only(host, port, family=0, type=0, proto=0, flags=0):
"""
IPv6 sniper: If the request is going to a tuya server,
force it to use IPv4 (AF_INET) to prevent IPv6 timeout
"""
tuya_hosts = [
"apigw.iotbing.com",
"openapi.tuyaus.com",
"openapi.tuyacn.com",
"openapi.tuyaeu.com",
"openapi.tuyain.com",
"openapi-sg.iotbing.com",
"openapi-ueaz.tuyaus.com",
"openapi-weaz.tuyaeu.com",
]
if host and host in tuya_hosts:
return _original_getaddrinfo(host, port, socket.AF_INET, type, proto, flags)
return _original_getaddrinfo(host, port, family, type, proto, flags)


# Replace the global function with the sniper
socket.getaddrinfo = _getaddrinfo_ipv4_only


# Suppress logs from the library, it logs unneeded on error
logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/xtend_tuya/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ async def stream_source(self) -> str | None:
try:
return await super().stream_source()
except Exception as e:
LOGGER.error(
LOGGER.debug(
f"Error getting stream source for device {self.device.id}: {e}",
stack_info=True,
)
Expand Down
159 changes: 139 additions & 20 deletions custom_components/xtend_tuya/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import collections
from dataclasses import dataclass
from enum import StrEnum
from typing import cast, Self
from typing import Any, cast, Self
from homeassistant.components.climate.const import (
HVACMode,
HVACAction,
Expand Down Expand Up @@ -165,9 +165,21 @@
XTDPCode.POWER2,
)

XT_CLIMATE_HVAC_ACTION_DPCODES: tuple[XTDPCode, ...] = (
XTDPCode.WORK_STATE,
)
XT_CLIMATE_HVAC_ACTION_DPCODES: tuple[XTDPCode, ...] = (XTDPCode.WORK_STATE,)


@dataclass(frozen=True, kw_only=True)
class XTClimateConfigurableProperties:
current_humidity_wrapper: TuyaClimateRoundedIntegerWrapper | None
current_temperature_wrapper: TuyaDPCodeIntegerWrapper | None
fan_mode_wrapper: TuyaDPCodeEnumWrapper | None
preset_wrapper: TuyaDeviceWrapper[str] | None
hvac_mode_wrapper: TuyaDPCodeEnumWrapper | None
hvac_action_wrapper: TuyaDPCodeEnumWrapper | None
set_temperature_wrapper: TuyaDPCodeIntegerWrapper | None
swing_wrapper: TuyaClimateSwingModeWrapper | None
switch_wrapper: TuyaDPCodeBooleanWrapper | None
target_humidity_wrapper: TuyaClimateRoundedIntegerWrapper | None


@dataclass(frozen=True, kw_only=True)
Expand Down Expand Up @@ -230,6 +242,7 @@ def get_entity_instance(
),
}


def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]:
"""Filter TUYA_HVAC_TO_HA modes that are not in the range.

Expand All @@ -245,6 +258,7 @@ def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | No
modes_in_range[key] = None
return modes_in_range


class XTClimatePresetWrapper(TuyaClimatePresetWrapper):
def __init__(self, dpcode: str, type_information: TuyaEnumTypeInformation) -> None:
"""Init _PresetWrapper."""
Expand All @@ -256,10 +270,13 @@ def __init__(self, dpcode: str, type_information: TuyaEnumTypeInformation) -> No

def read_device_status(self, device: TuyaCustomerDevice) -> str | None:
"""Read the device status."""
if (raw := super(TuyaDPCodeEnumWrapper, self).read_device_status(device)) in self.options:
if (
raw := super(TuyaDPCodeEnumWrapper, self).read_device_status(device)
) in self.options:
return raw
return None


class XTClimateHvacModeWrapper(TuyaClimateHvacModeWrapper):
def __init__(self, dpcode: str, type_information: TuyaEnumTypeInformation) -> None:
"""Init _HvacModeWrapper."""
Expand All @@ -268,12 +285,43 @@ def __init__(self, dpcode: str, type_information: TuyaEnumTypeInformation) -> No
self.options = [
ha_mode for ha_mode in self._mappings.values() if ha_mode is not None
]

self.replace_heat_cool_with: HVACMode | None = None

def read_device_status(self, device: TuyaCustomerDevice) -> HVACMode | None:
"""Read the device status."""
if (raw := super(TuyaDPCodeEnumWrapper, self).read_device_status(device)) not in XT_HVAC_TO_HA:
if (
raw := super(TuyaDPCodeEnumWrapper, self).read_device_status(device)
) not in XT_HVAC_TO_HA:
return None
return XT_HVAC_TO_HA[raw]
base_value = XT_HVAC_TO_HA[raw]
if base_value == HVACMode.HEAT_COOL and self.replace_heat_cool_with is not None:
return self.replace_heat_cool_with
return base_value

def remap_heat_cool_based_on_action_wrapper(
self, action_wrapper: TuyaDPCodeEnumWrapper | None
):
if action_wrapper is None:
return
has_heating = False
has_cooling = False
for option in action_wrapper.options:
if option in XT_HVAC_ACTION_TO_HA:
match XT_HVAC_ACTION_TO_HA[option]:
case HVACAction.HEATING:
has_heating = True
case HVACAction.COOLING:
has_cooling = True

if has_heating and has_cooling:
# Device has both cooling and heating, don't change anything
return
if has_heating:
self.replace_heat_cool_with = HVACMode.HEAT

if has_cooling:
self.replace_heat_cool_with = HVACMode.COOL


class XTClimateSwingModeWrapper(TuyaClimateSwingModeWrapper):
@classmethod
Expand Down Expand Up @@ -416,6 +464,20 @@ def async_discover_device(device_map, restrict_dpcode: str | None = None) -> Non
temperature_wrappers = _get_temperature_wrappers(
device, hass.config.units.temperature_unit
)
hvac_action_wrapper = TuyaDPCodeEnumWrapper.find_dpcode(
device,
XT_CLIMATE_HVAC_ACTION_DPCODES, # type: ignore
prefer_function=True,
)
hvac_mode_wrapper = XTClimateHvacModeWrapper.find_dpcode(
device,
XT_CLIMATE_MODE_DPCODES, # type: ignore
prefer_function=True,
)
if hvac_mode_wrapper is not None:
hvac_mode_wrapper.remap_heat_cool_based_on_action_wrapper(
hvac_action_wrapper
)
entities.append(
XTClimateEntity.get_entity_instance(
device_descriptor,
Expand All @@ -436,16 +498,8 @@ def async_discover_device(device_map, restrict_dpcode: str | None = None) -> Non
XT_CLIMATE_MODE_DPCODES,
prefer_function=True,
),
hvac_mode_wrapper=XTClimateHvacModeWrapper.find_dpcode(
device,
XT_CLIMATE_MODE_DPCODES, # type: ignore
prefer_function=True,
),
hvac_action_wrapper=TuyaDPCodeEnumWrapper.find_dpcode(
device,
XT_CLIMATE_HVAC_ACTION_DPCODES, # type: ignore
prefer_function=True,
),
hvac_mode_wrapper=hvac_mode_wrapper,
hvac_action_wrapper=hvac_action_wrapper,
set_temperature_wrapper=temperature_wrappers[1],
swing_wrapper=XTClimateSwingModeWrapper.find_dpcode(
device,
Expand Down Expand Up @@ -532,15 +586,66 @@ def __init__(
self.device_manager = device_manager
self.entity_description = description
self._hvac_action_wrapper = hvac_action_wrapper
self.device.set_preference(
f"{XTDevice.XTDevicePreference.CLIMATE_DEVICE_ENTITY}",
self,
)

# Re-Determine HVAC modes
self._attr_hvac_modes = []
if hvac_mode_wrapper:
self._attr_hvac_modes = [HVACMode.OFF]
for mode in hvac_mode_wrapper.options:
if mode != HVACMode.OFF:
# OFF is always added first
self._attr_hvac_modes.append(HVACMode(mode))

elif switch_wrapper:
self._attr_hvac_modes = [
HVACMode.OFF,
description.switch_only_hvac_mode,
]

# Determine preset modes (ignore if empty options)
if preset_wrapper and preset_wrapper.options:
for option in preset_wrapper.options:
if hvac_mode := XT_HVAC_TO_HA.get(option):
if hvac_mode not in self._attr_hvac_modes:
self._attr_hvac_modes.append(hvac_mode)
if isinstance(self._hvac_mode_wrapper, XTClimateHvacModeWrapper):
if self._hvac_mode_wrapper.replace_heat_cool_with is not None:
if HVACMode.HEAT_COOL in self._attr_hvac_modes:
self._attr_hvac_modes.remove(HVACMode.HEAT_COOL)
if (
self._hvac_mode_wrapper.replace_heat_cool_with
not in self._attr_hvac_modes
):
self._attr_hvac_modes.append(
self._hvac_mode_wrapper.replace_heat_cool_with
)

def get_configurable_properties(self) -> Any | None:
return XTClimateConfigurableProperties(
current_humidity_wrapper=self._current_humidity_wrapper, # type: ignore
current_temperature_wrapper=self._current_temperature, # type: ignore
fan_mode_wrapper=self._fan_mode_wrapper, # type: ignore
preset_wrapper=self._preset_wrapper,
hvac_mode_wrapper=self._hvac_mode_wrapper, # type: ignore
hvac_action_wrapper=self._hvac_action_wrapper,
set_temperature_wrapper=self._set_temperature, # type: ignore
swing_wrapper=self._swing_wrapper, # type: ignore
switch_wrapper=self._switch_wrapper, # type: ignore
target_humidity_wrapper=self._target_humidity_wrapper, # type: ignore
)

@property
def hvac_action(self) -> HVACAction | None: # type: ignore
def hvac_action(self) -> HVACAction | None: # type: ignore
"""Return the current running hvac operation if supported."""
raw_value = self._read_wrapper(self._hvac_action_wrapper)
if raw_value in XT_HVAC_ACTION_TO_HA:
return XT_HVAC_ACTION_TO_HA[raw_value]
return self._attr_hvac_action

@property
def preset_mode(self) -> str | None:
"""Return preset mode."""
Expand Down Expand Up @@ -600,3 +705,17 @@ def get_entity_instance(
target_humidity_wrapper=target_humidity_wrapper,
temperature_unit=temperature_unit,
)

# @property
# def target_temperature_step(self) -> float | None:
# """Return the target temperature step to use."""
# if (
# self.device_manager.config_entry.options
# and "device_settings" in self.device_manager.config_entry.options
# and self.device.id
# in self.device_manager.config_entry.options["device_settings"]
# ):
# return self.device_manager.config_entry.options["device_settings"][
# self.device.id
# ].get("target_temperature_step")
# return super().target_temperature_step
Loading