Skip to content

Commit 1d0aa56

Browse files
committed
Initial commit
0 parents  commit 1d0aa56

9 files changed

Lines changed: 2499 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.idea
2+
__pycache__

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# LightCast
2+
3+
Transform your Home Assistant smart bulbs into a media player
4+
5+
When an image is played on a LightCast media player, it uses [colorgram.py](https://github.com/obskyr/colorgram.py/tree/master)
6+
to extract a palette of colors equal to the amount of lights configured in the LightCast player and sets those lights to that color palette.
7+
8+
## Configuration
9+
10+
`configuration.yml`
11+
12+
```yaml
13+
media_player:
14+
- platform: lightcast
15+
name: Living Room LightCast
16+
target: area.living_room
17+
```
18+
19+
Target can be a light, area, group or device.
20+
21+
You can specify target as a list of entities as well, LightCast will resolve all lights found in all targets.
22+
Note that LightCast cannot resolve individual lights if you provide a Zigbee coordinator group.
23+
24+
```yaml
25+
media_player:
26+
- platform: lightcast
27+
name: Living Room LightCast
28+
target:
29+
- entity.living_room_ceiling
30+
- entity.living_room_backlight
31+
```
32+
33+
You can create multiple LightCast media players for each area in your home
34+
35+
```yaml
36+
media_player:
37+
- platform: lightcast
38+
name: Living Room LightCast
39+
target: area.living_room
40+
- platform: lightcast
41+
name: Office LightCast
42+
target: area.office
43+
```

__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
LightCast integration
3+
"""
4+
from homeassistant.core import HomeAssistant
5+
from homeassistant.config_entries import ConfigEntry
6+
from homeassistant.const import Platform
7+
8+
9+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
10+
await hass.config_entries.async_forward_entry_setups(entry, [Platform.MEDIA_PLAYER])

const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CONF_TARGET = 'target'
2+
CONF_NAME = 'name'

entity_resolver.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import typing
2+
import collections.abc
3+
from homeassistant.core import HomeAssistant, State
4+
from homeassistant.helpers.template import area_entities, device_entities
5+
from homeassistant.helpers.entity import entity_sources
6+
from homeassistant.const import ATTR_ENTITY_ID
7+
8+
GROUP_DOMAIN_PREFIX = 'group.'
9+
AREA_DOMAIN_PREFIX = 'area.'
10+
DEVICE_DOMAIN_PREFIX = 'device.'
11+
12+
EntityRef = str | State
13+
14+
15+
def expand_entities(
16+
hass: HomeAssistant,
17+
expand_id: EntityRef | typing.Iterable[EntityRef],
18+
in_domain: str = 'light') -> typing.List[State]:
19+
"""
20+
Expand an entity reference given by expand_id to a list of HomeAssistant states that are a member of that reference.
21+
expand_id can be one of:
22+
- A string referencing an entity id
23+
- A State itself
24+
- A device ID
25+
- An area ID
26+
- A group helper ID
27+
28+
:param hass:
29+
Instance of HASS client
30+
:param expand_id:
31+
The entity ID or group reference to resolve
32+
:param in_domain:
33+
Filter returned entities to only those that are of the given domain
34+
:return:
35+
A list of de-duplicated resolved states
36+
"""
37+
search = [expand_id]
38+
found: typing.Dict[str, State] = {}
39+
40+
while search:
41+
entity = search.pop()
42+
43+
if isinstance(entity, State):
44+
found[entity.entity_id] = entity
45+
continue
46+
47+
if (not (is_str := isinstance(entity, str))) and isinstance(entity, collections.abc.Iterable):
48+
search += entity
49+
continue
50+
51+
# Must be a string by this point
52+
if not is_str:
53+
continue # ignore
54+
55+
entity_id = entity
56+
57+
# Entity ref is an area
58+
if entity_id.startswith(AREA_DOMAIN_PREFIX):
59+
search += area_entities(hass, entity_id.removeprefix(AREA_DOMAIN_PREFIX))
60+
continue
61+
62+
# Entity ref is a device
63+
if entity_id.startswith(DEVICE_DOMAIN_PREFIX):
64+
search += device_entities(hass, entity_id)
65+
continue
66+
67+
# Entity ref should be an entity (or a group helper)
68+
if (entity := hass.states.get(entity_id)) is None:
69+
continue # not defined
70+
71+
if entity_id.startswith(GROUP_DOMAIN_PREFIX) or (
72+
(source := entity_sources(hass).get(entity_id))
73+
and source["domain"] == "group"
74+
):
75+
if group_entities := entity.attributes.get(ATTR_ENTITY_ID):
76+
search += group_entities
77+
continue
78+
79+
# Finally, this should be a single entity in the light domain
80+
if entity.domain == in_domain:
81+
found[entity.entity_id] = entity
82+
83+
return list(found.values())

manifest.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"domain": "lightcast",
3+
"name": "Light Cast",
4+
"codeowners": ["@relvacode"],
5+
"dependencies": [],
6+
"requirements": ["aiohttp", "colorgram.py"],
7+
"documentation": "https://github.com/relvacode/LightCast#readme",
8+
"issue_tracker": "https://github.com/relvacode/LightCast/issues",
9+
"version": "0.1.0",
10+
"integration_type": "entity"
11+
}

media_player.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import logging
2+
import io
3+
import itertools
4+
import aiohttp
5+
import colorgram
6+
7+
from homeassistant.core import HomeAssistant
8+
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
9+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
10+
from homeassistant.components.media_player import MediaPlayerEntity, MediaPlayerEntityFeature, \
11+
MediaType, MediaPlayerDeviceClass
12+
from homeassistant.components import media_source
13+
from homeassistant.components.media_player.browse_media import (
14+
BrowseMedia,
15+
async_process_play_media_url,
16+
)
17+
from homeassistant.const import ATTR_ENTITY_ID
18+
from homeassistant.components import light
19+
20+
from . import const
21+
from .entity_resolver import expand_entities
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
async def async_setup_platform(
27+
hass: HomeAssistant,
28+
config: ConfigType,
29+
add_entities: AddEntitiesCallback,
30+
discovery_info: DiscoveryInfoType | None = None,
31+
) -> None:
32+
_LOGGER.info('LightCast media_player setup: %s', config)
33+
34+
cast_device = LightCastPlayer(
35+
hass,
36+
config[const.CONF_NAME],
37+
config[const.CONF_TARGET],
38+
)
39+
40+
add_entities([cast_device])
41+
42+
43+
class LightCastPlayer(MediaPlayerEntity):
44+
_attr_available = True
45+
_attr_supported_features = MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA
46+
_attr_device_class = MediaPlayerDeviceClass.TV
47+
48+
def __init__(self, hass: HomeAssistant, name: str, device_target: str) -> None:
49+
self.hass = hass
50+
self.device_target = device_target
51+
self._attr_name = name
52+
53+
async def async_browse_media(self, media_content_type: str | None = None,
54+
media_content_id: str | None = None) -> BrowseMedia:
55+
"""
56+
Implement MediaPlayerEntityFeature.BROWSE_MEDIA
57+
:param media_content_type:
58+
:param media_content_id:
59+
:return:
60+
"""
61+
return await media_source.async_browse_media(
62+
self.hass,
63+
media_content_id,
64+
content_filter=lambda item: item.media_content_type.startswith('image/')
65+
)
66+
67+
async def process_image(self, media_type: str, media_id: str) -> None:
68+
"""
69+
Download an image found at media_id to an in-memory buffer.
70+
Resolve the entity list of lights referenced by device_target which are currently in the on state
71+
and extract that many colors from the image using colorgram.
72+
For each pair of light and color, call light.turn_on with that color
73+
:param media_type:
74+
:param media_id:
75+
"""
76+
_LOGGER.info('Processing image %s: %s', media_type, media_id)
77+
78+
found_entities = expand_entities(self.hass, self.device_target)
79+
_LOGGER.info('Found %d entities matching %s', len(found_entities), self.device_target)
80+
81+
if not found_entities:
82+
_LOGGER.warning('No entities were matched')
83+
return
84+
85+
valid_entities = [e for e in found_entities if e.state == 'on']
86+
if not valid_entities:
87+
_LOGGER.warning('No targets are on')
88+
return
89+
90+
async with aiohttp.ClientSession() as session:
91+
async with session.get(media_id) as response:
92+
response_data = await response.read()
93+
94+
n_colors = len(valid_entities)
95+
_LOGGER.info('Downloaded image, extracting palette of %d colors', n_colors)
96+
97+
extracted_colors = colorgram.extract(io.BytesIO(response_data), n_colors)
98+
_LOGGER.info('Extracted palette of %d colors from image', len(extracted_colors))
99+
100+
for e, color in zip(valid_entities, itertools.cycle(extracted_colors)):
101+
rgb_color = [color.rgb.r, color.rgb.g, color.rgb.b]
102+
_LOGGER.info('Set light %s to %s', e.entity_id, rgb_color)
103+
await self.hass.services.async_call(
104+
light.DOMAIN,
105+
light.SERVICE_TURN_ON,
106+
{
107+
ATTR_ENTITY_ID: e.entity_id,
108+
light.ATTR_RGB_COLOR: rgb_color
109+
}
110+
)
111+
112+
async def async_play_media(self, media_type: MediaType, media_id: str, **kwargs: any) -> None:
113+
if media_source.is_media_source_id(media_id):
114+
play_item = await media_source.async_resolve_media(self.hass, media_id, self.entity_id)
115+
media_id = async_process_play_media_url(self.hass, play_item.url)
116+
117+
self.hass.async_create_task(
118+
self.process_image(media_type, media_id)
119+
)

0 commit comments

Comments
 (0)