light.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. """
  2. Setup for different kinds of Tuya light devices
  3. """
  4. import logging
  5. from struct import error as StructError
  6. from struct import pack, unpack
  7. import homeassistant.util.color as color_util
  8. from homeassistant.components.light import (
  9. ATTR_BRIGHTNESS,
  10. ATTR_COLOR_TEMP_KELVIN,
  11. ATTR_EFFECT,
  12. ATTR_HS_COLOR,
  13. ATTR_WHITE,
  14. EFFECT_OFF,
  15. ColorMode,
  16. LightEntity,
  17. LightEntityFeature,
  18. )
  19. from .device import TuyaLocalDevice
  20. from .entity import TuyaLocalEntity
  21. from .helpers.config import async_tuya_setup_platform
  22. from .helpers.device_config import TuyaEntityConfig
  23. _LOGGER = logging.getLogger(__name__)
  24. async def async_setup_entry(hass, config_entry, async_add_entities):
  25. config = {**config_entry.data, **config_entry.options}
  26. await async_tuya_setup_platform(
  27. hass,
  28. async_add_entities,
  29. config,
  30. "light",
  31. TuyaLocalLight,
  32. )
  33. def _ha_brightness_to_dp_value(ha_brightness, dp_range):
  34. """Convert HA brightness to a clamped device DP value."""
  35. if ha_brightness == 1 and dp_range[0] != 0:
  36. return dp_range[0]
  37. dp_value = color_util.brightness_to_value(dp_range, ha_brightness)
  38. return max(dp_range[0], dp_value)
  39. class TuyaLocalLight(TuyaLocalEntity, LightEntity):
  40. """Representation of a Tuya WiFi-connected light."""
  41. def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
  42. """
  43. Initialize the light.
  44. Args:
  45. device (TuyaLocalDevice): The device API instance.
  46. config (TuyaEntityConfig): The configuration for this entity.
  47. """
  48. super().__init__()
  49. dps_map = self._init_begin(device, config)
  50. self._switch_dps = dps_map.pop("switch", None)
  51. self._brightness_dps = dps_map.pop("brightness", None)
  52. self._color_mode_dps = dps_map.pop("color_mode", None)
  53. self._color_temp_dps = dps_map.pop("color_temp", None)
  54. self._rgbhsv_dps = dps_map.pop("rgbhsv", None)
  55. self._named_color_dps = dps_map.pop("named_color", None)
  56. self._effect_dps = dps_map.pop("effect", None)
  57. self._init_end(dps_map)
  58. # Set min and max color temp
  59. if self._color_temp_dps:
  60. range_set = False
  61. m = self._color_temp_dps._find_map_for_dps(0, self._device)
  62. if m:
  63. tr = m.get("target_range")
  64. if tr:
  65. # Target range can be inverted, so use min/max functions to ensure correct order
  66. self._attr_min_color_temp_kelvin = min(tr.get("min"), tr.get("max"))
  67. self._attr_max_color_temp_kelvin = max(tr.get("min"), tr.get("max"))
  68. range_set = True
  69. if not range_set:
  70. r = self._color_temp_dps.range(self._device)
  71. if r:
  72. # For lights that use K natively, use range
  73. self._attr_min_color_temp_kelvin = r[0]
  74. self._attr_max_color_temp_kelvin = r[1]
  75. @property
  76. def supported_color_modes(self):
  77. """Return the supported color modes for this light."""
  78. if self._color_mode_dps:
  79. return {
  80. ColorMode(mode)
  81. for mode in self._color_mode_dps.values(self._device)
  82. if mode and hasattr(ColorMode, mode.upper())
  83. }
  84. else:
  85. try:
  86. mode = ColorMode(self.color_mode)
  87. if mode and mode != ColorMode.UNKNOWN:
  88. return {mode}
  89. except ValueError:
  90. _LOGGER.warning(
  91. "%s/%s: Unrecognised color mode %s ignored",
  92. self._config._device.config,
  93. self.name or "light",
  94. self.color_mode,
  95. )
  96. @property
  97. def supported_features(self):
  98. """Return the supported features for this light."""
  99. if self.effect_list:
  100. return LightEntityFeature.EFFECT
  101. else:
  102. return LightEntityFeature(0)
  103. @property
  104. def color_mode(self):
  105. """Return the color mode of the light"""
  106. from_dp = self.raw_color_mode
  107. if from_dp:
  108. return from_dp
  109. if self._rgbhsv_dps:
  110. return ColorMode.HS
  111. elif self._named_color_dps:
  112. return ColorMode.HS
  113. elif self._color_temp_dps:
  114. return ColorMode.COLOR_TEMP
  115. elif self._brightness_dps:
  116. return ColorMode.BRIGHTNESS
  117. elif self._switch_dps:
  118. return ColorMode.ONOFF
  119. else:
  120. return ColorMode.UNKNOWN
  121. @property
  122. def raw_color_mode(self):
  123. """Return the color_mode as set from the dps."""
  124. if self._color_mode_dps:
  125. mode = self._color_mode_dps.get_value(self._device)
  126. if mode and hasattr(ColorMode, mode.upper()):
  127. return ColorMode(mode)
  128. @property
  129. def color_temp_kelvin(self):
  130. """Return the color temperature in kelvin."""
  131. if self._color_temp_dps and self.color_mode != ColorMode.HS:
  132. return self._color_temp_dps.get_value(self._device)
  133. @property
  134. def is_on(self):
  135. """Return the current state."""
  136. if self._switch_dps:
  137. return self._switch_dps.get_value(self._device)
  138. elif self._brightness_dps:
  139. b = self.brightness
  140. return isinstance(b, int) and b > 0
  141. elif self._effect_dps and "off" in self._effect_dps.values(self._device):
  142. return self._effect_dps.get_value(self._device) != "off"
  143. else:
  144. # There shouldn't be lights without control, but if there are,
  145. # assume always on if they are responding
  146. return self.available
  147. def _brightness_control_by_hsv(self, target_mode=None):
  148. """Return whether brightness is controlled by HSV."""
  149. v_available = self._rgbhsv_dps and "v" in self._rgbhsv_dps.format["names"]
  150. b_available = self._brightness_dps is not None
  151. current_raw_mode = target_mode or self.raw_color_mode
  152. current_mode = target_mode or self.color_mode
  153. if current_raw_mode == ColorMode.HS and v_available:
  154. return True
  155. if current_raw_mode is None and current_mode == ColorMode.HS and v_available:
  156. return True
  157. if b_available:
  158. return False
  159. return v_available
  160. @property
  161. def brightness(self):
  162. """Get the current brightness of the light"""
  163. if self._brightness_control_by_hsv():
  164. return self._hsv_brightness
  165. return self._white_brightness
  166. @property
  167. def _effective_brightness_range(self):
  168. """Get the effective brightness range of the light"""
  169. if self._brightness_dps:
  170. r = self._brightness_dps.range(self._device)
  171. if r:
  172. if self._switch_dps is None and r[0] == 0:
  173. # If the light has no switch, and the brightness range starts
  174. # at 0, the effective minimum brightness is 1
  175. return (self._brightness_dps.step(self._device, False), r[1])
  176. return r
  177. @property
  178. def _white_brightness(self):
  179. if self._brightness_dps:
  180. r = self._effective_brightness_range
  181. val = self._brightness_dps.get_value(self._device)
  182. if r and val:
  183. val = color_util.value_to_brightness(r, val)
  184. return val
  185. @property
  186. def _unpacked_rgbhsv(self):
  187. """Get the unpacked rgbhsv data"""
  188. if self._rgbhsv_dps:
  189. color = self._rgbhsv_dps.decoded_value(self._device)
  190. fmt = self._rgbhsv_dps.format
  191. if fmt and color:
  192. try:
  193. vals = unpack(fmt.get("format"), color)
  194. idx = 0
  195. rgbhsv = {}
  196. for v in vals:
  197. # HA range: s = 0-100, rgbv = 0-255, h = 0-360
  198. n = fmt["names"][idx]
  199. r = fmt["ranges"][idx]
  200. mx = r["max"]
  201. scale = 1
  202. if n == "h":
  203. scale = 360 / mx
  204. elif n == "s":
  205. scale = 100 / mx
  206. elif n in ["v", "r", "g", "b"]:
  207. scale = 255 / mx
  208. rgbhsv[n] = round(scale * v)
  209. idx += 1
  210. return rgbhsv
  211. except StructError as e:
  212. _LOGGER.warning(
  213. "%s/%s: Failed to unpack rgbhsv data: %s",
  214. self._config._device.config,
  215. self.name or "light",
  216. e,
  217. )
  218. elif self._named_color_dps:
  219. colour = self._named_color_dps.get_value(self._device)
  220. if colour:
  221. rgb = color_util.color_name_to_rgb(colour)
  222. return {"r": rgb[0], "g": rgb[1], "b": rgb[2]}
  223. @property
  224. def _hsv_brightness(self):
  225. """Get the colour mode brightness from the light"""
  226. rgbhsv = self._unpacked_rgbhsv
  227. if rgbhsv:
  228. return rgbhsv.get("v", self._white_brightness)
  229. return self._white_brightness
  230. @property
  231. def hs_color(self):
  232. """Get the current hs color of the light"""
  233. rgbhsv = self._unpacked_rgbhsv
  234. if rgbhsv:
  235. if "h" in rgbhsv and "s" in rgbhsv:
  236. hs = (rgbhsv["h"], rgbhsv["s"])
  237. else:
  238. r = rgbhsv.get("r")
  239. g = rgbhsv.get("g")
  240. b = rgbhsv.get("b")
  241. hs = color_util.color_RGB_to_hs(r, g, b)
  242. return hs
  243. @property
  244. def effect_list(self):
  245. """Return the list of valid effects for the light"""
  246. if self._effect_dps:
  247. return self._effect_dps.values(self._device)
  248. elif self._color_mode_dps:
  249. effects = [
  250. effect
  251. for effect in self._color_mode_dps.values(self._device)
  252. if effect and not hasattr(ColorMode, effect.upper())
  253. ]
  254. effects.append(EFFECT_OFF)
  255. return effects
  256. @property
  257. def effect(self):
  258. """Return the current effect setting of this light"""
  259. if self._effect_dps:
  260. return self._effect_dps.get_value(self._device)
  261. elif self._color_mode_dps:
  262. mode = self._color_mode_dps.get_value(self._device)
  263. if mode and not hasattr(ColorMode, mode.upper()):
  264. return mode
  265. return EFFECT_OFF
  266. def named_color_from_hsv(self, hs, brightness):
  267. """Get the named color from the rgb value"""
  268. if self._named_color_dps:
  269. palette = self._named_color_dps.values(self._device)
  270. xy = color_util.color_hs_to_xy(*hs)
  271. distance = float("inf")
  272. best_match = None
  273. for entry in palette:
  274. rgb = color_util.color_name_to_rgb(entry)
  275. xy_entry = color_util.color_RGB_to_xy(*rgb)
  276. d = color_util.get_distance_between_two_points(
  277. color_util.XYPoint(*xy),
  278. color_util.XYPoint(*xy_entry),
  279. )
  280. if d < distance:
  281. distance = d
  282. best_match = entry
  283. return best_match
  284. async def async_turn_on(self, **params):
  285. settings = {}
  286. color_mode = None
  287. _LOGGER.debug("Light turn_on: %s", params)
  288. if self._color_mode_dps and ATTR_WHITE in params:
  289. if self.color_mode != ColorMode.WHITE:
  290. color_mode = ColorMode.WHITE
  291. if ATTR_BRIGHTNESS not in params and self._brightness_dps:
  292. bright = params.get(ATTR_WHITE)
  293. r = self._effective_brightness_range
  294. if r:
  295. bright = _ha_brightness_to_dp_value(bright, r)
  296. _LOGGER.info(
  297. "%s setting white brightness to %d", self._config.config_id, bright
  298. )
  299. settings = {
  300. **settings,
  301. **self._brightness_dps.get_values_to_set(
  302. self._device,
  303. bright,
  304. settings,
  305. ),
  306. }
  307. elif self._color_temp_dps and ATTR_COLOR_TEMP_KELVIN in params:
  308. if self.color_mode != ColorMode.COLOR_TEMP:
  309. color_mode = ColorMode.COLOR_TEMP
  310. color_temp = params.get(ATTR_COLOR_TEMP_KELVIN)
  311. # Light groups use the widest range from the lights in the
  312. # group, so we are expected to silently handle out of range values
  313. if color_temp < self.min_color_temp_kelvin:
  314. color_temp = self.min_color_temp_kelvin
  315. if color_temp > self.max_color_temp_kelvin:
  316. color_temp = self.max_color_temp_kelvin
  317. _LOGGER.info(
  318. "%s setting color temp to %d", self._config.config_id, color_temp
  319. )
  320. settings = {
  321. **settings,
  322. **self._color_temp_dps.get_values_to_set(
  323. self._device,
  324. color_temp,
  325. settings,
  326. ),
  327. }
  328. elif self._rgbhsv_dps and (
  329. ATTR_HS_COLOR in params
  330. or (ATTR_BRIGHTNESS in params and self._brightness_control_by_hsv())
  331. ):
  332. if self.color_mode != ColorMode.HS:
  333. color_mode = ColorMode.HS
  334. hs = params.get(ATTR_HS_COLOR, self.hs_color or (0, 0))
  335. brightness = params.get(ATTR_BRIGHTNESS, self.brightness or 255)
  336. fmt = self._rgbhsv_dps.format
  337. if hs and fmt:
  338. rgb = color_util.color_hsv_to_RGB(*hs, brightness / 2.55)
  339. rgbhsv = {
  340. "r": rgb[0],
  341. "g": rgb[1],
  342. "b": rgb[2],
  343. "h": hs[0],
  344. "s": hs[1],
  345. "v": brightness,
  346. }
  347. _LOGGER.debug(
  348. "Setting color as R:%d,G:%d,B:%d,H:%d,S:%d,V:%d",
  349. rgb[0],
  350. rgb[1],
  351. rgb[2],
  352. hs[0],
  353. hs[1],
  354. brightness,
  355. )
  356. current = self._unpacked_rgbhsv
  357. if current:
  358. ordered = []
  359. idx = 0
  360. for n in fmt["names"]:
  361. if n in rgbhsv:
  362. r = fmt["ranges"][idx]
  363. scale = 1
  364. if n == "s":
  365. scale = r["max"] / 100
  366. elif n == "h":
  367. scale = r["max"] / 360
  368. else:
  369. scale = r["max"] / 255
  370. val = round(rgbhsv[n] * scale)
  371. if val < r["min"]:
  372. _LOGGER.warning(
  373. "%s/%s: Color data %s=%d constrained to be above %d",
  374. self._config._device.config,
  375. self.name or "light",
  376. n,
  377. val,
  378. r["min"],
  379. )
  380. val = r["min"]
  381. else:
  382. val = current[n]
  383. ordered.append(val)
  384. idx += 1
  385. binary = pack(fmt["format"], *ordered)
  386. encoded = self._rgbhsv_dps.encode_value(binary)
  387. _LOGGER.info(
  388. "%s setting color to %s", self._config.config_id, encoded
  389. )
  390. settings = {
  391. **settings,
  392. **self._rgbhsv_dps.get_values_to_set(
  393. self._device,
  394. encoded,
  395. settings,
  396. ),
  397. }
  398. elif self._named_color_dps and ATTR_HS_COLOR in params:
  399. if self.color_mode != ColorMode.HS:
  400. color_mode = ColorMode.HS
  401. hs = params.get(ATTR_HS_COLOR, self.hs_color or (0, 0))
  402. brightness = params.get(ATTR_BRIGHTNESS, self.brightness or 255)
  403. best_match = self.named_color_from_hsv(hs, brightness)
  404. _LOGGER.debug("Setting color to %s", best_match)
  405. if best_match:
  406. _LOGGER.info(
  407. "%s setting named color to %s", self._config.config_id, best_match
  408. )
  409. settings = {
  410. **settings,
  411. **self._named_color_dps.get_values_to_set(
  412. self._device,
  413. best_match,
  414. settings,
  415. ),
  416. }
  417. if self._color_mode_dps:
  418. if color_mode:
  419. _LOGGER.info(
  420. "%s auto setting color mode to %s",
  421. self._config.config_id,
  422. color_mode,
  423. )
  424. settings = {
  425. **settings,
  426. **self._color_mode_dps.get_values_to_set(
  427. self._device,
  428. color_mode,
  429. settings,
  430. ),
  431. }
  432. elif not self._effect_dps:
  433. effect = params.get(ATTR_EFFECT)
  434. if effect and effect != self.effect:
  435. if effect == EFFECT_OFF:
  436. # Turn off the effect. Ideally this should keep the
  437. # previous mode, but since the mode is shared with
  438. # effect, use the default, or first in the list
  439. effect = (
  440. self._color_mode_dps.default
  441. or self._color_mode_dps.values(self._device)[0]
  442. )
  443. _LOGGER.info(
  444. "%s emulating effect using color mode of %s",
  445. self._config.config_id,
  446. effect,
  447. )
  448. settings = {
  449. **settings,
  450. **self._color_mode_dps.get_values_to_set(
  451. self._device,
  452. effect,
  453. settings,
  454. ),
  455. }
  456. if (
  457. ATTR_BRIGHTNESS in params
  458. and not self._brightness_control_by_hsv(color_mode)
  459. and self._brightness_dps
  460. ):
  461. bright = params.get(ATTR_BRIGHTNESS)
  462. r = self._effective_brightness_range
  463. if r:
  464. bright = _ha_brightness_to_dp_value(bright, r)
  465. _LOGGER.info("%s setting brightness to %d", self._config.config_id, bright)
  466. settings = {
  467. **settings,
  468. **self._brightness_dps.get_values_to_set(
  469. self._device,
  470. bright,
  471. settings,
  472. ),
  473. }
  474. if self._effect_dps:
  475. effect = params.get(ATTR_EFFECT, None)
  476. if effect:
  477. _LOGGER.info("%s setting effect to %s", self._config.config_id, effect)
  478. settings = {
  479. **settings,
  480. **self._effect_dps.get_values_to_set(
  481. self._device,
  482. effect,
  483. settings,
  484. ),
  485. }
  486. # On packed dps where switch / brightness / effect all share the same
  487. # dp id (e.g. dp 51 with different masks), the original `id not in
  488. # settings` check incorrectly skipped the switch-on merge once any
  489. # other masked sub-field had populated settings. For masked dps it's
  490. # always safe to merge — get_values_to_set with pending_map=settings
  491. # will OR onto the existing pending value.
  492. if (
  493. self._switch_dps
  494. and not self._switch_dps.readonly
  495. and not self.is_on
  496. and (
  497. self._switch_dps.mask is not None or self._switch_dps.id not in settings
  498. )
  499. ):
  500. _LOGGER.info("%s turning light on", self._config.config_id)
  501. settings = settings | self._switch_dps.get_values_to_set(
  502. self._device, True, settings
  503. )
  504. elif (
  505. self._brightness_dps
  506. and not self.is_on
  507. and (
  508. self._brightness_dps.mask is not None
  509. or self._brightness_dps.id not in settings
  510. )
  511. ):
  512. bright = 255
  513. r = self._effective_brightness_range
  514. if r:
  515. bright = color_util.brightness_to_value(r, bright)
  516. _LOGGER.info(
  517. "%s turning light on to brightness %d",
  518. self._config.config_id,
  519. bright,
  520. )
  521. settings = settings | self._brightness_dps.get_values_to_set(
  522. self._device, bright, settings
  523. )
  524. elif (
  525. self._effect_dps
  526. and not self.is_on
  527. and "off" in self._effect_dps.values(self._device)
  528. and (
  529. self._effect_dps.mask is not None or self._effect_dps.id not in settings
  530. )
  531. ):
  532. # Special case for lights with effect that has off state, but no switch or brightness
  533. on_value = self._effect_dps.default
  534. if on_value is None and "on" in self._effect_dps.values(self._device):
  535. on_value = "on"
  536. if on_value:
  537. _LOGGER.info(
  538. "%s turning light on using %s effect",
  539. self._config.config_id,
  540. on_value,
  541. )
  542. settings = settings | self._effect_dps.get_values_to_set(
  543. self._device, on_value, settings
  544. )
  545. if settings:
  546. await self._device.async_set_properties(settings)
  547. async def async_turn_off(self):
  548. if self._switch_dps and not self._switch_dps.readonly:
  549. _LOGGER.info("%s turning light off", self._config.config_id)
  550. await self._switch_dps.async_set_value(self._device, False)
  551. elif self._brightness_dps:
  552. _LOGGER.info(
  553. "%s turning light off by setting brightness to 0",
  554. self._config.config_id,
  555. )
  556. await self._brightness_dps.async_set_value(self._device, 0)
  557. elif self._effect_dps and "off" in self._effect_dps.values(self._device):
  558. # off by effect
  559. _LOGGER.info("%s turning light off using effect", self._config.config_id)
  560. await self._effect_dps.async_set_value(self._device, "off")
  561. else:
  562. raise NotImplementedError()