Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Philips TV ambilight support #44867

Merged
merged 9 commits into from
May 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/philips_js/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from .const import DOMAIN

PLATFORMS = ["media_player", "remote"]
PLATFORMS = ["media_player", "light", "remote"]

LOGGER = logging.getLogger(__name__)

Expand Down
380 changes: 380 additions & 0 deletions homeassistant/components/philips_js/light.py
Original file line number Diff line number Diff line change
@@ -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(
elupus marked this conversation as resolved.
Show resolved Hide resolved
_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()
Loading