light.py 22 KB

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