light.py 21 KB

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