From dd551a3551d702d2bdc74355811f6830a717bcb8 Mon Sep 17 00:00:00 2001 From: Timmo Date: Sat, 8 Feb 2020 13:22:08 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=A5Rework=20Twitch=20Integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CODEOWNERS | 1 + .../components/twitch/.translations/en.json | 13 + homeassistant/components/twitch/__init__.py | 169 +++++++++++- .../components/twitch/config_flow.py | 29 +++ homeassistant/components/twitch/const.py | 13 + homeassistant/components/twitch/manifest.json | 16 +- homeassistant/components/twitch/sensor.py | 245 ++++++++++++------ homeassistant/components/twitch/strings.json | 13 + homeassistant/generated/config_flows.py | 1 + tests/components/twitch/__init__.py | 1 + tests/components/twitch/test_config_flow.py | 90 +++++++ 11 files changed, 503 insertions(+), 88 deletions(-) create mode 100644 homeassistant/components/twitch/.translations/en.json create mode 100644 homeassistant/components/twitch/config_flow.py create mode 100644 homeassistant/components/twitch/const.py create mode 100644 homeassistant/components/twitch/strings.json create mode 100644 tests/components/twitch/__init__.py create mode 100644 tests/components/twitch/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index c3f018ef83a38a..c08fd48721c468 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -366,6 +366,7 @@ homeassistant/components/tts/* @robbiet480 homeassistant/components/twentemilieu/* @frenck homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 +homeassistant/components/twitch/* @timmo001 homeassistant/components/ubee/* @mzdrale homeassistant/components/unifi/* @kane610 homeassistant/components/unifiled/* @florisvdk diff --git a/homeassistant/components/twitch/.translations/en.json b/homeassistant/components/twitch/.translations/en.json new file mode 100644 index 00000000000000..54cefe630b3cf6 --- /dev/null +++ b/homeassistant/components/twitch/.translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "title": "Twitch", + "step": { + "user": { + "title": "Add a streamer", + "data": { + "user": "User" + } + } + } + } +} diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 0cdeb8139450bd..92a92abc397b43 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -1 +1,168 @@ -"""The twitch component.""" +"""The twitch integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Dict, Optional, Union + +import twitch as Twitch +from twitch import Helix +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DATA_TWITCH_CLIENT, DATA_TWITCH_UPDATED, DATA_USER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +DATA_TWITCH = DOMAIN + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +PLATFORMS = ["sensor"] + +CONFIG_SCHEMA = vol.Schema( + {vol.Optional(DOMAIN): vol.Schema({vol.Required(CONF_CLIENT_ID): cv.string})}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the twitch component.""" + hass.data[DATA_TWITCH_CLIENT] = Twitch.Helix( + config[DOMAIN].get(CONF_CLIENT_ID), + use_cache=True, + cache_duration=timedelta(minutes=5), + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up twitch from a config entry.""" + + # Get twitch instance for this entry + twitch = hass.data[DATA_TWITCH_CLIENT] + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_TWITCH_CLIENT: twitch} + + # Get user + user = twitch.user(entry.data[DATA_USER]) + + # For backwards compat, set unique ID + if entry.unique_id is None: + hass.config_entries.async_update_entry(entry, unique_id=user.id) + + # Set up all platforms for this device/entry. + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class TwitchEntity(Entity): + """Defines a base Twitch entity.""" + + def __init__( + self, + entry_id: str, + twitch: Helix, + user: str, + name: str, + icon: str, + enabled_default: bool = True, + ) -> None: + """Initialize the Twitch entity.""" + self._attributes: Dict[str, Union[str, int, float]] = {} + self._available = True + self._enabled_default = enabled_default + self._entry_id = entry_id + self._icon = icon + self._name = name + self._unsub_dispatcher = None + self.twitch = twitch + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + @property + def should_poll(self) -> bool: + """Return the polling requirement of the entity.""" + return True + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes of the entity.""" + return self._attributes + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, DATA_TWITCH_UPDATED, self._schedule_immediate_update + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect from update signal.""" + self._unsub_dispatcher() + + @callback + def _schedule_immediate_update(self, entry_id: str) -> None: + """Schedule an immediate update of the entity.""" + if entry_id == self._entry_id: + self.async_schedule_update_ha_state(True) + + async def async_update(self) -> None: + """Update Twitch entity.""" + if not self.enabled: + return + + if self.twitch is None: + self._available = False + return + + self._available = True + await self._twitch_update() + + async def _twitch_update(self) -> None: + """Update Twitch entity.""" + raise NotImplementedError() diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py new file mode 100644 index 00000000000000..bcf0c9c623998e --- /dev/null +++ b/homeassistant/components/twitch/config_flow.py @@ -0,0 +1,29 @@ +"""Config flow for twitch integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries + +from .const import DATA_USER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({DATA_USER: str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for twitch.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + return self.async_create_entry(title=user_input[DATA_USER], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py new file mode 100644 index 00000000000000..dce49c72215149 --- /dev/null +++ b/homeassistant/components/twitch/const.py @@ -0,0 +1,13 @@ +"""Constants for the twitch integration.""" + +DOMAIN = "twitch" + +DATA_BOX_ART_URL = "Box Art URL" +DATA_GAME = "current_game" +DATA_LIVE = "live" +DATA_THUMBNAIL_URL = "Thumbnail URL" +DATA_CHANNEL_VIEWS = "Channel Views" +DATA_TWITCH_CLIENT = "twitch_client" +DATA_TWITCH_UPDATED = "twitch_updated" +DATA_USER = "user" +DATA_VIEWERS = "viewers" diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index 639624c352fc53..abf42be22128ff 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -1,8 +1,16 @@ { "domain": "twitch", - "name": "Twitch", + "name": "twitch", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/twitch", - "requirements": ["python-twitch-client==0.6.0"], + "requirements": [ + "twitch-python===0.0.17" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, "dependencies": [], - "codeowners": [] -} + "codeowners": [ + "@timmo001" + ] +} \ No newline at end of file diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index f4276160d6c472..be3d718160333d 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -1,108 +1,187 @@ -"""Support for the Twitch stream status.""" +"""Support for Twitch sensors.""" import logging +from typing import Callable, List, Union -from requests.exceptions import HTTPError -from twitch import TwitchClient -import voluptuous as vol +from twitch import Helix +from twitch.helix import StreamNotFound, User -from homeassistant.components.sensor import PLATFORM_SCHEMA -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from . import TwitchEntity +from .const import ( + DATA_BOX_ART_URL, + DATA_CHANNEL_VIEWS, + DATA_GAME, + DATA_LIVE, + DATA_THUMBNAIL_URL, + DATA_TWITCH_CLIENT, + DATA_USER, + DATA_VIEWERS, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -ATTR_GAME = "game" -ATTR_TITLE = "title" - -CONF_CHANNELS = "channels" -CONF_CLIENT_ID = "client_id" - -ICON = "mdi:twitch" - -STATE_OFFLINE = "offline" -STATE_STREAMING = "streaming" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), - } -) +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up Twitch sensor based on a config entry.""" + twitch: Helix = hass.data[DOMAIN][entry.entry_id][DATA_TWITCH_CLIENT] -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Twitch platform.""" - channels = config[CONF_CHANNELS] - client_id = config[CONF_CLIENT_ID] - client = TwitchClient(client_id=client_id) + user = twitch.user(entry.data[DATA_USER]) - try: - client.ingests.get_server_list() - except HTTPError: - _LOGGER.error("Client ID is not valid") - return + sensors = [ + TwitchUserLiveSensor(entry.entry_id, user, twitch), + TwitchUserViewersSensor(entry.entry_id, user, twitch), + TwitchUserGameSensor(entry.entry_id, user, twitch), + ] - users = client.users.translate_usernames_to_ids(channels) + async_add_entities(sensors, True) - add_entities([TwitchSensor(user, client) for user in users], True) +class TwitchSensor(TwitchEntity): + """Defines a Twitch sensor.""" -class TwitchSensor(Entity): - """Representation of an Twitch channel.""" + def __init__( + self, + entry_id: str, + twitch: Helix, + user: User, + name: str, + icon: str, + key: str, + unit_of_measurement: str = "", + enabled_default: bool = True, + ) -> None: + """Initialize Twitch sensor.""" + self._state = None + self._entity_picture = None + self._unit_of_measurement = unit_of_measurement + self._key = key - def __init__(self, user, client): - """Initialize the sensor.""" - self._client = client - self._user = user - self._channel = self._user.name - self._id = self._user.id - self._state = self._preview = self._game = self._title = None - - @property - def should_poll(self): - """Device should be polled.""" - return True + super().__init__(entry_id, twitch, user, name, icon, enabled_default) @property - def name(self): - """Return the name of the sensor.""" - return self._channel + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return f"{self.id}_{self._key}" @property - def state(self): + def state(self) -> Union[None, str, int, float]: """Return the state of the sensor.""" return self._state @property - def entity_picture(self): - """Return preview of current game.""" - return self._preview - - @property - def device_state_attributes(self): - """Return the state attributes.""" - if self._state == STATE_STREAMING: - return {ATTR_GAME: self._game, ATTR_TITLE: self._title} + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement @property - def unique_id(self): - """Return unique ID for this sensor.""" - return self._id - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - # pylint: disable=no-member - def update(self): - """Update device state.""" - stream = self._client.streams.get_stream_by_user(self._id) - if stream: - self._game = stream.get("channel").get("game") - self._title = stream.get("channel").get("status") - self._preview = stream.get("preview").get("medium") - self._state = STATE_STREAMING - else: - self._preview = self._client.users.get_by_id(self._id).get("logo") - self._state = STATE_OFFLINE + def entity_picture(self): + """Return preview of current game.""" + return self._entity_picture + + +class TwitchUserLiveSensor(TwitchSensor): + """Defines a Twitch User Live sensor.""" + + def __init__(self, entry_id: str, user: User, twitch: Helix) -> None: + """Initialize Twitch User Live sensor.""" + self.user = user + self.id = self.user.id + self._entity_picture = self.user.profile_image_url + super().__init__( + entry_id, + twitch, + user, + f"{self.user.display_name} Live", + "mdi:twitch", + DATA_LIVE, + enabled_default=True, + ) + + async def _twitch_update(self) -> None: + """Update Twitch User Live sensor.""" + try: + stream = self.twitch.stream(user_id=self.user.id) + self._state = stream.title + thumbnail_url = stream.thumbnail_url.format(width="0", height="0") + self._attributes = { + DATA_CHANNEL_VIEWS: self.user.view_count, + DATA_THUMBNAIL_URL: thumbnail_url, + } + self._entity_picture = thumbnail_url + except StreamNotFound: + self._state = "Offline" + self._entity_picture = self.user.profile_image_url + + +class TwitchUserViewersSensor(TwitchSensor): + """Defines a Twitch User Viewers sensor.""" + + def __init__(self, entry_id: str, user: User, twitch: Helix) -> None: + """Initialize Twitch User Viewers sensor.""" + self.user = user + self.id = self.user.id + self._entity_picture = self.user.profile_image_url + super().__init__( + entry_id, + twitch, + user, + f"{self.user.display_name} Viewers", + "mdi:twitch", + DATA_VIEWERS, + enabled_default=True, + ) + + async def _twitch_update(self) -> None: + """Update Twitch User Viewers sensor.""" + try: + stream = self.twitch.stream(user_id=self.user.id) + self._state = stream.viewer_count + thumbnail_url = stream.thumbnail_url.format(width="0", height="0") + self._entity_picture = thumbnail_url + except StreamNotFound: + self._state = 0 + self._entity_picture = self.user.profile_image_url + + +class TwitchUserGameSensor(TwitchSensor): + """Defines a Twitch User Game sensor.""" + + def __init__(self, entry_id: str, user: User, twitch: Helix) -> None: + """Initialize Twitch User Game sensor.""" + self.user = user + self.id = self.user.id + self._entity_picture = self.user.profile_image_url + super().__init__( + entry_id, + twitch, + user, + f"{self.user.display_name} Current Game", + "mdi:twitch", + DATA_GAME, + enabled_default=True, + ) + + async def _twitch_update(self) -> None: + """Update Twitch User Game sensor.""" + try: + stream = self.twitch.stream(user_id=self.user.id) + game = self.twitch.game(id=stream.game_id) + if game is None: + self._state = "Unknown" + self._entity_picture = self.user.profile_image_url + else: + self._state = game.name + box_art_url = game.box_art_url.format(width="0", height="0") + self._attributes = {DATA_BOX_ART_URL: box_art_url} + self._entity_picture = box_art_url + except StreamNotFound: + self._state = "Unknown" + self._entity_picture = self.user.profile_image_url diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json new file mode 100644 index 00000000000000..54cefe630b3cf6 --- /dev/null +++ b/homeassistant/components/twitch/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "title": "Twitch", + "step": { + "user": { + "title": "Add a streamer", + "data": { + "user": "User" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4872b08e9fc86b..f87dc61e4e3eb0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -94,6 +94,7 @@ "transmission", "twentemilieu", "twilio", + "twitch", "unifi", "upnp", "velbus", diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py new file mode 100644 index 00000000000000..6d99a9f20ea6a1 --- /dev/null +++ b/tests/components/twitch/__init__.py @@ -0,0 +1 @@ +"""Tests for the twitch integration.""" diff --git a/tests/components/twitch/test_config_flow.py b/tests/components/twitch/test_config_flow.py new file mode 100644 index 00000000000000..28d887d1feb3c8 --- /dev/null +++ b/tests/components/twitch/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the twitch config flow.""" +from asynctest import patch + +from homeassistant import config_entries, setup +from homeassistant.components.twitch.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.twitch.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.twitch.config_flow.PlaceholderHub.authenticate", + return_value=True, + ), patch( + "homeassistant.components.twitch.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.twitch.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Name of the device" + assert result2["data"] == { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.twitch.config_flow.PlaceholderHub.authenticate", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.twitch.config_flow.PlaceholderHub.authenticate", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}