From 6631a4e605ebb0e9317a1450c36fb3b9e3c63c46 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 31 May 2021 23:39:28 +0200 Subject: [PATCH] Philips TV ambilight support (#44867) --- .coveragerc | 1 + .../components/philips_js/__init__.py | 2 +- homeassistant/components/philips_js/light.py | 380 ++++++++++++++++++ .../components/philips_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/philips_js/light.py diff --git a/.coveragerc b/.coveragerc index c3a56fa27c0..9357f6d9972 100644 --- a/.coveragerc +++ b/.coveragerc @@ -769,6 +769,7 @@ omit = homeassistant/components/pcal9535a/* homeassistant/components/pencom/switch.py homeassistant/components/philips_js/__init__.py + homeassistant/components/philips_js/light.py homeassistant/components/philips_js/media_player.py homeassistant/components/philips_js/remote.py homeassistant/components/pi_hole/sensor.py diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index b4e086ef391..587a5f8c4f2 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -PLATFORMS = ["media_player", "remote"] +PLATFORMS = ["media_player", "light", "remote"] LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py new file mode 100644 index 00000000000..800604ad7c7 --- /dev/null +++ b/homeassistant/components/philips_js/light.py @@ -0,0 +1,380 @@ +"""Component to integrate ambilight for TVs exposing the Joint Space API.""" +from __future__ import annotations + +from typing import Any + +from haphilipsjs import PhilipsTV +from haphilipsjs.typing import AmbilightCurrentConfiguration + +from homeassistant import config_entries +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + ATTR_HS_COLOR, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_EFFECT, + LightEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv + +from . import PhilipsTVDataUpdateCoordinator +from .const import CONF_SYSTEM, DOMAIN + +EFFECT_PARTITION = ": " +EFFECT_MODE = "Mode" +EFFECT_EXPERT = "Expert" +EFFECT_AUTO = "Auto" +EFFECT_EXPERT_STYLES = {"FOLLOW_AUDIO", "FOLLOW_COLOR", "Lounge light"} + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: config_entries.ConfigEntry, + async_add_entities, +): + """Set up the configuration entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + PhilipsTVLightEntity( + coordinator, config_entry.data[CONF_SYSTEM], config_entry.unique_id + ) + ] + ) + + +def _get_settings(style: AmbilightCurrentConfiguration): + """Extract the color settings data from a style.""" + if style["styleName"] in ("FOLLOW_COLOR", "Lounge light"): + return style["colorSettings"] + if style["styleName"] == "FOLLOW_AUDIO": + return style["audioSettings"] + return None + + +def _parse_effect(effect: str): + style, _, algorithm = effect.partition(EFFECT_PARTITION) + if style == EFFECT_MODE: + return EFFECT_MODE, algorithm, None + algorithm, _, expert = algorithm.partition(EFFECT_PARTITION) + if expert: + return EFFECT_EXPERT, style, algorithm + return EFFECT_AUTO, style, algorithm + + +def _get_effect(mode: str, style: str, algorithm: str | None): + if mode == EFFECT_MODE: + return f"{EFFECT_MODE}{EFFECT_PARTITION}{style}" + if mode == EFFECT_EXPERT: + return f"{style}{EFFECT_PARTITION}{algorithm}{EFFECT_PARTITION}{EFFECT_EXPERT}" + return f"{style}{EFFECT_PARTITION}{algorithm}" + + +def _is_on(mode, style, powerstate): + if mode in (EFFECT_AUTO, EFFECT_EXPERT): + if style in ("FOLLOW_VIDEO", "FOLLOW_AUDIO"): + return powerstate in ("On", None) + if style == "OFF": + return False + return True + + if mode == EFFECT_MODE: + if style == "internal": + return powerstate in ("On", None) + return True + + return False + + +def _is_valid(mode, style): + if mode == EFFECT_EXPERT: + return style in EFFECT_EXPERT_STYLES + return True + + +def _get_cache_keys(device: PhilipsTV): + """Return a cache keys to avoid always updating.""" + return ( + device.on, + device.powerstate, + device.ambilight_current_configuration, + device.ambilight_mode, + ) + + +def _average_pixels(data): + """Calculate an average color over all ambilight pixels.""" + color_c = 0 + color_r = 0.0 + color_g = 0.0 + color_b = 0.0 + for layer in data.values(): + for side in layer.values(): + for pixel in side.values(): + color_c += 1 + color_r += pixel["r"] + color_g += pixel["g"] + color_b += pixel["b"] + + if color_c: + color_r /= color_c + color_g /= color_c + color_b /= color_c + return color_r, color_g, color_b + return 0.0, 0.0, 0.0 + + +class PhilipsTVLightEntity(CoordinatorEntity, LightEntity): + """Representation of a Philips TV exposing the JointSpace API.""" + + def __init__( + self, + coordinator: PhilipsTVDataUpdateCoordinator, + system: dict[str, Any], + unique_id: str, + ) -> None: + """Initialize light.""" + self._tv = coordinator.api + self._hs = None + self._brightness = None + self._system = system + self._coordinator = coordinator + self._cache_keys = None + super().__init__(coordinator) + + self._attr_supported_color_modes = [COLOR_MODE_HS, COLOR_MODE_ONOFF] + self._attr_supported_features = ( + SUPPORT_EFFECT | SUPPORT_COLOR | SUPPORT_BRIGHTNESS + ) + self._attr_name = self._system["name"] + self._attr_unique_id = unique_id + self._attr_icon = "mdi:television-ambient-light" + self._attr_device_info = { + "name": self._system["name"], + "identifiers": { + (DOMAIN, self._attr_unique_id), + }, + "model": self._system.get("model"), + "manufacturer": "Philips", + "sw_version": self._system.get("softwareversion"), + } + + self._update_from_coordinator() + + def _calculate_effect_list(self): + """Calculate an effect list based on current status.""" + effects = [] + effects.extend( + _get_effect(EFFECT_AUTO, style, setting) + for style, data in self._tv.ambilight_styles.items() + if _is_valid(EFFECT_AUTO, style) + and _is_on(EFFECT_AUTO, style, self._tv.powerstate) + for setting in data.get("menuSettings", []) + ) + + effects.extend( + _get_effect(EFFECT_EXPERT, style, algorithm) + for style, data in self._tv.ambilight_styles.items() + if _is_valid(EFFECT_EXPERT, style) + and _is_on(EFFECT_EXPERT, style, self._tv.powerstate) + for algorithm in data.get("algorithms", []) + ) + + effects.extend( + _get_effect(EFFECT_MODE, style, None) + for style in self._tv.ambilight_modes + if _is_valid(EFFECT_MODE, style) + and _is_on(EFFECT_MODE, style, self._tv.powerstate) + ) + + return sorted(effects) + + def _calculate_effect(self): + """Return the current effect.""" + current = self._tv.ambilight_current_configuration + if current and self._tv.ambilight_mode != "manual": + if current["isExpert"]: + settings = _get_settings(current) + if settings: + return _get_effect( + EFFECT_EXPERT, current["styleName"], settings["algorithm"] + ) + return _get_effect(EFFECT_EXPERT, current["styleName"], None) + + return _get_effect( + EFFECT_AUTO, current["styleName"], current.get("menuSetting", None) + ) + + return _get_effect(EFFECT_MODE, self._tv.ambilight_mode, None) + + @property + def color_mode(self): + """Return the current color mode.""" + current = self._tv.ambilight_current_configuration + if current and current["isExpert"]: + return COLOR_MODE_HS + + if self._tv.ambilight_mode in ["manual", "expert"]: + return COLOR_MODE_HS + + return COLOR_MODE_ONOFF + + @property + def is_on(self): + """Return if the light is turned on.""" + if self._tv.on: + mode, style, _ = _parse_effect(self.effect) + return _is_on(mode, style, self._tv.powerstate) + + return False + + def _update_from_coordinator(self): + current = self._tv.ambilight_current_configuration + color = None + if current and current["isExpert"]: + if settings := _get_settings(current): + color = settings["color"] + + if color: + self._attr_hs_color = ( + color["hue"] * 360.0 / 255.0, + color["saturation"] * 100.0 / 255.0, + ) + self._attr_brightness = color["brightness"] + elif data := self._tv.ambilight_cached: + hsv_h, hsv_s, hsv_v = color_RGB_to_hsv(*_average_pixels(data)) + self._attr_hs_color = hsv_h, hsv_s + self._attr_brightness = hsv_v * 255.0 / 100.0 + else: + self._attr_hs_color = None + self._attr_brightness = None + + if (cache_keys := _get_cache_keys(self._tv)) != self._cache_keys: + self._cache_keys = cache_keys + self._attr_effect_list = self._calculate_effect_list() + self._attr_effect = self._calculate_effect() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_from_coordinator() + super()._handle_coordinator_update() + + async def _set_ambilight_cached(self, algorithm, hs_color, brightness): + """Set ambilight via the manual or expert mode.""" + rgb = color_hsv_to_RGB(hs_color[0], hs_color[1], brightness * 100 / 255) + + data = { + "r": rgb[0], + "g": rgb[1], + "b": rgb[2], + } + + if not await self._tv.setAmbilightCached(data): + raise Exception("Failed to set ambilight color") + + if algorithm != self._tv.ambilight_mode: + if not await self._tv.setAmbilightMode(algorithm): + raise Exception("Failed to set ambilight mode") + + async def _set_ambilight_expert_config( + self, style, algorithm, hs_color, brightness + ): + """Set ambilight via current configuration.""" + config: AmbilightCurrentConfiguration = { + "styleName": style, + "isExpert": True, + } + + setting = { + "algorithm": algorithm, + "color": { + "hue": round(hs_color[0] * 255.0 / 360.0), + "saturation": round(hs_color[1] * 255.0 / 100.0), + "brightness": round(brightness), + }, + "colorDelta": { + "hue": 0, + "saturation": 0, + "brightness": 0, + }, + } + + if style in ("FOLLOW_COLOR", "Lounge light"): + config["colorSettings"] = setting + config["speed"] = 2 + + elif style == "FOLLOW_AUDIO": + config["audioSettings"] = setting + config["tuning"] = 0 + + if not await self._tv.setAmbilightCurrentConfiguration(config): + raise Exception("Failed to set ambilight mode") + + async def _set_ambilight_config(self, style, algorithm): + """Set ambilight via current configuration.""" + config: AmbilightCurrentConfiguration = { + "styleName": style, + "isExpert": False, + "menuSetting": algorithm, + } + + if await self._tv.setAmbilightCurrentConfiguration(config) is False: + raise Exception("Failed to set ambilight mode") + + async def async_turn_on(self, **kwargs) -> None: + """Turn the bulb on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) + effect = kwargs.get(ATTR_EFFECT, self.effect) + + if not self._tv.on: + raise Exception("TV is not available") + + mode, style, setting = _parse_effect(effect) + + if not _is_on(mode, style, self._tv.powerstate): + mode = EFFECT_MODE + setting = None + if self._tv.powerstate in ("On", None): + style = "internal" + else: + style = "manual" + + if brightness is None: + brightness = 255 + + if hs_color is None: + hs_color = [0, 0] + + if mode == EFFECT_MODE: + await self._set_ambilight_cached(style, hs_color, brightness) + elif mode == EFFECT_AUTO: + await self._set_ambilight_config(style, setting) + elif mode == EFFECT_EXPERT: + await self._set_ambilight_expert_config( + style, setting, hs_color, brightness + ) + + self._update_from_coordinator() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn of ambilight.""" + + if not self._tv.on: + raise Exception("TV is not available") + + if await self._tv.setAmbilightMode("internal") is False: + raise Exception("Failed to set ambilight mode") + + await self._set_ambilight_config("OFF", "") + + self._update_from_coordinator() + self.async_write_ha_state() diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 9d1c4dbd04d..4f3ee5a9ab3 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -2,7 +2,7 @@ "domain": "philips_js", "name": "Philips TV", "documentation": "https://www.home-assistant.io/integrations/philips_js", - "requirements": ["ha-philipsjs==2.7.3"], + "requirements": ["ha-philipsjs==2.7.4"], "codeowners": ["@elupus"], "config_flow": true, "iot_class": "local_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 298934a40e4..68bc6e37a4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -723,7 +723,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.3 +ha-philipsjs==2.7.4 # homeassistant.components.habitica habitipy==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c389bc742b..b881db6904c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.3 +ha-philipsjs==2.7.4 # homeassistant.components.habitica habitipy==0.2.0