light.py 19 KB

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