𝐂𝐫𝐞𝐝𝐢𝐭𝐬
43 |
44 |
45 | [rospogrigio](https://github.com/rospogrigio), the original maintainer of LocalTuya. This fork was created when the [upstream](https://github.com/rospogrigio/localtuya) version was at `v5.2.1`.
46 |
47 | [NameLessJedi](https://github.com/NameLessJedi/localtuya-homeassistant) and [mileperhour](https://github.com/mileperhour/localtuya-homeassistant) being the major sources of inspiration, and whose code for switches is substantially unchanged.
48 |
49 | [TradeFace](https://github.com/TradeFace), for being the only one to provide the correct code for communication with the cover (in particular, the 0x0d command for the status instead of the 0x0a, and related needs such as double reply to be received):
50 |
51 | sean6541, for the working (standard) Python Handler for Tuya devices.
52 |
53 | [jasonacox](https://github.com/jasonacox), for the [TinyTuya](https://github.com/jasonacox/tinytuya) project from where I got big help and references to upgrade integration.
54 |
55 | [uzlonewolf](https://github.com/uzlonewolf), for maintaining TinyTuya who improved the tool so much and introduced new features like new protocols, etc.
56 |
57 | [postlund](https://github.com/postlund), for the ideas, for coding 95% of the refactoring and boosting the quality of the upstream repository.
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/custom_components/localtuya/alarm_control_panel.py:
--------------------------------------------------------------------------------
1 | """Platform to present any Tuya DP as a Alarm."""
2 |
3 | from enum import StrEnum
4 | import logging
5 | from functools import partial
6 | from .config_flow import col_to_select
7 |
8 | import voluptuous as vol
9 | from homeassistant.helpers.selector import ObjectSelector
10 | from homeassistant.components.alarm_control_panel import (
11 | DOMAIN,
12 | AlarmControlPanelEntity,
13 | CodeFormat,
14 | AlarmControlPanelEntityFeature,
15 | AlarmControlPanelState,
16 | )
17 |
18 | from .entity import LocalTuyaEntity, async_setup_entry
19 | from .const import CONF_ALARM_SUPPORTED_STATES, DictSelector
20 |
21 | _LOGGER = logging.getLogger(__name__)
22 |
23 | DEFAULT_PRECISION = 2
24 |
25 |
26 | class TuyaMode(StrEnum):
27 | DISARMED = "disarmed"
28 | ARM = "arm"
29 | HOME = "home"
30 | SOS = "sos"
31 |
32 |
33 | DEFAULT_SUPPORTED_MODES = {
34 | AlarmControlPanelState.DISARMED: TuyaMode.DISARMED,
35 | AlarmControlPanelState.ARMED_AWAY: TuyaMode.ARM,
36 | AlarmControlPanelState.ARMED_HOME: TuyaMode.HOME,
37 | AlarmControlPanelState.TRIGGERED: TuyaMode.SOS,
38 | }
39 |
40 |
41 | def flow_schema(dps):
42 | """Return schema used in config flow."""
43 | return {
44 | vol.Optional(
45 | CONF_ALARM_SUPPORTED_STATES, default=DEFAULT_SUPPORTED_MODES
46 | ): ObjectSelector(),
47 | }
48 |
49 |
50 | class LocalTuyaAlarmControlPanel(LocalTuyaEntity, AlarmControlPanelEntity):
51 | """Representation of a Tuya Alarm."""
52 |
53 | _supported_modes = {}
54 |
55 | def __init__(
56 | self,
57 | device,
58 | config_entry,
59 | dpid,
60 | **kwargs,
61 | ):
62 | """Initialize the Tuya Alarm."""
63 | super().__init__(device, config_entry, dpid, _LOGGER, **kwargs)
64 | self._state = None
65 | self._changed_by = None
66 |
67 | # supported modes
68 | if supported_modes := self._config.get(CONF_ALARM_SUPPORTED_STATES, {}):
69 | # Key is HA state and value is Tuya State.
70 | if AlarmControlPanelState.ARMED_AWAY in supported_modes:
71 | self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
72 | if AlarmControlPanelState.ARMED_HOME in supported_modes:
73 | self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
74 | if AlarmControlPanelState.TRIGGERED in supported_modes:
75 | self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
76 |
77 | self._states = DictSelector(supported_modes, reverse=True)
78 |
79 | @property
80 | def alarm_state(self) -> AlarmControlPanelState | None:
81 | """Return the state of the device."""
82 | return self._states.to_ha(self._state, None)
83 |
84 | @property
85 | def code_format(self) -> CodeFormat | None:
86 | """Code format or None if no code is required."""
87 | return None # self._attr_code_format
88 |
89 | @property
90 | def changed_by(self) -> str | None:
91 | """Last change triggered by."""
92 | return None # self._attr_changed_by
93 |
94 | @property
95 | def code_arm_required(self) -> bool:
96 | """Whether the code is required for arm actions."""
97 | return True # self._attr_code_arm_required
98 |
99 | async def async_alarm_disarm(self, code: str | None = None) -> None:
100 | """Send disarm command."""
101 | state = self._states.to_tuya(AlarmControlPanelState.DISARMED)
102 | await self._device.set_dp(state, self._dp_id)
103 |
104 | async def async_alarm_arm_home(self, code: str | None = None) -> None:
105 | """Send arm home command."""
106 | state = self._states.to_tuya(AlarmControlPanelState.ARMED_HOME)
107 | await self._device.set_dp(state, self._dp_id)
108 |
109 | async def async_alarm_arm_away(self, code: str | None = None) -> None:
110 | """Send arm away command."""
111 | state = self._states.to_tuya(AlarmControlPanelState.ARMED_AWAY)
112 | await self._device.set_dp(state, self._dp_id)
113 |
114 | async def async_alarm_trigger(self, code: str | None = None) -> None:
115 | """Send alarm trigger command."""
116 | state = self._states.to_tuya(AlarmControlPanelState.TRIGGERED)
117 | await self._device.set_dp(state, self._dp_id)
118 |
119 | def status_updated(self):
120 | """Device status was updated."""
121 | super().status_updated()
122 |
123 | # No need to restore state for a AlarmControlPanel
124 | async def restore_state_when_connected(self):
125 | """Do nothing for a AlarmControlPanel."""
126 | return
127 |
128 |
129 | async_setup_entry = partial(
130 | async_setup_entry, DOMAIN, LocalTuyaAlarmControlPanel, flow_schema
131 | )
132 |
--------------------------------------------------------------------------------
/custom_components/localtuya/binary_sensor.py:
--------------------------------------------------------------------------------
1 | """Platform to present any Tuya DP as a binary sensor."""
2 |
3 | import logging
4 | import voluptuous as vol
5 |
6 | from functools import partial
7 |
8 | from homeassistant.helpers.selector import NumberSelector, NumberSelectorConfig
9 | from homeassistant.helpers.event import async_call_later
10 | from homeassistant.core import callback, CALLBACK_TYPE
11 | from homeassistant.const import CONF_DEVICE_CLASS
12 | from homeassistant.components.binary_sensor import (
13 | DEVICE_CLASSES_SCHEMA,
14 | DOMAIN,
15 | BinarySensorEntity,
16 | )
17 |
18 | from .entity import LocalTuyaEntity, async_setup_entry
19 | from .const import CONF_STATE_ON, CONF_RESET_TIMER
20 |
21 |
22 | CONF_STATE_OFF = "state_off"
23 |
24 | _LOGGER = logging.getLogger(__name__)
25 |
26 |
27 | def flow_schema(dps):
28 | """Return schema used in config flow."""
29 | return {
30 | vol.Required(CONF_STATE_ON, default="True"): str,
31 | # vol.Required(CONF_STATE_OFF, default="False"): str,
32 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
33 | vol.Optional(CONF_RESET_TIMER, default=0): NumberSelector(
34 | NumberSelectorConfig(min=0, unit_of_measurement="Seconds", mode="box")
35 | ),
36 | }
37 |
38 |
39 | class LocalTuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity):
40 | """Representation of a Tuya binary sensor."""
41 |
42 | def __init__(
43 | self,
44 | device,
45 | config_entry,
46 | sensorid,
47 | **kwargs,
48 | ):
49 | """Initialize the Tuya binary sensor."""
50 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
51 | self._is_on = False
52 |
53 | self._reset_timer: float = self._config.get(CONF_RESET_TIMER, 0)
54 | self._reset_timer_interval: CALLBACK_TYPE | None = None
55 |
56 | @property
57 | def is_on(self):
58 | """Return sensor state."""
59 | return self._is_on
60 |
61 | def status_updated(self):
62 | """Device status was updated."""
63 | super().status_updated()
64 |
65 | state = str(self.dp_value(self._dp_id)).lower()
66 | # users may set wrong on states, But we assume that must devices use this on states.
67 | possible_on_states = ["true", "1", "pir", "on"]
68 | if state == self._config[CONF_STATE_ON].lower() or state in possible_on_states:
69 | self._is_on = True
70 | else:
71 | self._is_on = False
72 |
73 | if self._reset_timer and self._is_on:
74 | if self._reset_timer_interval is not None:
75 | self._reset_timer_interval()
76 | self._reset_timer_interval = None
77 |
78 | @callback
79 | def async_reset_state(now):
80 | """Set the state of the entity to off."""
81 | # "_update_handler" logic, if status hasn't changed "status_updated" will not be called.
82 | # Maybe we can find better solution then this workaround?
83 | self._status[self._dp_id] = "reset_state_binary_sensor"
84 | self._is_on = False
85 | self.async_write_ha_state()
86 |
87 | self._reset_timer_interval = async_call_later(
88 | self.hass, self._reset_timer, async_reset_state
89 | )
90 |
91 | # No need to restore state for a sensor
92 | async def restore_state_when_connected(self):
93 | """Do nothing for a sensor."""
94 | return
95 |
96 |
97 | async_setup_entry = partial(
98 | async_setup_entry, DOMAIN, LocalTuyaBinarySensor, flow_schema
99 | )
100 |
--------------------------------------------------------------------------------
/custom_components/localtuya/button.py:
--------------------------------------------------------------------------------
1 | """Platform to locally control Tuya-based button devices."""
2 |
3 | import logging
4 | from functools import partial
5 |
6 | import voluptuous as vol
7 | from homeassistant.components.button import DOMAIN, ButtonEntity
8 |
9 | from .entity import LocalTuyaEntity, async_setup_entry
10 | from .const import CONF_PASSIVE_ENTITY
11 |
12 | _LOGGER = logging.getLogger(__name__)
13 |
14 |
15 | def flow_schema(dps):
16 | """Return schema used in config flow."""
17 | return {
18 | # vol.Required(CONF_PASSIVE_ENTITY): bool,
19 | }
20 |
21 |
22 | class LocalTuyaButton(LocalTuyaEntity, ButtonEntity):
23 | """Representation of a Tuya button."""
24 |
25 | def __init__(
26 | self,
27 | device,
28 | config_entry,
29 | buttonid,
30 | **kwargs,
31 | ):
32 | """Initialize the Tuya button."""
33 | super().__init__(device, config_entry, buttonid, _LOGGER, **kwargs)
34 | self._state = None
35 |
36 | async def async_press(self):
37 | """Press the button."""
38 | await self._device.set_dp(True, self._dp_id)
39 |
40 |
41 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaButton, flow_schema)
42 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/__init__.py:
--------------------------------------------------------------------------------
1 | """The core of localtuya"""
2 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/alarm_control_panels.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 | Credits: official HA Tuya integration.
5 | Modified by: xZetsubou
6 | """
7 |
8 | from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE
9 | from ...const import CONF_ALARM_SUPPORTED_STATES
10 | from homeassistant.components.alarm_control_panel import AlarmControlPanelState
11 |
12 | MAP_ALARM_STATES = {
13 | "disarmed": AlarmControlPanelState.DISARMED,
14 | "arm": AlarmControlPanelState.ARMED_AWAY,
15 | "home": AlarmControlPanelState.ARMED_HOME,
16 | "sos": AlarmControlPanelState.TRIGGERED,
17 | }
18 |
19 |
20 | def localtuya_alarm(states: dict):
21 | """Generate localtuya alarm configs"""
22 | data = {
23 | CONF_ALARM_SUPPORTED_STATES: CLOUD_VALUE(
24 | states, "id", "range", dict, MAP_ALARM_STATES, True
25 | ),
26 | }
27 | return data
28 |
29 |
30 | # All descriptions can be found here:
31 | # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
32 | ALARMS: dict[str, tuple[LocalTuyaEntity, ...]] = {
33 | # Alarm Host
34 | # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf
35 | "mal": (
36 | LocalTuyaEntity(
37 | id=DPCode.MASTER_MODE,
38 | custom_configs=localtuya_alarm(
39 | {
40 | AlarmControlPanelState.DISARMED: "disarmed",
41 | AlarmControlPanelState.ARMED_AWAY: "arm",
42 | AlarmControlPanelState.ARMED_HOME: "home",
43 | AlarmControlPanelState.TRIGGERED: "sos",
44 | }
45 | ),
46 | ),
47 | ),
48 | }
49 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/buttons.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 |
5 | Credits: official HA Tuya integration.
6 | Modified by: xZetsubou
7 | """
8 |
9 | from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory
10 |
11 | BUTTONS: dict[str, tuple[LocalTuyaEntity, ...]] = {
12 | # Scene Switch
13 | # https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8
14 | "cjkg": (
15 | LocalTuyaEntity(
16 | id=DPCode.SCENE_1,
17 | name="Scene 1",
18 | icon="mdi:palette",
19 | ),
20 | LocalTuyaEntity(
21 | id=DPCode.SCENE_2,
22 | name="Scene 2",
23 | icon="mdi:palette",
24 | ),
25 | LocalTuyaEntity(
26 | id=DPCode.SCENE_3,
27 | name="Scene 3",
28 | icon="mdi:palette",
29 | ),
30 | LocalTuyaEntity(
31 | id=DPCode.SCENE_4,
32 | name="Scene 4",
33 | icon="mdi:palette",
34 | ),
35 | LocalTuyaEntity(
36 | id=DPCode.SCENE_5,
37 | name="Scene 5",
38 | icon="mdi:palette",
39 | ),
40 | LocalTuyaEntity(
41 | id=DPCode.SCENE_6,
42 | name="Scene 6",
43 | icon="mdi:palette",
44 | ),
45 | LocalTuyaEntity(
46 | id=DPCode.SCENE_7,
47 | name="Scene 7",
48 | icon="mdi:palette",
49 | ),
50 | LocalTuyaEntity(
51 | id=DPCode.SCENE_8,
52 | name="Scene 8",
53 | icon="mdi:palette",
54 | ),
55 | LocalTuyaEntity(
56 | id=DPCode.SCENE_9,
57 | name="Scene 9",
58 | icon="mdi:palette",
59 | ),
60 | LocalTuyaEntity(
61 | id=DPCode.SCENE_10,
62 | name="Scene 10",
63 | icon="mdi:palette",
64 | ),
65 | LocalTuyaEntity(
66 | id=DPCode.SCENE_11,
67 | name="Scene 11",
68 | icon="mdi:palette",
69 | ),
70 | LocalTuyaEntity(
71 | id=DPCode.SCENE_12,
72 | name="Scene 12",
73 | icon="mdi:palette",
74 | ),
75 | LocalTuyaEntity(
76 | id=DPCode.SCENE_13,
77 | name="Scene 13",
78 | icon="mdi:palette",
79 | ),
80 | LocalTuyaEntity(
81 | id=DPCode.SCENE_14,
82 | name="Scene 14",
83 | icon="mdi:palette",
84 | ),
85 | LocalTuyaEntity(
86 | id=DPCode.SCENE_15,
87 | name="Scene 15",
88 | icon="mdi:palette",
89 | ),
90 | LocalTuyaEntity(
91 | id=DPCode.SCENE_16,
92 | name="Scene 16",
93 | icon="mdi:palette",
94 | ),
95 | LocalTuyaEntity(
96 | id=DPCode.SCENE_17,
97 | name="Scene 17",
98 | icon="mdi:palette",
99 | ),
100 | LocalTuyaEntity(
101 | id=DPCode.SCENE_18,
102 | name="Scene 18",
103 | icon="mdi:palette",
104 | ),
105 | LocalTuyaEntity(
106 | id=DPCode.SCENE_18,
107 | name="Scene 18",
108 | icon="mdi:palette",
109 | ),
110 | LocalTuyaEntity(
111 | id=DPCode.SCENE_19,
112 | name="Scene 19",
113 | icon="mdi:palette",
114 | ),
115 | LocalTuyaEntity(
116 | id=DPCode.SCENE_20,
117 | name="Scene 20",
118 | icon="mdi:palette",
119 | ),
120 | ),
121 | # Curtain
122 | # Note: Multiple curtains isn't documented
123 | # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df
124 | "cl": (
125 | LocalTuyaEntity(
126 | id=DPCode.REMOTE_REGISTER,
127 | name="Pair Remote",
128 | icon="mdi:remote",
129 | entity_category=EntityCategory.CONFIG,
130 | ),
131 | ),
132 | # Smart Pet Feeder
133 | # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
134 | "cwwsq": (
135 | LocalTuyaEntity(
136 | id=DPCode.FACTORY_RESET,
137 | name="Factory Reset",
138 | icon="mdi:cog-counterclockwise",
139 | entity_category=EntityCategory.CONFIG,
140 | ),
141 | ),
142 | # Smart Pet Feeder
143 | # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
144 | "cwwsq": (
145 | LocalTuyaEntity(
146 | id=DPCode.FACTORY_RESET,
147 | name="Factory Reset",
148 | icon="mdi:cog-counterclockwise",
149 | entity_category=EntityCategory.CONFIG,
150 | ),
151 | ),
152 | # Cat litter box
153 | # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit
154 | "msp": (
155 | LocalTuyaEntity(
156 | id=DPCode.FACTORY_RESET,
157 | name="Factory Reset",
158 | icon="mdi:restore",
159 | entity_category=EntityCategory.CONFIG,
160 | ),
161 | LocalTuyaEntity(
162 | id=DPCode.REBOOT,
163 | name="Reboot",
164 | icon="mdi:restart",
165 | entity_category=EntityCategory.CONFIG,
166 | ),
167 | ),
168 | # Robot Vacuum
169 | # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
170 | "sd": (
171 | LocalTuyaEntity(
172 | id=DPCode.RESET_DUSTER_CLOTH,
173 | name="Reset Duster Cloth",
174 | icon="mdi:restart",
175 | entity_category=EntityCategory.CONFIG,
176 | ),
177 | LocalTuyaEntity(
178 | id=DPCode.RESET_EDGE_BRUSH,
179 | name="Reset Edge Brush",
180 | icon="mdi:restart",
181 | entity_category=EntityCategory.CONFIG,
182 | ),
183 | LocalTuyaEntity(
184 | id=DPCode.RESET_FILTER,
185 | name="Reset Filter",
186 | icon="mdi:air-filter",
187 | entity_category=EntityCategory.CONFIG,
188 | ),
189 | LocalTuyaEntity(
190 | id=DPCode.RESET_MAP,
191 | name="Reset Map",
192 | icon="mdi:map-marker-remove",
193 | entity_category=EntityCategory.CONFIG,
194 | ),
195 | LocalTuyaEntity(
196 | id=DPCode.RESET_ROLL_BRUSH,
197 | name="Reset Roll Brush",
198 | icon="mdi:restart",
199 | entity_category=EntityCategory.CONFIG,
200 | ),
201 | ),
202 | # Wake Up Light II
203 | # Not documented
204 | "hxd": (
205 | LocalTuyaEntity(
206 | id=DPCode.SWITCH_USB6,
207 | name="Snooze",
208 | icon="mdi:sleep",
209 | ),
210 | ),
211 | "cz": (
212 | LocalTuyaEntity(
213 | id=DPCode.CLEAR_ENERGY,
214 | name="Clear Energy",
215 | icon="mdi:lightning-bolt-circle",
216 | entity_category=EntityCategory.CONFIG,
217 | ),
218 | ),
219 | # EV Charcher
220 | # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm
221 | "qccdz": (
222 | LocalTuyaEntity(
223 | id=DPCode.CLEAR_ENERGY,
224 | name="Clear Energy",
225 | icon="mdi:lightning-bolt-circle",
226 | entity_category=EntityCategory.CONFIG,
227 | ),
228 | ),
229 | }
230 |
231 | # Wireless Switch # also can come as knob switch.
232 | # https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5
233 | BUTTONS["wxkg"] = BUTTONS["cjkg"]
234 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/covers.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 | Credits: official HA Tuya integration.
5 | Modified by: xZetsubou
6 | """
7 |
8 | from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory
9 | from homeassistant.components.cover import CoverDeviceClass
10 |
11 | # from const.py this is temporarily.
12 | CONF_COMMANDS_SET = "commands_set"
13 | CONF_POSITIONING_MODE = "positioning_mode"
14 | CONF_CURRENT_POSITION_DP = "current_position_dp"
15 | CONF_SET_POSITION_DP = "set_position_dp"
16 | CONF_POSITION_INVERTED = "position_inverted"
17 | CONF_SPAN_TIME = "span_time"
18 |
19 |
20 | def localtuya_cover(cmd_set, position_mode=None, inverted=False, timed=25):
21 | """Define localtuya cover configs"""
22 | data = {
23 | CONF_COMMANDS_SET: cmd_set,
24 | CONF_POSITIONING_MODE: position_mode,
25 | CONF_POSITION_INVERTED: inverted,
26 | CONF_SPAN_TIME: timed,
27 | }
28 | return data
29 |
30 |
31 | COVERS: dict[str, tuple[LocalTuyaEntity, ...]] = {
32 | # Curtain
33 | # Note: Multiple curtains isn't documented
34 | # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df
35 | "cl": (
36 | LocalTuyaEntity(
37 | id=DPCode.CONTROL,
38 | name="Curtain",
39 | custom_configs=localtuya_cover("open_close_stop", "position"),
40 | current_state=DPCode.SITUATION_SET,
41 | current_position_dp=(DPCode.PERCENT_STATE, DPCode.PERCENT_CONTROL),
42 | set_position_dp=DPCode.PERCENT_CONTROL,
43 | ),
44 | LocalTuyaEntity(
45 | id=DPCode.CONTROL_2,
46 | name="Curtain 2",
47 | custom_configs=localtuya_cover("open_close_stop", "position"),
48 | current_position_dp=(DPCode.PERCENT_STATE_2, DPCode.PERCENT_CONTROL_2),
49 | set_position_dp=DPCode.PERCENT_CONTROL_2,
50 | device_class=CoverDeviceClass.CURTAIN,
51 | ),
52 | LocalTuyaEntity(
53 | id=DPCode.CONTROL_3,
54 | name="Curtain 3",
55 | custom_configs=localtuya_cover("open_close_stop", "position"),
56 | current_position_dp=(DPCode.PERCENT_STATE_3, DPCode.PERCENT_CONTROL_3),
57 | set_position_dp=DPCode.PERCENT_CONTROL_3,
58 | device_class=CoverDeviceClass.CURTAIN,
59 | ),
60 | LocalTuyaEntity(
61 | id=DPCode.CONTROL_4,
62 | name="Curtain 4",
63 | custom_configs=localtuya_cover("open_close_stop", "position"),
64 | current_position_dp=(DPCode.PERCENT_STATE_4, DPCode.PERCENT_CONTROL_4),
65 | set_position_dp=DPCode.PERCENT_CONTROL_4,
66 | device_class=CoverDeviceClass.CURTAIN,
67 | ),
68 | LocalTuyaEntity(
69 | id=DPCode.MACH_OPERATE,
70 | name="Curtain",
71 | custom_configs=localtuya_cover("fz_zz_stop", "position"),
72 | current_position_dp=DPCode.POSITION,
73 | set_position_dp=DPCode.POSITION,
74 | device_class=CoverDeviceClass.CURTAIN,
75 | ),
76 | # switch_1 is an undocumented code that behaves identically to control
77 | # It is used by the Kogan Smart Blinds Driver
78 | LocalTuyaEntity(
79 | id=DPCode.SWITCH_1,
80 | name="Blind",
81 | custom_configs=localtuya_cover("open_close_stop", "position"),
82 | current_position_dp=DPCode.PERCENT_CONTROL,
83 | set_position_dp=DPCode.PERCENT_CONTROL,
84 | device_class=CoverDeviceClass.BLIND,
85 | ),
86 | ),
87 | # Garage Door Opener
88 | # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee
89 | "ckmkzq": (
90 | LocalTuyaEntity(
91 | id=DPCode.SWITCH_1,
92 | name="Door",
93 | custom_configs=localtuya_cover("open_close_stop", "none", True),
94 | current_position_dp=DPCode.DOORCONTACT_STATE,
95 | device_class=CoverDeviceClass.GARAGE,
96 | ),
97 | LocalTuyaEntity(
98 | id=DPCode.SWITCH_2,
99 | name="Door 2",
100 | custom_configs=localtuya_cover("open_close_stop", "none", True),
101 | current_position_dp=DPCode.DOORCONTACT_STATE_2,
102 | device_class=CoverDeviceClass.GARAGE,
103 | ),
104 | LocalTuyaEntity(
105 | id=DPCode.SWITCH_3,
106 | name="Door 3",
107 | custom_configs=localtuya_cover("open_close_stop", "none", True),
108 | current_position_dp=DPCode.DOORCONTACT_STATE_3,
109 | device_class=CoverDeviceClass.GARAGE,
110 | ),
111 | ),
112 | # Curtain Switch
113 | # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39
114 | "clkg": (
115 | LocalTuyaEntity(
116 | id=DPCode.CONTROL,
117 | name="Curtain",
118 | custom_configs=localtuya_cover("open_close_stop", "position"),
119 | current_position_dp=DPCode.PERCENT_CONTROL,
120 | set_position_dp=DPCode.PERCENT_CONTROL,
121 | device_class=CoverDeviceClass.CURTAIN,
122 | ),
123 | LocalTuyaEntity(
124 | id=DPCode.CONTROL_2,
125 | name="Curtain 2",
126 | custom_configs=localtuya_cover("open_close_stop", "position"),
127 | current_position_dp=DPCode.PERCENT_CONTROL_2,
128 | set_position_dp=DPCode.PERCENT_CONTROL_2,
129 | device_class=CoverDeviceClass.CURTAIN,
130 | ),
131 | ),
132 | # Curtain Robot
133 | # Note: Not documented
134 | "jdcljqr": (
135 | LocalTuyaEntity(
136 | id=DPCode.CONTROL,
137 | name="Curtain",
138 | custom_configs=localtuya_cover("open_close_stop", "position"),
139 | current_position_dp=DPCode.PERCENT_STATE,
140 | set_position_dp=DPCode.PERCENT_CONTROL,
141 | device_class=CoverDeviceClass.CURTAIN,
142 | ),
143 | ),
144 | }
145 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/fans.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 | Credits: official HA Tuya integration.
5 | Modified by: xZetsubou
6 | """
7 |
8 | from .base import (
9 | DPCode,
10 | LocalTuyaEntity,
11 | CONF_DEVICE_CLASS,
12 | EntityCategory,
13 | CLOUD_VALUE,
14 | )
15 | from homeassistant.components.fan import DIRECTION_FORWARD, DIRECTION_REVERSE
16 |
17 | # from const.py this is temporarily
18 | CONF_FAN_SPEED_CONTROL = "fan_speed_control"
19 | CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control"
20 | CONF_FAN_DIRECTION = "fan_direction"
21 |
22 | CONF_FAN_SPEED_MIN = "fan_speed_min"
23 | CONF_FAN_SPEED_MAX = "fan_speed_max"
24 | CONF_FAN_DIRECTION_FWD = "fan_direction_forward"
25 | CONF_FAN_DIRECTION_REV = "fan_direction_reverse"
26 | CONF_FAN_DPS_TYPE = "fan_dps_type"
27 | CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list"
28 |
29 | FAN_SPEED_DP = (
30 | DPCode.FAN_SPEED_PERCENT,
31 | DPCode.FAN_SPEED,
32 | DPCode.SPEED,
33 | DPCode.FAN_SPEED_ENUM,
34 | )
35 |
36 | FANS_OSCILLATING = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL)
37 |
38 |
39 | def localtuya_fan(fwd, rev, min_speed, max_speed, order, dp_type):
40 | """Define localtuya fan configs"""
41 | data = {
42 | CONF_FAN_DIRECTION_FWD: fwd,
43 | CONF_FAN_DIRECTION_REV: rev,
44 | CONF_FAN_SPEED_MIN: CLOUD_VALUE(min_speed, CONF_FAN_SPEED_CONTROL, "min"),
45 | CONF_FAN_SPEED_MAX: CLOUD_VALUE(max_speed, CONF_FAN_SPEED_CONTROL, "max"),
46 | CONF_FAN_ORDERED_LIST: CLOUD_VALUE(order, CONF_FAN_SPEED_CONTROL, "range", str),
47 | CONF_FAN_DPS_TYPE: dp_type,
48 | }
49 | return data
50 |
51 |
52 | FANS: dict[str, tuple[LocalTuyaEntity, ...]] = {
53 | # Fan
54 | "fs": (
55 | LocalTuyaEntity(
56 | id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH),
57 | name="Fan",
58 | icon="mdi:fan",
59 | fan_speed_control=FAN_SPEED_DP,
60 | fan_direction=DPCode.FAN_DIRECTION,
61 | fan_oscillating_control=FANS_OSCILLATING,
62 | custom_configs=localtuya_fan(
63 | DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int"
64 | ),
65 | ),
66 | ),
67 | # Normal switch with fan controller.
68 | "tdq": (
69 | LocalTuyaEntity(
70 | id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH),
71 | name="Fan",
72 | icon="mdi:fan",
73 | fan_speed_control=FAN_SPEED_DP,
74 | fan_direction=DPCode.FAN_DIRECTION,
75 | fan_oscillating_control=FANS_OSCILLATING,
76 | custom_configs=localtuya_fan(
77 | DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int"
78 | ),
79 | ),
80 | ),
81 | }
82 | # Fan with Light
83 | FANS["fsd"] = FANS["fs"]
84 | # Fan wall switch
85 | FANS["fskg"] = FANS["fs"]
86 | # Air Purifier
87 | FANS["kj"] = FANS["fs"]
88 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/humidifiers.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 |
5 | Credits: official HA Tuya integration.
6 | Modified by: xZetsubou
7 | """
8 |
9 | from .base import (
10 | DPCode,
11 | LocalTuyaEntity,
12 | CONF_DEVICE_CLASS,
13 | EntityCategory,
14 | CLOUD_VALUE,
15 | )
16 | from homeassistant.components.humidifier import (
17 | HumidifierDeviceClass,
18 | ATTR_MAX_HUMIDITY,
19 | ATTR_MIN_HUMIDITY,
20 | DEFAULT_MAX_HUMIDITY,
21 | DEFAULT_MIN_HUMIDITY,
22 | )
23 |
24 | CONF_HUMIDIFIER_SET_HUMIDITY_DP = "humidifier_set_humidity_dp"
25 | CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP = "humidifier_current_humidity_dp"
26 | CONF_HUMIDIFIER_MODE_DP = "humidifier_mode_dp"
27 | CONF_HUMIDIFIER_AVAILABLE_MODES = "humidifier_available_modes"
28 |
29 |
30 | def localtuya_humidifier(modes):
31 | """Define localtuya fan configs"""
32 |
33 | data = {
34 | CONF_HUMIDIFIER_AVAILABLE_MODES: CLOUD_VALUE(
35 | modes, CONF_HUMIDIFIER_MODE_DP, "range", dict
36 | ),
37 | ATTR_MIN_HUMIDITY: CLOUD_VALUE(
38 | DEFAULT_MIN_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "min"
39 | ),
40 | ATTR_MAX_HUMIDITY: CLOUD_VALUE(
41 | DEFAULT_MAX_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "max"
42 | ),
43 | }
44 | return data
45 |
46 |
47 | HUMIDIFIERS: dict[str, tuple[LocalTuyaEntity, ...]] = {
48 | # Dehumidifier
49 | # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
50 | "cs": (
51 | LocalTuyaEntity(
52 | id=DPCode.SWITCH,
53 | humidifier_current_humidity_dp=DPCode.HUMIDITY_INDOOR,
54 | humidifier_set_humidity_dp=DPCode.DEHUMIDITY_SET_VALUE,
55 | humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE),
56 | custom_configs=localtuya_humidifier(
57 | {
58 | "dehumidify": "Dehumidify",
59 | "drying": "Drying",
60 | "continuous": "Continuous",
61 | }
62 | ),
63 | device_class=HumidifierDeviceClass.DEHUMIDIFIER,
64 | ),
65 | ),
66 | # Humidifier
67 | # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
68 | "jsq": (
69 | LocalTuyaEntity(
70 | id=DPCode.SWITCH,
71 | humidifier_current_humidity_dp=DPCode.HUMIDITY_CURRENT,
72 | humidifier_set_humidity_dp=DPCode.HUMIDITY_SET,
73 | humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE),
74 | custom_configs=localtuya_humidifier(
75 | {
76 | "large": "Large",
77 | "middle": "Middle",
78 | "small": "Small",
79 | }
80 | ),
81 | device_class=HumidifierDeviceClass.HUMIDIFIER,
82 | ),
83 | ),
84 | }
85 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/locks.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 | Credits: official HA Tuya integration.
5 | Modified by: xZetsubou
6 | """
7 |
8 | from .base import (
9 | DPCode,
10 | LocalTuyaEntity,
11 | )
12 |
13 |
14 | def localtuya_lock():
15 | """Define localtuya lock configs"""
16 | data = {}
17 | return data
18 |
19 |
20 | LOCKS: dict[str, tuple[LocalTuyaEntity, ...]] = {
21 | # Locks
22 | "ms": (
23 | LocalTuyaEntity(
24 | id=(DPCode.REMOTE_UNLOCK_SWITCH, DPCode.SWITCH),
25 | jammed_dp=DPCode.HIJACK,
26 | lock_state_dp=(DPCode.CLOSED_OPENED, DPCode.OPEN_CLOSE),
27 | ),
28 | ),
29 | }
30 |
31 | LOCKS["jtmspro"] = LOCKS["ms"]
32 | LOCKS["jtmsbh"] = LOCKS["ms"]
33 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/remotes.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 |
5 | Credits: official HA Tuya integration.
6 | Modified by: xZetsubou
7 | """
8 |
9 | from .base import DPCode, LocalTuyaEntity
10 |
11 |
12 | CONF_RECEIVE_DP = "receive_dp"
13 |
14 |
15 | # def localtuya_remote(_):
16 | # """Define localtuya fan configs"""
17 | # data = {}
18 | # return data
19 |
20 |
21 | REMOTES: dict[str, tuple[LocalTuyaEntity, ...]] = {
22 | # IR Remote
23 | # not documented
24 | "wnykq": (
25 | LocalTuyaEntity(
26 | id=(DPCode.IR_SEND, DPCode.CONTROL),
27 | receive_dp=(DPCode.IR_STUDY_CODE, DPCode.STUDY_CODE),
28 | key_study_dp=DPCode.KEY_STUDY,
29 | ),
30 | ),
31 | }
32 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/sirens.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 | Credits: official HA Tuya integration.
5 | Modified by: xZetsubou
6 | """
7 |
8 | from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory
9 |
10 | # All descriptions can be found here:
11 | # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
12 | SIRENS: dict[str, tuple[LocalTuyaEntity, ...]] = {
13 | # Multi-functional Sensor
14 | # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
15 | "dgnbj": (
16 | LocalTuyaEntity(
17 | id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH),
18 | ),
19 | ),
20 | # Siren Alarm
21 | # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
22 | "sgbj": (
23 | LocalTuyaEntity(
24 | id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH),
25 | ),
26 | ),
27 | # Smart Camera
28 | # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
29 | "sp": (
30 | LocalTuyaEntity(
31 | id=DPCode.SIREN_SWITCH,
32 | ),
33 | ),
34 | }
35 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/vacuums.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 | Credits: official HA Tuya integration.
5 | Modified by: xZetsubou
6 | """
7 |
8 | from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE
9 |
10 | CONF_POWERGO_DP = "powergo_dp"
11 | CONF_IDLE_STATUS_VALUE = "idle_status_value"
12 | CONF_RETURNING_STATUS_VALUE = "returning_status_value"
13 | CONF_DOCKED_STATUS_VALUE = "docked_status_value"
14 | CONF_BATTERY_DP = "battery_dp"
15 | CONF_MODE_DP = "mode_dp"
16 | CONF_MODES = "modes"
17 | CONF_FAN_SPEED_DP = "fan_speed_dp"
18 | CONF_FAN_SPEEDS = "fan_speeds"
19 | CONF_CLEAN_TIME_DP = "clean_time_dp"
20 | CONF_CLEAN_AREA_DP = "clean_area_dp"
21 | CONF_CLEAN_RECORD_DP = "clean_record_dp"
22 | CONF_LOCATE_DP = "locate_dp"
23 | CONF_FAULT_DP = "fault_dp"
24 | CONF_PAUSED_STATE = "paused_state"
25 | CONF_RETURN_MODE = "return_mode"
26 | CONF_STOP_STATUS = "stop_status"
27 |
28 | DEFAULT_IDLE_STATUS = "standby,sleep"
29 | DEFAULT_RETURNING_STATUS = "docking,to_charge,goto_charge"
30 | DEFAULT_DOCKED_STATUS = "charging,chargecompleted,charge_done"
31 | DEFAULT_MODES = "smart,wall_follow,spiral,single"
32 | DEFAULT_FAN_SPEEDS = "low,normal,high"
33 | DEFAULT_PAUSED_STATE = "paused"
34 | DEFAULT_RETURN_MODE = "chargego"
35 | DEFAULT_STOP_STATUS = "standby"
36 |
37 |
38 | def localtuya_vaccuums(
39 | modes: str = None,
40 | returning_status_value: str = None,
41 | return_mode: str = None,
42 | fan_speeds: str = None,
43 | paused_state: str = None,
44 | stop_status: str = None,
45 | idle_status_value: str = None,
46 | docked_status_value: str = None,
47 | ) -> dict:
48 | """Will return dict with the vacuum localtuya entity configs"""
49 | data = {
50 | CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", str),
51 | CONF_IDLE_STATUS_VALUE: idle_status_value or DEFAULT_IDLE_STATUS,
52 | CONF_STOP_STATUS: stop_status or DEFAULT_STOP_STATUS,
53 | CONF_PAUSED_STATE: paused_state or DEFAULT_PAUSED_STATE,
54 | CONF_FAN_SPEEDS: CLOUD_VALUE(fan_speeds, CONF_FAN_SPEED_DP, "range", str),
55 | CONF_RETURN_MODE: return_mode or DEFAULT_RETURN_MODE,
56 | CONF_RETURNING_STATUS_VALUE: returning_status_value or DEFAULT_RETURNING_STATUS,
57 | CONF_DOCKED_STATUS_VALUE: docked_status_value or CONF_DOCKED_STATUS_VALUE,
58 | }
59 |
60 | return data
61 |
62 |
63 | VACUUMS: dict[str, tuple[LocalTuyaEntity, ...]] = {
64 | # Robot Vacuum
65 | # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
66 | "sd": (
67 | LocalTuyaEntity(
68 | id=DPCode.STATUS,
69 | icon="mdi:robot-vacuum",
70 | powergo_dp=(DPCode.POWER_GO, DPCode.POWER, DPCode.SWITCH),
71 | battery_dp=(
72 | DPCode.BATTERY_PERCENTAGE,
73 | DPCode.ELECTRICITY_LEFT,
74 | DPCode.RESIDUAL_ELECTRICITY,
75 | ),
76 | mode_dp=DPCode.MODE,
77 | fan_speed_dp=DPCode.SUCTION,
78 | pause_dp=DPCode.PAUSE,
79 | locate_dp=DPCode.SEEK,
80 | clean_time_dp=(
81 | DPCode.CLEAN_TIME,
82 | DPCode.TOTAL_CLEAN_AREA,
83 | DPCode.TOTAL_CLEAN_TIME,
84 | ),
85 | clean_area_dp=DPCode.CLEAN_AREA,
86 | clean_record_dp=DPCode.CLEAN_RECORD,
87 | fault_dp=DPCode.FAULT,
88 | custom_configs=localtuya_vaccuums(
89 | modes=DEFAULT_MODES,
90 | returning_status_value=DEFAULT_RETURNING_STATUS,
91 | return_mode=DEFAULT_RETURN_MODE,
92 | fan_speeds=DEFAULT_FAN_SPEEDS,
93 | paused_state=DEFAULT_PAUSED_STATE,
94 | stop_status=DEFAULT_STOP_STATUS,
95 | idle_status_value=DEFAULT_IDLE_STATUS,
96 | docked_status_value=DEFAULT_DOCKED_STATUS,
97 | ),
98 | ),
99 | ),
100 | }
101 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/ha_entities/water_heaters.py:
--------------------------------------------------------------------------------
1 | """
2 | This a file contains available tuya data
3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
4 |
5 | Credits: official HA Tuya integration.
6 | Modified by: xZetsubou
7 | """
8 |
9 | from homeassistant.components.water_heater import (
10 | DEFAULT_MAX_TEMP,
11 | DEFAULT_MIN_TEMP,
12 | )
13 | from homeassistant.const import CONF_TEMPERATURE_UNIT
14 |
15 | from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE
16 | from ...const import (
17 | CONF_TARGET_TEMPERATURE_LOW_DP,
18 | CONF_TARGET_TEMPERATURE_HIGH_DP,
19 | CONF_PRECISION,
20 | CONF_TARGET_PRECISION,
21 | CONF_CURRENT_TEMPERATURE_DP,
22 | CONF_MAX_TEMP,
23 | CONF_MIN_TEMP,
24 | CONF_TARGET_TEMPERATURE_DP,
25 | CONF_MODES,
26 | CONF_MODE_DP,
27 | )
28 |
29 |
30 | UNIT_C = "celsius"
31 | UNIT_F = "fahrenheit"
32 |
33 |
34 | def localtuya_water_heater(
35 | modes={},
36 | unit=None,
37 | min_temperature=DEFAULT_MIN_TEMP,
38 | max_temperature=DEFAULT_MAX_TEMP,
39 | current_precsion=0.1,
40 | target_precision=1,
41 | ) -> dict:
42 | """Create localtuya climate configs"""
43 | data = {}
44 | for key, conf in {
45 | CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", dict),
46 | CONF_MIN_TEMP: CLOUD_VALUE(
47 | min_temperature, CONF_TARGET_TEMPERATURE_DP, "min", scale=True
48 | ),
49 | CONF_MAX_TEMP: CLOUD_VALUE(
50 | max_temperature, CONF_TARGET_TEMPERATURE_DP, "max", scale=True
51 | ),
52 | CONF_TEMPERATURE_UNIT: unit,
53 | CONF_PRECISION: CLOUD_VALUE(
54 | str(current_precsion), CONF_CURRENT_TEMPERATURE_DP, "scale", str
55 | ),
56 | CONF_TARGET_PRECISION: CLOUD_VALUE(
57 | str(target_precision), CONF_TARGET_TEMPERATURE_DP, "scale", str
58 | ),
59 | }.items():
60 | if conf is not None:
61 | data.update({key: conf})
62 |
63 | return data
64 |
65 |
66 | WATER_HEATERS: dict[str, tuple[LocalTuyaEntity, ...]] = {
67 | # Water Heater
68 | # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx
69 | "rs": (
70 | LocalTuyaEntity(
71 | id=DPCode.SWITCH,
72 | target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F),
73 | current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F),
74 | target_temperature_low_dp=(DPCode.TEMP_LOW, DPCode.LOWER_TEMP),
75 | target_temperature_high_dp=(DPCode.TEMP_UP, DPCode.UPPER_TEMP),
76 | mode_dp=DPCode.MODE,
77 | fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED),
78 | custom_configs=localtuya_water_heater(
79 | current_precsion=0.1, target_precision=0.1
80 | ),
81 | ),
82 | ),
83 | }
84 |
--------------------------------------------------------------------------------
/custom_components/localtuya/core/helpers.py:
--------------------------------------------------------------------------------
1 | """
2 | Helpers functions for HASS-LocalTuya.
3 | """
4 |
5 | import asyncio
6 | import logging
7 | import os.path
8 | from enum import Enum
9 | from fnmatch import fnmatch
10 | from typing import NamedTuple
11 |
12 | from homeassistant.util.yaml import load_yaml, dump
13 | from homeassistant.const import CONF_PLATFORM, CONF_ENTITIES
14 |
15 |
16 | import custom_components.localtuya.templates as templates_dir
17 |
18 | JSON_TYPE = list | dict | str
19 |
20 | _LOGGER = logging.getLogger(__name__)
21 |
22 |
23 | ###############################
24 | # Templates #
25 | ###############################
26 | class templates:
27 |
28 | def yaml_dump(config, fname: str | None = None) -> JSON_TYPE:
29 | """Save yaml config."""
30 | try:
31 | with open(fname, "w", encoding="utf-8") as conf_file:
32 | return conf_file.write(dump(config))
33 | except UnicodeDecodeError as exc:
34 | _LOGGER.error("Unable to save file %s: %s", fname, exc)
35 |
36 | def list_templates():
37 | """Return the available templates files."""
38 | dir = os.path.dirname(templates_dir.__file__)
39 | files = {}
40 | for e in sorted(os.scandir(dir), key=lambda e: e.name):
41 | file: str = e.name.lower()
42 | if e.is_file() and (fnmatch(file, "*yaml") or fnmatch(file, "*yml")):
43 | # fn = str(file).replace(".yaml", "").replace("_", " ")
44 | files[e.name] = e.name
45 | return files
46 |
47 | def import_config(filename):
48 | """Create a data that can be used as config in localtuya."""
49 | template_dir = os.path.dirname(templates_dir.__file__)
50 | template_file = os.path.join(template_dir, filename)
51 | _config = load_yaml(template_file)
52 | entities = []
53 | for cfg in _config:
54 | ent = {}
55 | for plat, values in cfg.items():
56 | for key, value in values.items():
57 | ent[str(key)] = (
58 | str(value)
59 | if not isinstance(value, (bool, float, dict, list))
60 | else value
61 | )
62 | ent[CONF_PLATFORM] = plat
63 | entities.append(ent)
64 | if not entities:
65 | raise ValueError("No entities found the can be used for localtuya")
66 | return entities
67 |
68 | @classmethod
69 | def export_config(cls, config: dict, config_name: str):
70 | """Create a yaml config file for localtuya."""
71 | export_config = []
72 | for cfg in config[CONF_ENTITIES]:
73 | # Special case device_classes
74 | for k, v in cfg.items():
75 | if not type(v) is str and isinstance(v, Enum):
76 | cfg[k] = v.value
77 |
78 | ents = {cfg[CONF_PLATFORM]: cfg}
79 | export_config.append(ents)
80 | fname = (
81 | config_name + ".yaml" if not config_name.endswith(".yaml") else config_name
82 | )
83 | fname = fname.replace(" ", "_")
84 | template_dir = os.path.dirname(templates_dir.__file__)
85 | template_file = os.path.join(template_dir, fname)
86 |
87 | cls.yaml_dump(export_config, template_file)
88 |
89 |
90 | ################################
91 | ## config flows ##
92 | ################################
93 |
94 | from ..const import CONF_LOCAL_KEY, CONF_NODE_ID
95 |
96 | GATEWAY = NamedTuple("Gateway", [("id", str), ("data", dict)])
97 |
98 |
99 | def get_gateway_by_deviceid(device_id: str, cloud_data: dict) -> GATEWAY:
100 | """Return the gateway (id, data) of the sub-deviceID if existed in cloud_data."""
101 |
102 | if sub_device := cloud_data.get(device_id):
103 | for dev_id, dev_data in cloud_data.items():
104 | # Get gateway Assuming the LocalKey is the same gateway LocalKey!
105 | if (
106 | dev_id != device_id
107 | and not dev_data.get(CONF_NODE_ID)
108 | and dev_data.get(CONF_LOCAL_KEY) == sub_device.get(CONF_LOCAL_KEY)
109 | ):
110 | return GATEWAY(dev_id, dev_data)
111 |
112 |
113 | ###############################
114 | # Auto configure device #
115 | ###############################
116 | from .ha_entities import gen_localtuya_entities
117 |
--------------------------------------------------------------------------------
/custom_components/localtuya/diagnostics.py:
--------------------------------------------------------------------------------
1 | """Diagnostics support for LocalTuya."""
2 |
3 | from __future__ import annotations
4 |
5 | import copy
6 | import logging
7 | from typing import Any
8 |
9 | from homeassistant.config_entries import ConfigEntry
10 | from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES
11 | from homeassistant.core import HomeAssistant
12 | from homeassistant.helpers.device_registry import DeviceEntry
13 |
14 | from . import HassLocalTuyaData
15 | from .const import CONF_LOCAL_KEY, CONF_USER_ID, DOMAIN, CONF_NO_CLOUD, DATA_DISCOVERY
16 |
17 | CLOUD_DEVICES = "cloud_devices"
18 | DEVICE_CONFIG = "device_config"
19 | DEVICE_CLOUD_INFO = "device_cloud_info"
20 |
21 | _LOGGER = logging.getLogger(__name__)
22 |
23 | DATA_OBFUSCATE = {"ip": 1, "uid": 3, CONF_LOCAL_KEY: 3, "lat": 0, "lon": 0}
24 |
25 |
26 | async def async_get_config_entry_diagnostics(
27 | hass: HomeAssistant, entry: ConfigEntry
28 | ) -> dict[str, Any]:
29 | """Return diagnostics for a config entry."""
30 | data = {}
31 | data = dict(entry.data)
32 | hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id]
33 | tuya_api = hass_localtuya.cloud_data
34 | if data.get(CONF_NO_CLOUD, True) is not True:
35 | await hass.async_create_task(tuya_api.async_get_devices_dps_query())
36 | # censoring private information on integration diagnostic data
37 | for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
38 | data[field] = obfuscate(data[field])
39 | data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES])
40 | for dev_id, dev in data[CONF_DEVICES].items():
41 | local_key = dev[CONF_LOCAL_KEY]
42 | local_key_obfuscated = obfuscate(local_key)
43 | dev[CONF_LOCAL_KEY] = local_key_obfuscated
44 | data[CLOUD_DEVICES] = copy.deepcopy(tuya_api.device_list)
45 | for dev_id, dev in data[CLOUD_DEVICES].items():
46 | for obf, obf_len in DATA_OBFUSCATE.items():
47 | if ob := data[CLOUD_DEVICES][dev_id].get(obf):
48 | data[CLOUD_DEVICES][dev_id][obf] = obfuscate(ob, obf_len, obf_len)
49 | if discovery := hass.data[DOMAIN].get(DATA_DISCOVERY):
50 | data["Discovered_Devices"] = discovery.devices
51 | return data
52 |
53 |
54 | async def async_get_device_diagnostics(
55 | hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
56 | ) -> dict[str, Any]:
57 | """Return diagnostics for a device entry."""
58 | data = {}
59 | dev_id = list(device.identifiers)[0][1].split("_")[-1]
60 | data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy()
61 | # NOT censoring private information on device diagnostic data
62 | # local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY]
63 | # data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}"
64 |
65 | hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id]
66 | tuya_api = hass_localtuya.cloud_data
67 | if dev_id in tuya_api.device_list:
68 | await tuya_api.async_get_device_functions(dev_id)
69 | data[DEVICE_CLOUD_INFO] = copy.deepcopy(tuya_api.device_list[dev_id])
70 | for obf, obf_len in DATA_OBFUSCATE.items():
71 | if ob := data[DEVICE_CLOUD_INFO].get(obf):
72 | data[DEVICE_CLOUD_INFO][obf] = obfuscate(ob, obf_len, obf_len)
73 | # NOT censoring private information on device diagnostic data
74 | # local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY]
75 | # local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}"
76 | # data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated
77 |
78 | # data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log()
79 | if discovery := hass.data[DOMAIN].get(DATA_DISCOVERY):
80 | data["Discovered_Devices"] = discovery.devices.get(dev_id)
81 | return data
82 |
83 |
84 | def obfuscate(key, start_characters=3, end_characters=3) -> str:
85 | """Return obfuscated text by removing characters between [start_characters and end_characters]"""
86 | if start_characters <= 0 and end_characters <= 0:
87 | return ""
88 |
89 | return f"{key[0:start_characters]}...{key[-end_characters:]}"
90 |
--------------------------------------------------------------------------------
/custom_components/localtuya/discovery.py:
--------------------------------------------------------------------------------
1 | """Discovery module for Tuya devices.
2 |
3 | based on tuya-convert.py from tuya-convert:
4 | https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py
5 |
6 | Maintained by @xZetsubou
7 | """
8 |
9 | import os
10 | import asyncio
11 | import json
12 | import logging
13 | from hashlib import md5
14 | from socket import inet_aton
15 |
16 | from cryptography.hazmat.backends import default_backend
17 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
18 |
19 | from .entity import pytuya
20 |
21 | _LOGGER = logging.getLogger(__name__)
22 |
23 | UDP_KEY = md5(b"yGAdlopoPVldABfn").digest()
24 |
25 | PREFIX_55AA_BIN = b"\x00\x00U\xaa"
26 | PREFIX_6699_BIN = b"\x00\x00\x66\x99"
27 | UDP_COMMAND = b"\x00\x00\x00\x00"
28 |
29 | DEFAULT_TIMEOUT = 6.0
30 |
31 |
32 | def decrypt(msg, key):
33 | def _unpad(data):
34 | return data[: -ord(data[len(data) - 1 :])]
35 |
36 | cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend())
37 | decryptor = cipher.decryptor()
38 | return _unpad(decryptor.update(msg) + decryptor.finalize()).decode()
39 |
40 |
41 | def decrypt_udp(message):
42 | """Decrypt encrypted UDP broadcasts."""
43 | if message[:4] == PREFIX_55AA_BIN:
44 | payload = message[20:-8]
45 | if message[8:12] == UDP_COMMAND:
46 | return payload
47 | return decrypt(payload, UDP_KEY)
48 | if message[:4] == PREFIX_6699_BIN:
49 | unpacked = pytuya.unpack_message(message, hmac_key=UDP_KEY, no_retcode=None)
50 | payload = unpacked.payload.decode()
51 | # app sometimes has extra bytes at the end
52 | while payload[-1] == chr(0):
53 | payload = payload[:-1]
54 | return payload
55 | return decrypt(message, UDP_KEY)
56 |
57 |
58 | class TuyaDiscovery(asyncio.DatagramProtocol):
59 | """Datagram handler listening for Tuya broadcast messages."""
60 |
61 | def __init__(self, callback=None):
62 | """Initialize a new BaseDiscovery."""
63 | self.devices = {}
64 | self._listeners = []
65 | self._callback = callback
66 |
67 | async def start(self):
68 | """Start discovery by listening to broadcasts."""
69 | loop = asyncio.get_running_loop()
70 | op_reuse_port = {"reuse_port": True} if os.name != "nt" else {}
71 | listener = loop.create_datagram_endpoint(
72 | lambda: self, local_addr=("0.0.0.0", 6666), **op_reuse_port
73 | )
74 | encrypted_listener = loop.create_datagram_endpoint(
75 | lambda: self, local_addr=("0.0.0.0", 6667), **op_reuse_port
76 | )
77 | # tuyaApp_encrypted_listener = loop.create_datagram_endpoint(
78 | # lambda: self, local_addr=("0.0.0.0", 7000), **op_reuse_port
79 | # )
80 | self._listeners = await asyncio.gather(listener, encrypted_listener)
81 | _LOGGER.debug("Listening to broadcasts on UDP port 6666, 6667")
82 |
83 | def close(self):
84 | """Stop discovery."""
85 | self._callback = None
86 | for transport, _ in self._listeners:
87 | transport.close()
88 |
89 | def datagram_received(self, data, addr):
90 | """Handle received broadcast message."""
91 | try:
92 | try:
93 | data = decrypt_udp(data)
94 | except Exception: # pylint: disable=broad-except
95 | data = data.decode()
96 | decoded = json.loads(data)
97 | self.device_found(decoded)
98 | except:
99 | # _LOGGER.debug("Bordcast from app from ip: %s", addr[0])
100 | _LOGGER.debug("Failed to decode broadcast from %r: %r", addr[0], data)
101 |
102 | def device_found(self, device):
103 | """Discover a new device."""
104 | gwid, ip = device.get("gwId"), device.get("ip")
105 | # If device found but the ip changed.
106 | if gwid in self.devices and (self.devices[gwid].get("ip") != ip):
107 | self.devices.pop(gwid)
108 |
109 | if gwid not in self.devices:
110 | self.devices[gwid] = device
111 | # Sort devices by ip.
112 | sort_devices = sorted(
113 | self.devices.items(), key=lambda i: inet_aton(i[1].get("ip", "0"))
114 | )
115 | self.devices = dict(sort_devices)
116 |
117 | _LOGGER.debug("Discovered device: %s", device)
118 | if self._callback:
119 | self._callback(device)
120 |
121 |
122 | async def discover():
123 | """Discover and return devices on local network."""
124 | discovery = TuyaDiscovery()
125 | try:
126 | await discovery.start()
127 | await asyncio.sleep(DEFAULT_TIMEOUT)
128 | finally:
129 | discovery.close()
130 | return discovery.devices
131 |
--------------------------------------------------------------------------------
/custom_components/localtuya/humidifier.py:
--------------------------------------------------------------------------------
1 | """Platform to locally control Tuya-based button devices."""
2 |
3 | import logging
4 | from functools import partial
5 | from .config_flow import col_to_select
6 | from homeassistant.helpers.selector import ObjectSelector
7 |
8 | import voluptuous as vol
9 | from homeassistant.const import CONF_DEVICE_CLASS
10 | from homeassistant.components.humidifier import (
11 | DOMAIN,
12 | HumidifierDeviceClass,
13 | DEVICE_CLASSES_SCHEMA,
14 | HumidifierEntity,
15 | HumidifierEntityDescription,
16 | HumidifierEntityFeature,
17 | )
18 | from homeassistant.components.humidifier.const import (
19 | ATTR_MAX_HUMIDITY,
20 | ATTR_MIN_HUMIDITY,
21 | DEFAULT_MAX_HUMIDITY,
22 | DEFAULT_MIN_HUMIDITY,
23 | )
24 |
25 | from .const import (
26 | CONF_HUMIDIFIER_SET_HUMIDITY_DP,
27 | CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP,
28 | CONF_HUMIDIFIER_MODE_DP,
29 | CONF_HUMIDIFIER_AVAILABLE_MODES,
30 | DictSelector,
31 | )
32 |
33 | from .entity import LocalTuyaEntity, async_setup_entry
34 |
35 |
36 | _LOGGER = logging.getLogger(__name__)
37 |
38 |
39 | def flow_schema(dps):
40 | """Return schema used in config flow."""
41 | return {
42 | vol.Optional(CONF_HUMIDIFIER_SET_HUMIDITY_DP): col_to_select(dps, is_dps=True),
43 | vol.Optional(CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP): col_to_select(
44 | dps, is_dps=True
45 | ),
46 | vol.Optional(CONF_HUMIDIFIER_MODE_DP): col_to_select(dps, is_dps=True),
47 | vol.Required(ATTR_MIN_HUMIDITY, default=DEFAULT_MIN_HUMIDITY): int,
48 | vol.Required(ATTR_MAX_HUMIDITY, default=DEFAULT_MAX_HUMIDITY): int,
49 | vol.Optional(CONF_HUMIDIFIER_AVAILABLE_MODES, default={}): ObjectSelector(),
50 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
51 | }
52 |
53 |
54 | class LocalTuyaHumidifier(LocalTuyaEntity, HumidifierEntity):
55 | """Representation of a Localtuya Humidifier."""
56 |
57 | _dp_mode = CONF_HUMIDIFIER_MODE_DP
58 | _available_modes = CONF_HUMIDIFIER_AVAILABLE_MODES
59 | _dp_current_humidity = CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP
60 | _dp_set_humidity = CONF_HUMIDIFIER_SET_HUMIDITY_DP
61 | _mode_name_to_value = {}
62 |
63 | def __init__(
64 | self,
65 | device,
66 | config_entry,
67 | humidifierID,
68 | **kwargs,
69 | ):
70 | """Initialize the Tuya button."""
71 | super().__init__(device, config_entry, humidifierID, _LOGGER, **kwargs)
72 | self._state = None
73 | self._current_mode = None
74 |
75 | if (modes := self._config.get(self._available_modes, {})) and (
76 | self._config.get(self._dp_mode)
77 | ):
78 | self._attr_supported_features |= HumidifierEntityFeature.MODES
79 | modes = {
80 | k: v if k else v.replace("_", " ").capitalize()
81 | for k, v in modes.copy().items()
82 | }
83 | self._available_modes = DictSelector(modes)
84 |
85 | self._attr_min_humidity = self._config.get(
86 | ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY
87 | )
88 | self._attr_max_humidity = self._config.get(
89 | ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY
90 | )
91 |
92 | @property
93 | def is_on(self) -> bool:
94 | """Return the device is on or off."""
95 | return self._state
96 |
97 | @property
98 | def mode(self) -> str | None:
99 | """Return the current mode."""
100 | return self._current_mode
101 |
102 | @property
103 | def target_humidity(self) -> int | None:
104 | """Return the humidity we try to reach."""
105 | target_dp = self._config.get(self._dp_set_humidity, None)
106 | return self.dp_value(target_dp) if target_dp else None
107 |
108 | @property
109 | def current_humidity(self) -> int | None:
110 | """Return the current humidity."""
111 | curr_humidity = self._config.get(self._dp_current_humidity)
112 |
113 | return self.dp_value(self._dp_current_humidity) if curr_humidity else None
114 |
115 | async def async_turn_on(self, **kwargs):
116 | """Turn the device on."""
117 | await self._device.set_dp(True, self._dp_id)
118 |
119 | async def async_turn_off(self, **kwargs):
120 | """Turn the device off."""
121 | await self._device.set_dp(False, self._dp_id)
122 |
123 | async def async_set_humidity(self, humidity: int) -> None:
124 | """Set new target humidity."""
125 | set_humidity_dp = self._config.get(self._dp_set_humidity, None)
126 | if set_humidity_dp is None:
127 | return None
128 |
129 | await self._device.set_dp(humidity, set_humidity_dp)
130 |
131 | @property
132 | def available_modes(self):
133 | """Return the list of presets that this device supports."""
134 | return self._available_modes.names
135 |
136 | async def async_set_mode(self, mode):
137 | """Set new target preset mode."""
138 | set_mode_dp = self._config.get(self._dp_mode, None)
139 | if set_mode_dp is None:
140 | return None
141 |
142 | await self._device.set_dp(self._available_modes.to_tuya(mode), set_mode_dp)
143 |
144 | def status_updated(self):
145 | """Device status was updated."""
146 | super().status_updated()
147 | current_mode = self.dp_value(self._dp_mode)
148 |
149 | self._current_mode = self._available_modes.to_ha(current_mode, "unknown")
150 |
151 |
152 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaHumidifier, flow_schema)
153 |
--------------------------------------------------------------------------------
/custom_components/localtuya/lock.py:
--------------------------------------------------------------------------------
1 | """Platform to present any Tuya DP as a Lock."""
2 |
3 | import logging
4 | from functools import partial
5 | from typing import Any
6 | from .config_flow import col_to_select
7 |
8 | import voluptuous as vol
9 | from homeassistant.components.lock import DOMAIN, LockEntity
10 | from .entity import LocalTuyaEntity, async_setup_entry
11 |
12 | from .const import CONF_JAMMED_DP, CONF_LOCK_STATE_DP
13 |
14 | _LOGGER = logging.getLogger(__name__)
15 |
16 |
17 | def flow_schema(dps):
18 | """Return schema used in config flow."""
19 | return {
20 | vol.Optional(CONF_LOCK_STATE_DP): col_to_select(dps, is_dps=True),
21 | vol.Optional(CONF_JAMMED_DP): col_to_select(dps, is_dps=True),
22 | }
23 |
24 |
25 | class LocalTuyaLock(LocalTuyaEntity, LockEntity):
26 | """Representation of a Tuya Lock."""
27 |
28 | def __init__(
29 | self,
30 | device,
31 | config_entry,
32 | Lockid,
33 | **kwargs,
34 | ):
35 | """Initialize the Tuya Lock."""
36 | super().__init__(device, config_entry, Lockid, _LOGGER, **kwargs)
37 | self._state = None
38 |
39 | async def async_lock(self, **kwargs: Any) -> None:
40 | """Lock the lock."""
41 | await self._device.set_dp(True, self._dp_id)
42 |
43 | async def async_unlock(self, **kwargs: Any) -> None:
44 | """Unlock the lock."""
45 | await self._device.set_dp(False, self._dp_id)
46 |
47 | def status_updated(self):
48 | """Device status was updated."""
49 | state = self.dp_value(self._dp_id)
50 | if (lock_state := self.dp_value(CONF_LOCK_STATE_DP)) or lock_state is not None:
51 | state = lock_state
52 |
53 | self._attr_is_locked = state in (False, "closed", "close", None)
54 |
55 | if jammed := self.dp_value(CONF_JAMMED_DP, False):
56 | self._attr_is_jammed = jammed
57 |
58 | # No need to restore state for a Lock
59 | async def restore_state_when_connected(self):
60 | """Do nothing for a Lock."""
61 | return
62 |
63 |
64 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaLock, flow_schema)
65 |
--------------------------------------------------------------------------------
/custom_components/localtuya/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "localtuya",
3 | "name": "Local Tuya",
4 | "codeowners": [],
5 | "config_flow": true,
6 | "dependencies": [],
7 | "documentation": "https://github.com/xZetsubou/hass-localtuya/",
8 | "integration_type": "hub",
9 | "iot_class": "local_push",
10 | "issue_tracker": "https://github.com/xZetsubou/hass-localtuya/issues",
11 | "requirements": [],
12 | "version": "2025.5.1"
13 | }
14 |
--------------------------------------------------------------------------------
/custom_components/localtuya/number.py:
--------------------------------------------------------------------------------
1 | """Platform to present any Tuya DP as a number."""
2 |
3 | import logging
4 | from functools import partial
5 |
6 | import voluptuous as vol
7 | from homeassistant.components.number import DOMAIN, NumberEntity, DEVICE_CLASSES_SCHEMA
8 | from homeassistant.const import (
9 | CONF_DEVICE_CLASS,
10 | STATE_UNKNOWN,
11 | CONF_UNIT_OF_MEASUREMENT,
12 | )
13 |
14 | from .entity import LocalTuyaEntity, async_setup_entry
15 | from .const import (
16 | CONF_DEFAULT_VALUE,
17 | CONF_MAX_VALUE,
18 | CONF_MIN_VALUE,
19 | CONF_PASSIVE_ENTITY,
20 | CONF_RESTORE_ON_RECONNECT,
21 | CONF_SCALING,
22 | CONF_STEPSIZE,
23 | )
24 |
25 | _LOGGER = logging.getLogger(__name__)
26 |
27 | DEFAULT_MIN = 0
28 | DEFAULT_MAX = 100000
29 | DEFAULT_STEP = 1.0
30 |
31 |
32 | def flow_schema(dps):
33 | """Return schema used in config flow."""
34 | return {
35 | vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All(
36 | vol.Coerce(float),
37 | vol.Range(min=-1000000.0, max=1000000.0),
38 | ),
39 | vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All(
40 | vol.Coerce(float),
41 | vol.Range(min=-1000000.0, max=1000000.0),
42 | ),
43 | vol.Required(CONF_STEPSIZE, default=DEFAULT_STEP): vol.All(
44 | vol.Coerce(float), vol.Range(min=0.0, max=1000000.0)
45 | ),
46 | vol.Optional(CONF_RESTORE_ON_RECONNECT, default=False): bool,
47 | vol.Optional(CONF_PASSIVE_ENTITY, default=False): bool,
48 | vol.Optional(CONF_DEFAULT_VALUE): str,
49 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
50 | vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(None, str),
51 | vol.Optional(CONF_SCALING): vol.All(
52 | vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0)
53 | ),
54 | }
55 |
56 |
57 | class LocalTuyaNumber(LocalTuyaEntity, NumberEntity):
58 | """Representation of a Tuya Number."""
59 |
60 | def __init__(
61 | self,
62 | device,
63 | config_entry,
64 | sensorid,
65 | **kwargs,
66 | ):
67 | """Initialize the Tuya sensor."""
68 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
69 | self._state = STATE_UNKNOWN
70 |
71 | self._min_value = self.scale(self._config.get(CONF_MIN_VALUE, DEFAULT_MIN))
72 | self._max_value = self.scale(self._config.get(CONF_MAX_VALUE, DEFAULT_MAX))
73 | self._step_size = self.scale(self._config.get(CONF_STEPSIZE, DEFAULT_STEP))
74 |
75 | # Override standard default value handling to cast to a float
76 | default_value = self._config.get(CONF_DEFAULT_VALUE)
77 | if default_value is not None:
78 | self._default_value = float(default_value)
79 |
80 | @property
81 | def native_value(self) -> float:
82 | """Return sensor state."""
83 | self._state = self.scale(self._state)
84 | return self._state
85 |
86 | @property
87 | def native_min_value(self) -> float:
88 | """Return the minimum value."""
89 | return self._min_value
90 |
91 | @property
92 | def native_max_value(self) -> float:
93 | """Return the maximum value."""
94 | return self._max_value
95 |
96 | @property
97 | def native_step(self) -> float:
98 | """Return the maximum value."""
99 | return self._step_size
100 |
101 | @property
102 | def native_unit_of_measurement(self):
103 | """Return the unit of measurement of this entity, if any."""
104 | return self._config.get(CONF_UNIT_OF_MEASUREMENT)
105 |
106 | @property
107 | def device_class(self):
108 | """Return the class of this device."""
109 | return self._config.get(CONF_DEVICE_CLASS)
110 |
111 | async def async_set_native_value(self, value: float) -> None:
112 | """Update the current value."""
113 | if scale_factor := self._config.get(CONF_SCALING):
114 | value = value / float(scale_factor)
115 |
116 | await self._device.set_dp(int(value), self._dp_id)
117 |
118 | # Default value is the minimum value
119 | def entity_default_value(self):
120 | """Return the minimum value as the default for this entity type."""
121 | return self._min_value
122 |
123 |
124 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaNumber, flow_schema)
125 |
--------------------------------------------------------------------------------
/custom_components/localtuya/select.py:
--------------------------------------------------------------------------------
1 | """Platform to present any Tuya DP as an enumeration."""
2 |
3 | import logging
4 | from functools import partial
5 |
6 | import voluptuous as vol
7 | from homeassistant.components.select import DOMAIN, SelectEntity
8 | from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
9 | from homeassistant.helpers import selector
10 |
11 | from .entity import LocalTuyaEntity, async_setup_entry
12 | from .const import (
13 | CONF_DEFAULT_VALUE,
14 | CONF_OPTIONS,
15 | CONF_PASSIVE_ENTITY,
16 | CONF_RESTORE_ON_RECONNECT,
17 | DictSelector,
18 | )
19 |
20 |
21 | def flow_schema(dps):
22 | """Return schema used in config flow."""
23 | return {
24 | vol.Required(CONF_OPTIONS, default={}): selector.ObjectSelector(),
25 | vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
26 | vol.Required(CONF_PASSIVE_ENTITY): bool,
27 | vol.Optional(CONF_DEFAULT_VALUE): str,
28 | }
29 |
30 |
31 | _LOGGER = logging.getLogger(__name__)
32 |
33 |
34 | class LocalTuyaSelect(LocalTuyaEntity, SelectEntity):
35 | """Representation of a Tuya Enumeration."""
36 |
37 | def __init__(
38 | self,
39 | device,
40 | config_entry,
41 | sensorid,
42 | **kwargs,
43 | ):
44 | """Initialize the Tuya sensor."""
45 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
46 | self._state = STATE_UNKNOWN
47 | self._state_friendly = ""
48 |
49 | # Set Display options
50 | options = {}
51 | config_options: dict = self._config.get(CONF_OPTIONS, {})
52 | if not isinstance(config_options, dict):
53 | self.warning(
54 | f"{self.name} DPiD: {self._dp_id}: Options configured incorrectly!"
55 | + "It must be in the format of key-value pairs,"
56 | + "where each line follows the structure [device_value: friendly name]"
57 | )
58 | config_options = {}
59 | for k, v in config_options.items():
60 | options[k] = v if v else k.replace("_", "").capitalize()
61 |
62 | self._options = DictSelector(options)
63 |
64 | @property
65 | def current_option(self) -> str:
66 | """Return the current value."""
67 | return self._state_friendly
68 |
69 | @property
70 | def options(self) -> list:
71 | """Return the list of values."""
72 | return self._options.names
73 |
74 | @property
75 | def device_class(self):
76 | """Return the class of this device."""
77 | return self._config.get(CONF_DEVICE_CLASS)
78 |
79 | async def async_select_option(self, option: str) -> None:
80 | """Update the current value."""
81 | option_value = self._options.to_tuya(option)
82 | self.debug("Sending Option: " + option + " -> " + option_value)
83 | await self._device.set_dp(option_value, self._dp_id)
84 |
85 | def status_updated(self):
86 | """Device status was updated."""
87 | super().status_updated()
88 |
89 | if (state := self.dp_value(self._dp_id)) is not None:
90 | self._state_friendly = self._options.to_ha(state, state)
91 |
92 | # Default value is the first option
93 | def entity_default_value(self):
94 | """Return the first option as the default value for this entity type."""
95 | return self._options.names[0]
96 |
97 |
98 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSelect, flow_schema)
99 |
--------------------------------------------------------------------------------
/custom_components/localtuya/sensor.py:
--------------------------------------------------------------------------------
1 | """Platform to present any Tuya DP as a sensor."""
2 |
3 | import logging
4 | import base64
5 | from functools import partial
6 | from .config_flow import col_to_select
7 |
8 | import voluptuous as vol
9 | from homeassistant.components.sensor import (
10 | DEVICE_CLASSES_SCHEMA,
11 | DOMAIN,
12 | STATE_CLASSES_SCHEMA,
13 | SensorDeviceClass,
14 | SensorEntity,
15 | SensorStateClass,
16 | )
17 | from homeassistant.const import (
18 | CONF_DEVICE_CLASS,
19 | CONF_UNIT_OF_MEASUREMENT,
20 | Platform,
21 | STATE_UNKNOWN,
22 | UnitOfElectricCurrent,
23 | UnitOfElectricPotential,
24 | UnitOfPower,
25 | )
26 | from homeassistant.helpers import entity_registry as er
27 |
28 | from .entity import LocalTuyaEntity, async_setup_entry
29 | from .const import CONF_SCALING, CONF_STATE_CLASS
30 |
31 | _LOGGER = logging.getLogger(__name__)
32 |
33 | DEFAULT_PRECISION = 2
34 |
35 | ATTR_POWER = "power"
36 | ATTR_VOLTAGE = "voltage"
37 | ATTR_CURRENT = "current"
38 | MAP_UOM = {
39 | ATTR_CURRENT: UnitOfElectricCurrent.AMPERE,
40 | ATTR_VOLTAGE: UnitOfElectricPotential.VOLT,
41 | ATTR_POWER: UnitOfPower.KILO_WATT,
42 | }
43 |
44 |
45 | def flow_schema(dps):
46 | """Return schema used in config flow."""
47 | return {
48 | vol.Optional(CONF_UNIT_OF_MEASUREMENT): str,
49 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
50 | vol.Optional(CONF_STATE_CLASS): col_to_select(
51 | [sc.value for sc in SensorStateClass]
52 | ),
53 | vol.Optional(CONF_SCALING): vol.All(
54 | vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0)
55 | ),
56 | }
57 |
58 |
59 | class LocalTuyaSensor(LocalTuyaEntity, SensorEntity):
60 | """Representation of a Tuya sensor."""
61 |
62 | def __init__(
63 | self,
64 | device,
65 | config_entry,
66 | sensorid,
67 | **kwargs,
68 | ):
69 | """Initialize the Tuya sensor."""
70 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
71 | self._state = None
72 |
73 | self._has_sub_entities = False
74 | self._attr_device_class = self._config.get(CONF_DEVICE_CLASS)
75 |
76 | @property
77 | def native_value(self):
78 | """Return sensor state."""
79 | return self._state
80 |
81 | @property
82 | def state_class(self) -> str | None:
83 | """Return state class."""
84 | return getattr(self, "_attr_state_class", self._config.get(CONF_STATE_CLASS))
85 |
86 | @property
87 | def native_unit_of_measurement(self):
88 | """Return the unit of measurement of this entity, if any."""
89 | return getattr(
90 | self,
91 | "_attr_native_unit_of_measurement",
92 | self._config.get(CONF_UNIT_OF_MEASUREMENT),
93 | )
94 |
95 | def status_updated(self):
96 | """Device status was updated."""
97 |
98 | state = self.dp_value(self._dp_id)
99 |
100 | if self.is_base64(state):
101 | if not self._has_sub_entities:
102 | self.hass.add_job(self.__create_sub_sensors())
103 |
104 | if None not in (
105 | sub_sensor := getattr(self, "_attr_sub_sensor", None),
106 | sub_sensor_state := self.decode_base64(state).get(sub_sensor),
107 | ):
108 | self._state = sub_sensor_state
109 | else:
110 | self._state = state
111 | else:
112 | self._state = self.scale(state)
113 |
114 | def status_restored(self, stored_state) -> None:
115 | super().status_restored(stored_state)
116 |
117 | if (last_state := self._last_state) and self.is_base64(last_state):
118 | self._status.update({self._dp_id: last_state})
119 |
120 | # No need to restore state for a sensor
121 | async def restore_state_when_connected(self):
122 | """Do nothing for a sensor."""
123 | return
124 |
125 | def is_base64(self, data):
126 | """Return if the data is valid Tuya raw Base64 encoded data."""
127 | return (
128 | (data and isinstance(data, str))
129 | and len(data) >= 12
130 | and len(data) % 2 == 0
131 | and data.endswith("=")
132 | )
133 |
134 | def decode_base64(self, data):
135 | """Decode data base64 such as DPS phase_a."""
136 | buf = base64.b64decode(data)
137 | voltage = (buf[1] | buf[0] << 8) / 10
138 | current = (buf[4] | buf[3] << 8) / 1000
139 | power = (buf[7] | buf[6] << 8) / 1000
140 | return {ATTR_VOLTAGE: voltage, ATTR_CURRENT: current, ATTR_POWER: power}
141 |
142 | async def __create_sub_sensors(self):
143 | """Create sub entities for voltage, current and power and hide this parent sensor."""
144 | sub_entities = []
145 |
146 | for sensor in (ATTR_CURRENT, ATTR_POWER, ATTR_VOLTAGE):
147 | sub_entity = LocalTuyaSensor(
148 | self._device, self._device_config.as_dict(), self._dp_id
149 | )
150 | setattr(sub_entity, "_attr_sub_sensor", sensor)
151 | setattr(sub_entity, "_attr_unique_id", f"{self.unique_id}_{sensor}")
152 | setattr(sub_entity, "_attr_name", f"{self.name} {sensor.capitalize()}")
153 | setattr(sub_entity, "_attr_device_class", SensorDeviceClass(sensor))
154 | setattr(sub_entity, "_attr_state_class", SensorStateClass.MEASUREMENT)
155 | setattr(sub_entity, "_attr_native_unit_of_measurement", MAP_UOM[sensor])
156 | sub_entities.append(sub_entity)
157 |
158 | # Sub entities shouldn't have add entities attr.
159 | if sub_entities and self.componet_add_entities:
160 | self._has_sub_entities = True
161 | self.componet_add_entities(sub_entities)
162 | er.async_get(self.hass).async_update_entity(
163 | self.entity_id, hidden_by=er.RegistryEntryHider.INTEGRATION
164 | )
165 |
166 |
167 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSensor, flow_schema)
168 |
--------------------------------------------------------------------------------
/custom_components/localtuya/services.yaml:
--------------------------------------------------------------------------------
1 | reload:
2 | name: "Reload"
3 | description: Reload localtuya and reconnect to all devices.
4 |
5 | set_dp:
6 | name: "Set DP Value"
7 | description: Change the value of a datapoint (DP)
8 | fields:
9 | device_id:
10 | name: "Device ID"
11 | description: The device ID of the device where the datapoint value needs to be changed
12 | required: true
13 | example: 11100118278aab4de001
14 | selector:
15 | text:
16 | dp:
17 | name: "DP"
18 | description: Target DP, Datapoint index
19 | required: false
20 | example: 1
21 | selector:
22 | number:
23 | mode: box
24 | value:
25 | name: "Value"
26 | description: "A new value to set or a list of DP-value pairs. If a list is provided, the target DP will be ignored"
27 | required: true
28 | example: '{ "1": True, "2": True }'
29 | selector:
30 | object:
31 |
32 | remote_add_code:
33 | name: "Add Remote Code"
34 | description: Add the remote code to the device's remote storage.
35 | fields:
36 | target:
37 | name: "Choose remote device"
38 | description: "Select the remote to store the code on it"
39 | required: true
40 | selector:
41 | device:
42 | multiple: false
43 | entity:
44 | domain: "remote"
45 | filter:
46 | integration: "localtuya"
47 | device_name:
48 | name: "Device Name"
49 | description: The name of the device to store the code in
50 | required: true
51 | example: TV
52 | selector:
53 | text:
54 | command_name:
55 | name: "Command Name"
56 | description: The command name to use when calling it
57 | required: true
58 | example: volume_up
59 | selector:
60 | text:
61 | base64:
62 | name: "Base64 Code"
63 | description: The Base64 code (this will override the head/key values)
64 | required: false
65 | selector:
66 | text:
67 | head:
68 | name: "Head"
69 | description: "The header can be found in the Tuya IoT device debug logs, Key's required"
70 | required: false
71 | selector:
72 | text:
73 | key:
74 | name: "Key"
75 | description: "The key can be found in the Tuya IoT device debug logs, Head's required"
76 | required: false
77 | selector:
78 | text:
79 |
--------------------------------------------------------------------------------
/custom_components/localtuya/siren.py:
--------------------------------------------------------------------------------
1 | """Platform to present any Tuya DP as a siren."""
2 |
3 | import logging
4 | from functools import partial
5 |
6 | import voluptuous as vol
7 | from homeassistant.components.siren import DOMAIN, SirenEntity, SirenEntityFeature
8 |
9 | from .entity import LocalTuyaEntity, async_setup_entry
10 | from .const import CONF_STATE_ON
11 |
12 | _LOGGER = logging.getLogger(__name__)
13 |
14 | # CONF_STATE_MAP = ["True and False", "ON and OFF"]
15 |
16 |
17 | def flow_schema(dps):
18 | """Return schema used in config flow."""
19 | return {
20 | vol.Required(CONF_STATE_ON, default="true"): str,
21 | # vol.Required(CONF_STATE_OFF, default="False"): str,
22 | }
23 |
24 |
25 | class LocalTuyaSiren(LocalTuyaEntity, SirenEntity):
26 | """Representation of a Tuya siren."""
27 |
28 | _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF
29 |
30 | def __init__(
31 | self,
32 | device,
33 | config_entry,
34 | sirenid,
35 | **kwargs,
36 | ):
37 | """Initialize the Tuya siren."""
38 | super().__init__(device, config_entry, sirenid, _LOGGER, **kwargs)
39 | self._is_on = False
40 |
41 | @property
42 | def is_on(self):
43 | """Return siren state."""
44 | return self._is_on
45 |
46 | async def async_turn_on(self, **kwargs):
47 | """Turn Tuya siren on."""
48 | await self._device.set_dp(True, self._dp_id)
49 |
50 | async def async_turn_off(self, **kwargs):
51 | """Turn Tuya siren off."""
52 | await self._device.set_dp(False, self._dp_id)
53 |
54 | # No need to restore state for a siren
55 | async def restore_state_when_connected(self):
56 | """Do nothing for a siren."""
57 | return
58 |
59 | def status_updated(self):
60 | """Device status was updated."""
61 | super().status_updated()
62 |
63 | state = str(self.dp_value(self._dp_id)).lower()
64 | if state == self._config[CONF_STATE_ON].lower() or state == "true":
65 | self._is_on = True
66 | else:
67 | self._is_on = False
68 |
69 |
70 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSiren, flow_schema)
71 |
--------------------------------------------------------------------------------
/custom_components/localtuya/strings.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "abort": {
4 | "already_configured": "This Account ID has already been configured.",
5 | "unsupported_device_type": "Unsupported device type!"
6 | },
7 | "error": {
8 | "cannot_connect": "Cannot connect to device. Verify that address is correct.",
9 | "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.",
10 | "unknown": "An unknown error occurred. See log for details.",
11 | "switch_already_configured": "Switch with this ID has already been configured."
12 | },
13 | "step": {
14 | "user": {
15 | "title": "Main Configuration",
16 | "description": "Input the credentials for Tuya Cloud API.",
17 | "data": {
18 | "region": "API server region",
19 | "client_id": "Client ID",
20 | "client_secret": "Secret",
21 | "user_id": "User ID"
22 | }
23 | },
24 | "power_outlet": {
25 | "title": "Add subswitch",
26 | "description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.",
27 | "data": {
28 | "id": "ID",
29 | "name": "Name",
30 | "friendly_name": "Friendly name",
31 | "current": "Current",
32 | "current_consumption": "Current Consumption",
33 | "voltage": "Voltage",
34 | "add_another_switch": "Add another switch"
35 | }
36 | }
37 | }
38 | },
39 | "options": {
40 | "step": {
41 | "init": {
42 | "title": "LocalTuya Configuration",
43 | "description": "Please select the desired actionSSSS.",
44 | "data": {
45 | "add_device": "Add a new device",
46 | "edit_device": "Edit a device",
47 | "delete_device": "Delete a device",
48 | "setup_cloud": "Reconfigure Cloud API account"
49 | }
50 | },
51 | "entity": {
52 | "title": "Entity Configuration",
53 | "description": "Editing entity with DPS `{id}` and platform `{platform}`.",
54 | "data": {
55 | "id": "ID",
56 | "friendly_name": "Friendly name",
57 | "current": "Current",
58 | "current_consumption": "Current Consumption",
59 | "voltage": "Voltage",
60 | "commands_set": "Open_Close_Stop Commands Set",
61 | "positioning_mode": "Positioning mode",
62 | "current_position_dp": "Current Position (for *position* mode only)",
63 | "set_position_dp": "Set Position (for *position* mode only)",
64 | "position_inverted": "Invert 0-100 position (for *position* mode only)",
65 | "span_time": "Full opening time, in secs. (for *timed* mode only)",
66 | "unit_of_measurement": "Unit of Measurement",
67 | "device_class": "Device Class",
68 | "scaling": "Scaling Factor",
69 | "state_on": "On Value",
70 | "state_off": "Off Value",
71 | "powergo_dp": "Power DP (Usually 25 or 2)",
72 | "idle_status_value": "Idle Status (comma-separated)",
73 | "returning_status_value": "Returning Status",
74 | "docked_status_value": "Docked Status (comma-separated)",
75 | "fault_dp": "Fault DP (Usually 11)",
76 | "battery_dp": "Battery status DP (Usually 14)",
77 | "mode_dp": "Mode DP (Usually 27)",
78 | "modes": "Modes list",
79 | "return_mode": "Return home mode",
80 | "fan_speed_dp": "Fan speeds DP (Usually 30)",
81 | "fan_speeds": "Fan speeds list (comma-separated)",
82 | "clean_time_dp": "Clean Time DP (Usually 33)",
83 | "clean_area_dp": "Clean Area DP (Usually 32)",
84 | "clean_record_dp": "Clean Record DP (Usually 34)",
85 | "locate_dp": "Locate DP (Usually 31)",
86 | "paused_state": "Pause state (pause, paused, etc)",
87 | "stop_status": "Stop status",
88 | "brightness": "Brightness (only for white color)",
89 | "brightness_lower": "Brightness Lower Value",
90 | "brightness_upper": "Brightness Upper Value",
91 | "color_temp": "Color Temperature",
92 | "color_temp_reverse": "Color Temperature Reverse",
93 | "color": "Color",
94 | "color_mode": "Color Mode",
95 | "color_temp_min_kelvin": "Minimum Color Temperature in K",
96 | "color_temp_max_kelvin": "Maximum Color Temperature in K",
97 | "music_mode": "Music mode available",
98 | "scene": "Scene",
99 | "scene_values": "Scene values, separate entries by a ;",
100 | "scene_values_friendly": "User friendly scene values, separate entries by a ;",
101 | "fan_speed_control": "Fan Speed Control dps",
102 | "fan_oscillating_control": "Fan Oscillating Control dps",
103 | "fan_speed_min": "minimum fan speed integer",
104 | "fan_speed_max": "maximum fan speed integer",
105 | "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)",
106 | "fan_direction":"fan direction dps",
107 | "fan_direction_forward": "forward dps string",
108 | "fan_direction_reverse": "reverse dps string",
109 | "fan_dps_type": "DP value type",
110 | "current_temperature_dp": "Current Temperature",
111 | "target_temperature_dp": "Target Temperature",
112 | "temperature_step": "Temperature Step (optional)",
113 | "max_temperature_dp": "Max Temperature (optional)",
114 | "min_temperature_dp": "Min Temperature (optional)",
115 | "precision": "Precision (optional, for DPs values)",
116 | "target_precision": "Target Precision (optional, for DPs values)",
117 | "temperature_unit": "Temperature Unit (optional)",
118 | "hvac_mode_dp": "HVAC Mode DP (optional)",
119 | "hvac_mode_set": "HVAC Mode Set (optional)",
120 | "hvac_action_dp": "HVAC Current Action DP (optional)",
121 | "hvac_action_set": "HVAC Current Action Set (optional)",
122 | "preset_dp": "Presets DP (optional)",
123 | "preset_set": "Presets Set (optional)",
124 | "eco_dp": "Eco DP (optional)",
125 | "eco_value": "Eco value (optional)",
126 | "heuristic_action": "Enable heuristic action (optional)",
127 | "dps_default_value": "Default value when un-initialised (optional)",
128 | "restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
129 | "min_value": "Minimum Value",
130 | "max_value": "Maximum Value",
131 | "step_size": "Minimum increment between numbers"
132 | }
133 | },
134 | "yaml_import": {
135 | "title": "Not Supported",
136 | "description": "Options cannot be edited when configured via YAML."
137 | }
138 | }
139 | },
140 | "title": "LocalTuya"
141 | }
142 |
--------------------------------------------------------------------------------
/custom_components/localtuya/switch.py:
--------------------------------------------------------------------------------
1 | """Platform to locally control Tuya-based switch devices."""
2 |
3 | import logging
4 | from functools import partial
5 | from .config_flow import col_to_select
6 |
7 | import voluptuous as vol
8 | from homeassistant.components.switch import (
9 | DOMAIN,
10 | SwitchEntity,
11 | DEVICE_CLASSES_SCHEMA,
12 | SwitchDeviceClass,
13 | )
14 | from homeassistant.const import CONF_DEVICE_CLASS
15 |
16 | from .entity import LocalTuyaEntity, async_setup_entry
17 | from .const import (
18 | ATTR_CURRENT,
19 | ATTR_CURRENT_CONSUMPTION,
20 | ATTR_STATE,
21 | ATTR_VOLTAGE,
22 | CONF_CURRENT,
23 | CONF_CURRENT_CONSUMPTION,
24 | CONF_DEFAULT_VALUE,
25 | CONF_PASSIVE_ENTITY,
26 | CONF_RESTORE_ON_RECONNECT,
27 | CONF_VOLTAGE,
28 | )
29 |
30 | _LOGGER = logging.getLogger(__name__)
31 |
32 |
33 | def flow_schema(dps):
34 | """Return schema used in config flow."""
35 | return {
36 | vol.Optional(CONF_CURRENT): col_to_select(dps, is_dps=True),
37 | vol.Optional(CONF_CURRENT_CONSUMPTION): col_to_select(dps, is_dps=True),
38 | vol.Optional(CONF_VOLTAGE): col_to_select(dps, is_dps=True),
39 | vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
40 | vol.Required(CONF_PASSIVE_ENTITY): bool,
41 | vol.Optional(CONF_DEFAULT_VALUE): str,
42 | vol.Optional(CONF_DEVICE_CLASS): col_to_select(
43 | [sc.value for sc in SwitchDeviceClass]
44 | ),
45 | }
46 |
47 |
48 | class LocalTuyaSwitch(LocalTuyaEntity, SwitchEntity):
49 | """Representation of a Tuya switch."""
50 |
51 | _attr_device_class = SwitchDeviceClass.SWITCH
52 |
53 | def __init__(
54 | self,
55 | device,
56 | config_entry,
57 | switchid,
58 | **kwargs,
59 | ):
60 | """Initialize the Tuya switch."""
61 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
62 | self._state = None
63 |
64 | @property
65 | def is_on(self):
66 | """Check if Tuya switch is on."""
67 | return self._state
68 |
69 | @property
70 | def extra_state_attributes(self):
71 | """Return device state attributes."""
72 | attrs = {}
73 | if self.has_config(CONF_CURRENT):
74 | attrs[ATTR_CURRENT] = self.dp_value(self._config[CONF_CURRENT])
75 | if self.has_config(CONF_CURRENT_CONSUMPTION):
76 | val_cc = self.dp_value(self._config[CONF_CURRENT_CONSUMPTION])
77 | attrs[ATTR_CURRENT_CONSUMPTION] = None if val_cc is None else val_cc / 10
78 | if self.has_config(CONF_VOLTAGE):
79 | val_vol = self.dp_value(self._config[CONF_VOLTAGE])
80 | attrs[ATTR_VOLTAGE] = None if val_vol is None else val_vol / 10
81 |
82 | # Store the state
83 | if self._state is not None:
84 | attrs[ATTR_STATE] = self._state
85 | elif self._last_state is not None:
86 | attrs[ATTR_STATE] = self._last_state
87 | return attrs
88 |
89 | async def async_turn_on(self, **kwargs):
90 | """Turn Tuya switch on."""
91 | await self._device.set_dp(True, self._dp_id)
92 |
93 | async def async_turn_off(self, **kwargs):
94 | """Turn Tuya switch off."""
95 | await self._device.set_dp(False, self._dp_id)
96 |
97 | # Default value is the "OFF" state
98 | def entity_default_value(self):
99 | """Return False as the default value for this entity type."""
100 | return False
101 |
102 |
103 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSwitch, flow_schema)
104 |
--------------------------------------------------------------------------------
/custom_components/localtuya/templates/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/custom_components/localtuya/templates/__init__.py
--------------------------------------------------------------------------------
/custom_components/localtuya/templates/sample_2g_switch.yaml:
--------------------------------------------------------------------------------
1 | # Simple 2 Switches config example
2 | - switch:
3 | id: "1"
4 | friendly_name: "2G Local Switch 1"
5 | entity_category: None
6 | restore_on_reconnect: false
7 | is_passive_entity: false
8 | platform: "switch"
9 |
10 | - switch:
11 | id: "2"
12 | friendly_name: "2G Local Switch 2"
13 | entity_category: None
14 | is_passive_entity: false
15 | platform: "switch"
16 | ####################################################
17 | #---# Templates Guide #---#
18 | ####################################################
19 | # Templates:
20 | # The template is basically ready to go configs can be imported instead of choosing configs DPs names etc...
21 |
22 | # IMPORTANT:
23 | # there is now valid check atm config so make sure you're importing correct configs.
24 |
25 | # the configs depends on the platform and what input does platform support read bottom.
26 |
27 | # THERE Is 2 ways to make template:
28 | # - 1st is write the yaml ur self:
29 | # --[ Keep in mind there is no valid check atm ]
30 |
31 | # - 2nd is to export ur device file from config flow. [ Recommended ]:
32 | # -- in HA Dashboard go to [ Devices -> localtuya -> Configure -> Edit Device * choose the device u want to export
33 | # --- Export the device config then submit]
34 |
35 | # Templates DIR:
36 | # the configs will be exported in [custom_components/localtuya/templates]
37 |
38 | # How to import:
39 | # -- When u add new device when the form [ Pick Entity type selection ]
40 | # --- Import template Form will show up showing available templates in templates folder.
41 |
42 | # -- templates in [custom_components/localtuya/templates]
43 | # -- Templates files will load up with HA so adding files will require restarting HA to show up.
44 |
--------------------------------------------------------------------------------
/custom_components/localtuya/templates/sample_lights_bulb.yaml:
--------------------------------------------------------------------------------
1 | - light:
2 | brightness: '22'
3 | brightness_lower: 29
4 | brightness_upper: 1000
5 | color: '24'
6 | color_mode: '21'
7 | color_temp: '23'
8 | color_temp_max_kelvin: 6500
9 | color_temp_min_kelvin: 2700
10 | color_temp_reverse: false
11 | entity_category: None
12 | friendly_name: test_light_35
13 | id: '20'
14 | music_mode: true
15 | platform: light
16 | scene: '25'
17 |
--------------------------------------------------------------------------------
/custom_components/localtuya/translations/ar.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "abort": {
4 | "already_configured": "تم تكوين هذا الحساب بالفعل.",
5 | "device_updated": "تم تحديث تكوين الجهاز."
6 | },
7 | "error": {
8 | "authentication_failed": "فشلت عملية المصادقة.\n{msg}",
9 | "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.",
10 | "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}",
11 | "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.",
12 | "unknown": "حدث خطأ غير معروف.\n{ex}.",
13 | "entity_already_configured": "تم تكوين هذه الكيان بالفعل.",
14 | "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.",
15 | "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).",
16 | "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)."
17 | },
18 | "step": {
19 | "user": {
20 | "title": "تكوين حساب Cloud API",
21 | "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.",
22 | "data": {
23 | "region": "منطقة مركز البيانات",
24 | "client_id": "معرف العميل (Client ID)",
25 | "client_secret": "المعرف السري العميل (Client Secret)",
26 | "user_id": "معرف المستخدم (UID)",
27 | "username": "اسم المستخدم",
28 | "no_cloud": "هل تريد تعطيل Cloud API؟"
29 | }
30 | }
31 | }
32 | },
33 | "options": {
34 | "abort": {
35 | "already_configured": "تم تكوين هذا الحساب بالفعل.",
36 | "device_success": "تم {action} الجهاز {dev_name} بنجاح.",
37 | "no_entities": "لا يمكن حذف كل الكيانات من الجهاز.\nإذا كنت ترغب في حذف الجهاز: انتقل إلى القائمة 'الأجهزة والخدمات'، ابحث عن جهازك في علامة التبويب 'الأجهزة'، انقر على 3 نقاط في الإطار 'معلومات الجهاز'، واضغط على زر 'حذف'."
38 | },
39 | "error": {
40 | "authentication_failed": "فشلت عملية المصادقة.\n{msg}",
41 | "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.",
42 | "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}",
43 | "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.",
44 | "unknown": "حدث خطأ غير معروف. \n{ex}.",
45 | "entity_already_configured": "تم تكوين هذه الكيان بالفعل.",
46 | "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.",
47 | "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).",
48 | "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)."
49 | },
50 | "step": {
51 | "yaml_import": {
52 | "title": "غير معتمد",
53 | "description": "الأجهزة المكونة باستخدام `YAML` لا يمكن تكوينها في واجهة المستخدم. احذف جهازك من `YAML` وأعِد إنشاؤه في واجهة المستخدم أو قم بتعديل تكوين `YAML` الخاص بك."
54 | },
55 | "init": {
56 | "title": "التكوين",
57 | "description": "حدد خيارًا للمتابعة.",
58 | "menu_options": {
59 | "add_device": "إضافة جهاز جديد",
60 | "edit_device": "إعادة تكوين الجهاز موجود",
61 | "configure_cloud": "إدارة حساب Cloud API"
62 | }
63 | },
64 | "add_device": {
65 | "title": "اختيار الجهاز للتكوين",
66 | "description": "يتم اكتشاف الأجهزة المتوافقة مع Tuya على شبكتك المحلية تلقائيًا بمجرد إعدادها في تطبيق Tuya. إذا لم تر الجهاز الذي تتوقعه، اختر `إضافة الجهاز يدويًا` من القائمة المنسدلة.",
67 | "data": {
68 | "selected_device": "الأجهزة المكتشفة",
69 | "mass_configure": "ضبط جميع الأجهزة المتعرف عليها تلقائيًا"
70 | }
71 | },
72 | "edit_device": {
73 | "title": "إعادة تكوين الجهاز الموجود",
74 | "description": "حدد الجهاز الذي ترغب في إعادة تكوينه.",
75 | "data": {
76 | "selected_device": "الأجهزة المكونة"
77 | }
78 | },
79 | "configure_cloud": {
80 | "title": "إدارة حساب Cloud API",
81 | "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.",
82 | "data": {
83 | "region": "منطقة مركز البيانات",
84 | "client_id": "معرف العميل (Client ID)",
85 | "client_secret": "المعرف السري العميل (Client Secret)",
86 | "user_id": "معرف المستخدم (UID)",
87 | "username": "اسم للحساب",
88 | "no_cloud": "هل تريد تعطيل Cloud API؟"
89 | }
90 | },
91 | "configure_device": {
92 | "title": "تكوين اتصال الجهاز",
93 | "description": "قم بتكوين أي تفاصيل جهاز {for_device} فارغة (إن وجدت) للسماح لـ LocalTuya بالاتصال بالجهاز.",
94 | "data": {
95 | "friendly_name": "اسم الجهاز",
96 | "host": "عنوان IP",
97 | "device_id": "معرف الجهاز",
98 | "local_key": "المفتاح المحلي (Local Key)",
99 | "node_id": "(اختياري) معرف الأجهزة الفرعية",
100 | "protocol_version": "إصدار البروتوكول",
101 | "enable_debug": "تمكين التصحيح (يجب تمكينه يدويًا في `configuration.yaml` أيضًا)",
102 | "scan_interval": "(اختياري) الفاصل الزمني للمسح بالثواني، إذا كان الجهاز لا يمسح تلقائيًا",
103 | "entities": "الكيانات التي تم تكوينها (قم بإلغاء التحديد للحذف)",
104 | "add_entities": "إضافة كيان (كيانات) جديدة",
105 | "manual_dps_strings": "(اختياري) دليل DPS، إذا لم يتم اكتشافه تلقائيًا (مفصولاً بفواصل)",
106 | "reset_dpids": "(اختياري) معرفات DPID لإرسالها في أمر RESET، إذا لم يستجب الجهاز لطلبات الحالة بعد التشغيل (مفصولة بفواصل)",
107 | "device_sleep_time": "(اختياري) وقت سبات الجهاز بالثواني: في حالة أن الجهاز يقوم بإرسال الحالة ثم يدخل في وضع السكون",
108 | "export_config": "احفظ تكوين الكيان كقالب"
109 | }
110 | },
111 | "device_setup_method": {
112 | "title": "تكوين كيانات الجهاز",
113 | "description": "سيحاول LocalTuya اكتشاف بقية التكوين تلقائيًا. ",
114 | "menu_options": {
115 | "auto_configure_device": "اكتشف كيانات الجهاز تلقائيًا",
116 | "pick_entity_type": "قم بتكوين كيانات الجهاز يدويًا",
117 | "choose_template": "استخدم القالب المحفوظ"
118 | }
119 | },
120 | "auto_configure_device": {
121 | "title": "التكوين التلقائي",
122 | "description": "حدث خطأ: {err_msg}. ",
123 | "menu_options": {
124 | "device_setup_method": "العودة إلى طريقة الإعداد"
125 | }
126 | },
127 | "pick_entity_type": {
128 | "title": "اختيار نوع الكيان",
129 | "description": "اختر نوع الكيان الذي تريد إضافته.",
130 | "data": {
131 | "platform_to_add": "اختر الكيان",
132 | "no_additional_entities": "الانتهاء من تكوين الكيانات",
133 | "use_template": "استيراد ملف القالب"
134 | }
135 | },
136 | "choose_template": {
137 | "title": "استيراد ملف القالب",
138 | "description": "توجد ملفات القالب في المجلد `templates` ([لمعلومات أكثر](https://github.com/xZetsubou/hass-localtuya/discussions/13)).",
139 | "data": {
140 | "templates": "اختيار القالب"
141 | }
142 | }
143 | }
144 | },
145 | "title": "LocalTuya"
146 | }
--------------------------------------------------------------------------------
/custom_components/localtuya/water_heater.py:
--------------------------------------------------------------------------------
1 | """Platform to locally control Tuya-based WaterHeater devices."""
2 |
3 | import logging
4 | from functools import partial
5 | from .config_flow import col_to_select
6 | from homeassistant.helpers.selector import ObjectSelector
7 |
8 | import voluptuous as vol
9 | from homeassistant.components.water_heater import (
10 | DEFAULT_MIN_TEMP,
11 | DEFAULT_MAX_TEMP,
12 | DOMAIN,
13 | WaterHeaterEntity,
14 | WaterHeaterEntityFeature,
15 | )
16 | from homeassistant.components.water_heater.const import (
17 | STATE_ECO,
18 | STATE_ELECTRIC,
19 | STATE_PERFORMANCE,
20 | STATE_HIGH_DEMAND,
21 | STATE_HEAT_PUMP,
22 | STATE_GAS,
23 | )
24 | from homeassistant.const import (
25 | ATTR_TEMPERATURE,
26 | CONF_TEMPERATURE_UNIT,
27 | PRECISION_HALVES,
28 | PRECISION_TENTHS,
29 | PRECISION_WHOLE,
30 | UnitOfTemperature,
31 | )
32 | from .entity import LocalTuyaEntity, async_setup_entry
33 | from .const import (
34 | CONF_TARGET_TEMPERATURE_DP,
35 | CONF_CURRENT_TEMPERATURE_DP,
36 | CONF_MIN_TEMP,
37 | CONF_MAX_TEMP,
38 | CONF_PRECISION,
39 | CONF_TARGET_PRECISION,
40 | CONF_MODE_DP,
41 | CONF_MODES,
42 | CONF_TARGET_TEMPERATURE_LOW_DP,
43 | CONF_TARGET_TEMPERATURE_HIGH_DP,
44 | DictSelector,
45 | )
46 |
47 | _LOGGER = logging.getLogger(__name__)
48 |
49 |
50 | TEMPERATURE_CELSIUS = "celsius"
51 | TEMPERATURE_FAHRENHEIT = "fahrenheit"
52 |
53 | DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS
54 | DEFAULT_PRECISION = PRECISION_TENTHS
55 | DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES
56 | PERCISION_SET = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
57 |
58 | OFF_MODE = "Off"
59 |
60 |
61 | def flow_schema(dps):
62 | """Return schema used in config flow."""
63 | return {
64 | vol.Optional(CONF_TARGET_TEMPERATURE_DP): col_to_select(dps, is_dps=True),
65 | vol.Optional(CONF_TARGET_TEMPERATURE_LOW_DP): col_to_select(dps, is_dps=True),
66 | vol.Optional(CONF_TARGET_TEMPERATURE_HIGH_DP): col_to_select(dps, is_dps=True),
67 | vol.Optional(CONF_CURRENT_TEMPERATURE_DP): col_to_select(dps, is_dps=True),
68 | vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
69 | vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
70 | vol.Optional(CONF_PRECISION, default=str(DEFAULT_PRECISION)): col_to_select(
71 | PERCISION_SET
72 | ),
73 | vol.Optional(
74 | CONF_TARGET_PRECISION, default=str(DEFAULT_PRECISION)
75 | ): col_to_select(PERCISION_SET),
76 | vol.Optional(CONF_MODE_DP): col_to_select(dps, is_dps=True),
77 | vol.Optional(CONF_MODES, default={}): ObjectSelector(),
78 | vol.Optional(CONF_TEMPERATURE_UNIT): col_to_select(
79 | [TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT]
80 | ),
81 | }
82 |
83 |
84 | def config_unit(unit):
85 | if unit == TEMPERATURE_FAHRENHEIT:
86 | return UnitOfTemperature.FAHRENHEIT
87 | else:
88 | return UnitOfTemperature.CELSIUS
89 |
90 |
91 | class LocalTuyaWaterHeater(LocalTuyaEntity, WaterHeaterEntity):
92 | """Tuya WaterHeater device."""
93 |
94 | _enable_turn_on_off_backwards_compatibility = False
95 | _attr_current_operation = False
96 |
97 | def __init__(
98 | self,
99 | device,
100 | config_entry,
101 | switchid,
102 | **kwargs,
103 | ):
104 | """Initialize a new LocalTuyaWaterHeater."""
105 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
106 | self._state = None
107 | self._target_temperature = None
108 | self._current_temperature = None
109 | self._dp_mode = self._config.get(CONF_MODE_DP, None)
110 |
111 | self._available_modes = DictSelector(self._config.get(CONF_MODES, {}))
112 |
113 | self._precision = float(self._config.get(CONF_PRECISION, DEFAULT_PRECISION))
114 | self._precision_target = float(
115 | self._config.get(CONF_TARGET_PRECISION, DEFAULT_PRECISION)
116 | )
117 |
118 | @property
119 | def supported_features(self):
120 | """Flag supported features."""
121 | supported_features = WaterHeaterEntityFeature(0)
122 | if self.has_config(CONF_TARGET_TEMPERATURE_DP):
123 | supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE
124 | if self.has_config(CONF_MODE_DP):
125 | supported_features |= WaterHeaterEntityFeature.OPERATION_MODE
126 |
127 | supported_features |= WaterHeaterEntityFeature.ON_OFF
128 |
129 | return supported_features
130 |
131 | @property
132 | def precision(self):
133 | """Return the precision of the system."""
134 | return self._precision
135 |
136 | @property
137 | def temperature_unit(self):
138 | """Return the unit of measurement used by the platform."""
139 | return config_unit(self._config.get(CONF_TEMPERATURE_UNIT))
140 |
141 | @property
142 | def min_temp(self):
143 | """Return the minimum temperature."""
144 | return self._config.get(CONF_MIN_TEMP, DEFAULT_MIN_TEMP)
145 |
146 | @property
147 | def max_temp(self):
148 | """Return the maximum temperature."""
149 | return self._config.get(CONF_MAX_TEMP, DEFAULT_MAX_TEMP)
150 |
151 | @property
152 | def operation_list(self) -> list[str] | None:
153 | """Return the list of available operation modes."""
154 | return self._available_modes.names + [OFF_MODE]
155 |
156 | @property
157 | def current_temperature(self):
158 | """Return the current temperature."""
159 | return self._current_temperature
160 |
161 | @property
162 | def target_temperature(self):
163 | """Return the temperature we try to reach."""
164 | return self._target_temperature
165 |
166 | @property
167 | def target_temperature_high(self) -> float | None:
168 | """Return the highbound target temperature we try to reach."""
169 | return self._attr_target_temperature_high
170 |
171 | @property
172 | def target_temperature_low(self) -> float | None:
173 | """Return the lowbound target temperature we try to reach."""
174 | return self._attr_target_temperature_low
175 |
176 | async def async_set_temperature(self, **kwargs):
177 | """Set new target temperature."""
178 | if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP):
179 | temperature = kwargs[ATTR_TEMPERATURE]
180 |
181 | temperature = round(temperature / self._precision_target)
182 | await self._device.set_dp(
183 | temperature, self._config[CONF_TARGET_TEMPERATURE_DP]
184 | )
185 |
186 | async def async_set_operation_mode(self, operation_mode: str) -> None:
187 | """Set new target operation mode."""
188 | status = {}
189 | if operation_mode == OFF_MODE:
190 | return await self.async_turn_off()
191 | elif not self._state:
192 | status[self._dp_id] = True
193 |
194 | status[self._dp_mode] = self._available_modes.to_tuya(operation_mode)
195 | await self._device.set_dps(status)
196 |
197 | async def async_turn_on(self) -> None:
198 | """Turn the entity on."""
199 | await self._device.set_dp(True, self._dp_id)
200 |
201 | async def async_turn_off(self) -> None:
202 | """Turn the entity off."""
203 | await self._device.set_dp(False, self._dp_id)
204 |
205 | def status_updated(self):
206 | """Device status was updated."""
207 | self._state = self.dp_value(self._dp_id)
208 |
209 | # Update target temperature
210 | if self.has_config(CONF_TARGET_TEMPERATURE_DP):
211 | self._target_temperature = (
212 | self.dp_value(CONF_TARGET_TEMPERATURE_DP) * self._precision_target
213 | )
214 |
215 | # Update current temperature
216 | if self.has_config(CONF_CURRENT_TEMPERATURE_DP):
217 | self._current_temperature = (
218 | self.dp_value(CONF_CURRENT_TEMPERATURE_DP) * self._precision
219 | )
220 |
221 | # Update modes states
222 | if not self._state:
223 | self._attr_current_operation = OFF_MODE
224 | elif self._dp_mode is not None and (mode := self.dp_value(CONF_MODE_DP)):
225 | self._attr_current_operation = self._available_modes.to_ha(mode)
226 |
227 | if (
228 | target_high := self.dp_value(CONF_TARGET_TEMPERATURE_HIGH_DP)
229 | ) or target_high is not None:
230 | self._attr_target_temperature_high = target_high
231 |
232 | if (
233 | target_low := self.dp_value(CONF_TARGET_TEMPERATURE_LOW_DP)
234 | ) or target_low is not None:
235 | self._attr_target_temperature_low = target_low
236 |
237 |
238 | async_setup_entry = partial(
239 | async_setup_entry, DOMAIN, LocalTuyaWaterHeater, flow_schema
240 | )
241 |
--------------------------------------------------------------------------------
/documentation/docs/auto_configure.md:
--------------------------------------------------------------------------------
1 | # Auto configure devices
2 | Localtuya can disocver you device entities if cloud is enable because the feature at the moment rely on `DP code` and [Devices Category](https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq#title-6-List%20of%20category%20code){target="_blank"}.
3 |
4 | By known the `category` we use that to get all the possible entities from stored data.