Skip to content

Commit a07568d

Browse files
authored
Merge pull request #28 from forabi/main
Fix support for BLE TRV
2 parents d84d24c + 4847089 commit a07568d

File tree

11 files changed

+478
-198
lines changed

11 files changed

+478
-198
lines changed

custom_components/tuya_ble/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Platform.CLIMATE,
2424
Platform.NUMBER,
2525
Platform.SENSOR,
26+
Platform.BINARY_SENSOR,
2627
Platform.SELECT,
2728
Platform.SWITCH,
2829
]
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""The Tuya BLE integration."""
2+
from __future__ import annotations
3+
4+
from dataclasses import dataclass
5+
6+
import logging
7+
from typing import Callable
8+
9+
from homeassistant.components.binary_sensor import (
10+
BinarySensorDeviceClass,
11+
BinarySensorEntity,
12+
BinarySensorEntityDescription,
13+
)
14+
from homeassistant.config_entries import ConfigEntry
15+
from homeassistant.core import HomeAssistant, callback
16+
from homeassistant.helpers.entity import EntityCategory
17+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
18+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
19+
20+
from .const import (
21+
DOMAIN,
22+
)
23+
from .devices import TuyaBLEData, TuyaBLEEntity, TuyaBLEProductInfo
24+
from .tuya_ble import TuyaBLEDataPointType, TuyaBLEDevice
25+
26+
_LOGGER = logging.getLogger(__name__)
27+
28+
SIGNAL_STRENGTH_DP_ID = -1
29+
30+
31+
TuyaBLEBinarySensorIsAvailable = (
32+
Callable[["TuyaBLEBinarySensor", TuyaBLEProductInfo], bool] | None
33+
)
34+
35+
36+
@dataclass
37+
class TuyaBLEBinarySensorMapping:
38+
dp_id: int
39+
description: BinarySensorEntityDescription
40+
force_add: bool = True
41+
dp_type: TuyaBLEDataPointType | None = None
42+
getter: Callable[[TuyaBLEBinarySensor], None] | None = None
43+
coefficient: float = 1.0
44+
icons: list[str] | None = None
45+
is_available: TuyaBLEBinarySensorIsAvailable = None
46+
47+
48+
@dataclass
49+
class TuyaBLECategoryBinarySensorMapping:
50+
products: dict[str, list[TuyaBLEBinarySensorMapping]] | None = None
51+
mapping: list[TuyaBLEBinarySensorMapping] | None = None
52+
53+
54+
mapping: dict[str, TuyaBLECategoryBinarySensorMapping] = {
55+
"wk": TuyaBLECategoryBinarySensorMapping(
56+
products={
57+
"drlajpqc": [ # Thermostatic Radiator Valve
58+
TuyaBLEBinarySensorMapping(
59+
dp_id=105,
60+
description=BinarySensorEntityDescription(
61+
key="low_battery",
62+
icon="mdi:battery-alert",
63+
device_class=BinarySensorDeviceClass.BATTERY,
64+
entity_category=EntityCategory.DIAGNOSTIC,
65+
entity_registry_enabled_default=True,
66+
),
67+
),
68+
],
69+
},
70+
),
71+
}
72+
73+
74+
def get_mapping_by_device(device: TuyaBLEDevice) -> list[TuyaBLEBinarySensorMapping]:
75+
category = mapping.get(device.category)
76+
if category is not None and category.products is not None:
77+
product_mapping = category.products.get(device.product_id)
78+
if product_mapping is not None:
79+
return product_mapping
80+
if category.mapping is not None:
81+
return category.mapping
82+
else:
83+
return []
84+
else:
85+
return []
86+
87+
88+
class TuyaBLEBinarySensor(TuyaBLEEntity, BinarySensorEntity):
89+
"""Representation of a Tuya BLE binary sensor."""
90+
91+
def __init__(
92+
self,
93+
hass: HomeAssistant,
94+
coordinator: DataUpdateCoordinator,
95+
device: TuyaBLEDevice,
96+
product: TuyaBLEProductInfo,
97+
mapping: TuyaBLEBinarySensorMapping,
98+
) -> None:
99+
super().__init__(hass, coordinator, device, product, mapping.description)
100+
self._mapping = mapping
101+
102+
@callback
103+
def _handle_coordinator_update(self) -> None:
104+
"""Handle updated data from the coordinator."""
105+
if self._mapping.getter is not None:
106+
self._mapping.getter(self)
107+
else:
108+
datapoint = self._device.datapoints[self._mapping.dp_id]
109+
if datapoint:
110+
if datapoint.type == TuyaBLEDataPointType.DT_ENUM:
111+
if self.entity_description.options is not None:
112+
if datapoint.value >= 0 and datapoint.value < len(
113+
self.entity_description.options
114+
):
115+
self._attr_native_value = self.entity_description.options[
116+
datapoint.value
117+
]
118+
else:
119+
self._attr_native_value = datapoint.value
120+
if self._mapping.icons is not None:
121+
if datapoint.value >= 0 and datapoint.value < len(
122+
self._mapping.icons
123+
):
124+
self._attr_icon = self._mapping.icons[datapoint.value]
125+
elif datapoint.type == TuyaBLEDataPointType.DT_VALUE:
126+
self._attr_native_value = (
127+
datapoint.value / self._mapping.coefficient
128+
)
129+
else:
130+
self._attr_native_value = datapoint.value
131+
self.async_write_ha_state()
132+
133+
@property
134+
def available(self) -> bool:
135+
"""Return if entity is available."""
136+
result = super().available
137+
if result and self._mapping.is_available:
138+
result = self._mapping.is_available(self, self._product)
139+
return result
140+
141+
142+
async def async_setup_entry(
143+
hass: HomeAssistant,
144+
entry: ConfigEntry,
145+
async_add_entities: AddEntitiesCallback,
146+
) -> None:
147+
"""Set up the Tuya BLE sensors."""
148+
data: TuyaBLEData = hass.data[DOMAIN][entry.entry_id]
149+
mappings = get_mapping_by_device(data.device)
150+
entities: list[TuyaBLEBinarySensor] = []
151+
for mapping in mappings:
152+
if mapping.force_add or data.device.datapoints.has_id(
153+
mapping.dp_id, mapping.dp_type
154+
):
155+
entities.append(
156+
TuyaBLEBinarySensor(
157+
hass,
158+
data.coordinator,
159+
data.device,
160+
data.product,
161+
mapping,
162+
)
163+
)
164+
async_add_entities(entities)

custom_components/tuya_ble/climate.py

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from homeassistant.components.climate.const import (
1414
ClimateEntityFeature,
1515
HVACMode,
16+
HVACAction,
1617
PRESET_AWAY,
1718
PRESET_NONE,
1819
)
@@ -24,7 +25,7 @@
2425

2526
from .const import DOMAIN
2627
from .devices import TuyaBLEData, TuyaBLEEntity, TuyaBLEProductInfo
27-
from .tuya_ble import TuyaBLEDataPointType, TuyaBLEDevice
28+
from .tuya_ble import TuyaBLEDataPoint, TuyaBLEDataPointType, TuyaBLEDevice
2829

2930
_LOGGER = logging.getLogger(__name__)
3031

@@ -47,7 +48,7 @@ class TuyaBLEClimateMapping:
4748
target_temperature_dp_id: int = 0
4849
target_temperature_coefficient: float = 1.0
4950
target_temperature_max: float = 30.0
50-
target_temperature_min: float = -20.0
51+
target_temperature_min: float = 5
5152
target_temperature_step: float = 1.0
5253

5354
current_humidity_dp_id: int = 0
@@ -67,18 +68,57 @@ class TuyaBLECategoryClimateMapping:
6768
mapping: dict[str, TuyaBLECategoryClimateMapping] = {
6869
"wk": TuyaBLECategoryClimateMapping(
6970
products={
70-
"drlajpqc": [ # Thermostatic Radiator Valve
71+
"drlajpqc": [
72+
# Thermostatic Radiator Valve
73+
# - [x] 8 - Window
74+
# - [x] 10 - Antifreeze
75+
# - [x] 27 - Calibration
76+
# - [x] 40 - Lock
77+
# - [x] 101 - Switch
78+
# - [x] 102 - Current
79+
# - [x] 103 - Target
80+
# - [ ] 104 - Heating time
81+
# - [x] 105 - Battery power alarm
82+
# - [x] 106 - Away
83+
# - [x] 107 - Programming mode
84+
# - [x] 108 - Programming switch
85+
# - [ ] 109 - Programming data (deprecated - do not delete)
86+
# - [ ] 110 - Historical data protocol (Day-Target temperature)
87+
# - [ ] 111 - System Time Synchronization
88+
# - [ ] 112 - Historical data (Week-Target temperature)
89+
# - [ ] 113 - Historical data (Month-Target temperature)
90+
# - [ ] 114 - Historical data (Year-Target temperature)
91+
# - [ ] 115 - Historical data (Day-Current temperature)
92+
# - [ ] 116 - Historical data (Week-Current temperature)
93+
# - [ ] 117 - Historical data (Month-Current temperature)
94+
# - [ ] 118 - Historical data (Year-Current temperature)
95+
# - [ ] 119 - Historical data (Day-motor opening degree)
96+
# - [ ] 120 - Historical data (Week-motor opening degree)
97+
# - [ ] 121 - Historical data (Month-motor opening degree)
98+
# - [ ] 122 - Historical data (Year-motor opening degree)
99+
# - [ ] 123 - Programming data (Monday)
100+
# - [ ] 124 - Programming data (Tuseday)
101+
# - [ ] 125 - Programming data (Wednesday)
102+
# - [ ] 126 - Programming data (Thursday)
103+
# - [ ] 127 - Programming data (Friday)
104+
# - [ ] 128 - Programming data (Saturday)
105+
# - [ ] 129 - Programming data (Sunday)
106+
# - [x] 130 - Water scale
71107
TuyaBLEClimateMapping(
72108
description=ClimateEntityDescription(
73109
key="thermostatic_radiator_valve",
74110
),
75-
hvac_switch_dp_id=17,
111+
hvac_switch_dp_id=101,
76112
hvac_switch_mode=HVACMode.HEAT,
77-
# preset_mode_dp_ids={PRESET_AWAY: 106},
113+
hvac_modes=[HVACMode.OFF, HVACMode.HEAT],
114+
preset_mode_dp_ids={PRESET_AWAY: 106, PRESET_NONE: 106},
78115
current_temperature_dp_id=102,
79116
current_temperature_coefficient=10.0,
117+
target_temperature_coefficient=10.0,
118+
target_temperature_step=0.5,
80119
target_temperature_dp_id=103,
81120
target_temperature_min=5.0,
121+
target_temperature_max=30.0,
82122
),
83123
],
84124
},
@@ -113,7 +153,9 @@ def __init__(
113153
) -> None:
114154
super().__init__(hass, coordinator, device, product, mapping.description)
115155
self._mapping = mapping
116-
self._attr_hvac_mode = HVACMode.OFF
156+
self._attr_hvac_mode = HVACMode.HEAT
157+
self._attr_preset_mode = PRESET_NONE
158+
self._attr_hvac_action = HVACAction.HEATING
117159

118160
if mapping.hvac_mode_dp_id and mapping.hvac_modes:
119161
self._attr_hvac_modes = mapping.hvac_modes
@@ -185,20 +227,32 @@ def _handle_coordinator_update(self) -> None:
185227

186228
if self._mapping.preset_mode_dp_ids:
187229
current_preset_mode = PRESET_NONE
188-
for preset_mode, dp_id in self._mapping.preset_mode_dp_ids:
230+
for preset_mode, dp_id in self._mapping.preset_mode_dp_ids.items():
189231
datapoint = self._device.datapoints[dp_id]
190232
if datapoint and datapoint.value:
191233
current_preset_mode = preset_mode
192234
break
193235
self._attr_preset_mode = current_preset_mode
194236

237+
try:
238+
if (
239+
self._attr_preset_mode == PRESET_AWAY
240+
or self._attr_hvac_mode == HVACMode.OFF
241+
or self._attr_target_temperature <= self._attr_current_temperature
242+
):
243+
self._attr_hvac_action = HVACAction.IDLE
244+
else:
245+
self._attr_hvac_action = HVACAction.HEATING
246+
except:
247+
pass
248+
195249
self.async_write_ha_state()
196250

197251
async def async_set_temperature(self, **kwargs) -> None:
198252
"""Set new target temperature."""
199253
if self._mapping.target_temperature_dp_id != 0:
200254
int_value = int(
201-
kwargs["temperature"] * self._mapping.target_humidity_coefficient
255+
kwargs["temperature"] * self._mapping.target_temperature_coefficient
202256
)
203257
datapoint = self._device.datapoints.get_or_create(
204258
self._mapping.target_temperature_dp_id,
@@ -248,15 +302,37 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
248302
async def async_set_preset_mode(self, preset_mode: str) -> None:
249303
"""Set new preset mode."""
250304
if self._mapping.preset_mode_dp_ids:
251-
for dp_preset_mode, dp_id in self._mapping.preset_mode_dp_ids:
252-
bool_value = dp_preset_mode == preset_mode
253-
datapoint = self._device.datapoints.get_or_create(
254-
dp_id,
255-
TuyaBLEDataPointType.DT_BOOL,
256-
bool_value,
257-
)
258-
if datapoint:
259-
self._hass.create_task(datapoint.set_value(bool_value))
305+
datapoint: TuyaBLEDataPoint | None = None
306+
bool_value = False
307+
308+
keys = [x for x in self._mapping.preset_mode_dp_ids.keys()]
309+
values = [
310+
x for x in self._mapping.preset_mode_dp_ids.values()
311+
] # Get all DP IDs
312+
# TRVs with only Away and None modes can be set with a single datapoint and use a single DP ID
313+
if all(values[0] == elem for elem in values) and keys[0] == PRESET_AWAY:
314+
for dp_id in values:
315+
bool_value = preset_mode == PRESET_AWAY
316+
datapoint = self._device.datapoints.get_or_create(
317+
dp_id,
318+
TuyaBLEDataPointType.DT_BOOL,
319+
bool_value,
320+
)
321+
break
322+
else:
323+
if self._mapping.preset_mode_dp_ids:
324+
for (
325+
dp_preset_mode,
326+
dp_id,
327+
) in self._mapping.preset_mode_dp_ids.items():
328+
bool_value = dp_preset_mode == preset_mode
329+
datapoint = self._device.datapoints.get_or_create(
330+
dp_id,
331+
TuyaBLEDataPointType.DT_BOOL,
332+
bool_value,
333+
)
334+
if datapoint:
335+
self._hass.create_task(datapoint.set_value(bool_value))
260336

261337

262338
async def async_setup_entry(

custom_components/tuya_ble/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@
3737
FINGERBOT_MODE_PUSH: Final = "push"
3838
FINGERBOT_MODE_SWITCH: Final = "switch"
3939
FINGERBOT_BUTTON_EVENT: Final = "fingerbot_button_pressed"
40+

custom_components/tuya_ble/devices.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
99
from homeassistant.helpers import device_registry as dr
10-
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
10+
from homeassistant.helpers.entity import (
11+
DeviceInfo,
12+
EntityDescription,
13+
generate_entity_id,
14+
)
1115
from homeassistant.helpers.event import async_call_later
1216
from homeassistant.helpers.update_coordinator import (
1317
CoordinatorEntity,
@@ -73,6 +77,9 @@ def __init__(
7377
self._attr_has_entity_name = True
7478
self._attr_device_info = get_device_info(self._device)
7579
self._attr_unique_id = f"{self._device.device_id}-{description.key}"
80+
self.entity_id = generate_entity_id(
81+
"sensor.{}", self._attr_unique_id, hass=hass
82+
)
7683

7784
@property
7885
def available(self) -> bool:

custom_components/tuya_ble/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"config_flow": true,
1212
"dependencies": ["bluetooth_adapters", "tuya"],
1313
"documentation": "https://www.home-assistant.io/integrations/tuya_ble",
14-
"requirements": ["tuya-iot-py-sdk==0.6.6", "pycountry"],
14+
"requirements": ["tuya-iot-py-sdk==0.6.6", "pycountry==22.3.5"],
1515
"iot_class": "local_push",
1616
"version": "0.1.6"
1717
}

0 commit comments

Comments
 (0)