light.py 22 KB

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