From bc0e5b4c3ae79cc2e8d3f9f89d82d0ebd25845d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20P=C3=B6schl?= Date: Wed, 22 Jan 2020 16:24:00 +0100 Subject: [PATCH 1/6] add oauth functionality and additional attributes --- homeassistant/components/twitch/sensor.py | 91 ++++++++++++++++++----- 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index f4276160d6c472..8bb052d42ecb35 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_FRIENDLY_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -13,9 +14,17 @@ ATTR_GAME = "game" ATTR_TITLE = "title" +ATTR_SUBSCRIPTION = "subscribed" +ATTR_SUBSCRIPTION_SINCE = "subscribed_since" +ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted" +ATTR_FOLLOW = "following" +ATTR_FOLLOW_SINCE = "following_since" +ATTR_FOLLOWING = "follower" +ATTR_VIEWS = "views" CONF_CHANNELS = "channels" CONF_CLIENT_ID = "client_id" +CONF_OAUTH_TOKEN = "oauth_token" ICON = "mdi:twitch" @@ -26,6 +35,7 @@ { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_OAUTH_TOKEN): cv.string, } ) @@ -34,29 +44,35 @@ 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) + oauth_token = config.get(CONF_OAUTH_TOKEN) + client = TwitchClient(client_id, oauth_token) try: client.ingests.get_server_list() except HTTPError: - _LOGGER.error("Client ID is not valid") + _LOGGER.error("Client ID or OAuth token is not valid") return - users = client.users.translate_usernames_to_ids(channels) + channel_ids = client.users.translate_usernames_to_ids(channels) - add_entities([TwitchSensor(user, client) for user in users], True) + add_entities([TwitchSensor(channel_id, client) for channel_id in channel_ids], True) class TwitchSensor(Entity): """Representation of an Twitch channel.""" - def __init__(self, user, client): + def __init__(self, channel, 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 + self._channel = channel + self._oauth_enabled = client._oauth_token is not None + self._state = None + self._preview = None + self._game = None + self._title = None + self._subscription = None + self._follow = None + self._statistics = None @property def should_poll(self): @@ -66,7 +82,7 @@ def should_poll(self): @property def name(self): """Return the name of the sensor.""" - return self._channel + return self._channel.display_name @property def state(self): @@ -81,28 +97,67 @@ def entity_picture(self): @property def device_state_attributes(self): """Return the state attributes.""" + attr = { + ATTR_FRIENDLY_NAME: self._channel.display_name, + } + attr.update(self._statistics) + + if self._oauth_enabled: + attr.update(self._subscription) + attr.update(self._follow) + if self._state == STATE_STREAMING: - return {ATTR_GAME: self._game, ATTR_TITLE: self._title} + attr.update({ATTR_GAME: self._game, ATTR_TITLE: self._title}) + return attr @property def unique_id(self): """Return unique ID for this sensor.""" - return self._id + return self._channel.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) + + channel = self._client.channels.get_by_id(self._channel.id) + + self._statistics = { + ATTR_FOLLOWING: channel.followers, + ATTR_VIEWS: channel.views, + } + if self._oauth_enabled: + user = self._client.users.get() + + try: + sub = self._client.users.check_subscribed_to_channel( + user.id, self._channel.id + ) + self._subscription = { + ATTR_SUBSCRIPTION: True, + ATTR_SUBSCRIPTION_SINCE: sub.created_at, + ATTR_SUBSCRIPTION_GIFTED: sub.is_gift, + } + except HTTPError: + self._subscription = {ATTR_SUBSCRIPTION: False} + + try: + follow = self._client.users.check_follows_channel( + user.id, self._channel.id + ) + self._follow = {ATTR_FOLLOW: True, ATTR_FOLLOW_SINCE: follow.created_at} + except HTTPError: + self._follow = {ATTR_FOLLOW: False} + + stream = self._client.streams.get_stream_by_user(self._channel.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._game = stream.channel.game + self._title = stream.channel.status + self._preview = stream.preview.get("medium") self._state = STATE_STREAMING else: - self._preview = self._client.users.get_by_id(self._id).get("logo") + self._preview = self._channel.logo self._state = STATE_OFFLINE From 663b2fdf86b524c653b73be97b2a098bc1779c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20P=C3=B6schl?= Date: Thu, 23 Jan 2020 22:36:41 +0100 Subject: [PATCH 2/6] Add tests WIP --- requirements_test_all.txt | 3 + tests/components/twitch/__init__.py | 1 + tests/components/twitch/test_twitch.py | 172 +++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 tests/components/twitch/__init__.py create mode 100644 tests/components/twitch/test_twitch.py diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f0e6a21a2ae7a..b25f107b972ccd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -551,6 +551,9 @@ python-miio==0.4.8 # homeassistant.components.nest python-nest==4.1.0 +# homeassistant.components.twitch +python-twitch-client==0.6.0 + # homeassistant.components.velbus python-velbus==2.0.36 diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py new file mode 100644 index 00000000000000..ec26cf264ef74b --- /dev/null +++ b/tests/components/twitch/__init__.py @@ -0,0 +1 @@ +"""Tests for the Twitch component.""" diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py new file mode 100644 index 00000000000000..283ae728f95f86 --- /dev/null +++ b/tests/components/twitch/test_twitch.py @@ -0,0 +1,172 @@ +"""The tests for an update of the Twitch component.""" +from unittest.mock import MagicMock, patch + +from requests import HTTPError +from twitch.resources import Channel, Follow, Stream, Subscription, User + +from homeassistant.components import sensor +from homeassistant.setup import async_setup_component + +ENTITY_ID = "sensor.channel123" +CONFIG = { + sensor.DOMAIN: { + "platform": "twitch", + "client_id": "1234", + "channels": ["channel123"], + } +} +CONFIG_WITH_OAUTH = { + sensor.DOMAIN: { + "platform": "twitch", + "client_id": "1234", + "channels": ["channel123"], + "oauth_token": "9876", + } +} + +USER_ID = User({"id": 123, "display_name": "channel123", "logo": "logo.png"}) +STREAM_OBJECT_ONLINE = Stream( + { + "channel": {"game": "Good Game", "status": "Title"}, + "preview": {"medium": "stream-medium.png"}, + } +) +CHANNEL_OBJECT = Channel({"followers": 42, "views": 24}) +OAUTH_USER_ID = User({"id": 987}) +SUB_ACTIVE = Subscription({"created_at": "2020-01-20T21:22:42", "is_gift": False}) +FOLLOW_ACTIVE = Follow({"created_at": "2020-01-20T21:22:42"}) + + +async def test_init(hass): + """Test initial config.""" + + channels = MagicMock() + channels.get_by_id.return_value = CHANNEL_OBJECT + streams = MagicMock() + streams.get_stream_by_user.return_value = None + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels = channels + twitch_mock.streams = streams + + with patch("twitch.TwitchClient", return_value=twitch_mock): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "offline" + assert sensor_state.name == "channel123" + assert sensor_state.attributes["icon"] == "mdi:twitch" + assert sensor_state.attributes["friendly_name"] == "channel123" + assert sensor_state.attributes["views"] == 24 + assert sensor_state.attributes["following"] == 42 + + +async def test_offline(hass): + """Test offline state.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock.streams.get_stream_by_user.return_value = None + + with patch( + "twitch.TwitchClient", return_value=twitch_mock, + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "offline" + assert sensor_state.attributes["entity_picture"] == "logo.png" + + +async def test_streaming(hass): + """Test streaming state.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock.streams.get_stream_by_user.return_value = STREAM_OBJECT_ONLINE + + with patch( + "twitch.TwitchClient", return_value=twitch_mock, + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "streaming" + assert sensor_state.attributes["entity_picture"] == "stream-medium.png" + assert sensor_state.attributes["game"] == "Good Game" + assert sensor_state.attributes["title"] == "Title" + + +async def test_oauth_without_sub_and_follow(hass): + """Test state with oauth.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock._oauth_token = True # A replacement for the token + twitch_mock.users.get.return_value = OAUTH_USER_ID + twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError() + twitch_mock.users.check_follows_channel.side_effect = HTTPError() + + with patch( + "twitch.TwitchClient", return_value=twitch_mock, + ): + assert ( + await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True + ) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_sub(hass): + """Test state with oauth and sub.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock._oauth_token = True # A replacement for the token + twitch_mock.users.get.return_value = OAUTH_USER_ID + twitch_mock.users.check_subscribed_to_channel.return_value = SUB_ACTIVE + twitch_mock.users.check_follows_channel.side_effect = HTTPError() + + with patch( + "twitch.TwitchClient", return_value=twitch_mock, + ): + assert ( + await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True + ) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is True + assert sensor_state.attributes["subscribed_since"] == "2020-01-20T21:22:42" + assert sensor_state.attributes["subscription_is_gifted"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_follow(hass): + """Test state with oauth and follow.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock._oauth_token = True # A replacement for the token + twitch_mock.users.get.return_value = OAUTH_USER_ID + twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError() + twitch_mock.users.check_follows_channel.return_value = FOLLOW_ACTIVE + + with patch( + "twitch.TwitchClient", return_value=twitch_mock, + ): + assert ( + await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True + ) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert sensor_state.attributes["following"] is True + assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" From d8d507d409b2ea97b76fc09781a87633c4b6dabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20P=C3=B6schl?= Date: Mon, 3 Feb 2020 23:09:48 +0100 Subject: [PATCH 3/6] Make mocks work the correct way --- homeassistant/components/twitch/sensor.py | 4 ++-- tests/components/twitch/test_twitch.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 8bb052d42ecb35..e37b2ff9e6e84a 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -154,8 +154,8 @@ def update(self): stream = self._client.streams.get_stream_by_user(self._channel.id) if stream: - self._game = stream.channel.game - self._title = stream.channel.status + self._game = stream.channel.get("game") + self._title = stream.channel.get("status") self._preview = stream.preview.get("medium") self._state = STATE_STREAMING else: diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py index 283ae728f95f86..3cf51dfe6aff75 100644 --- a/tests/components/twitch/test_twitch.py +++ b/tests/components/twitch/test_twitch.py @@ -50,7 +50,9 @@ async def test_init(hass): twitch_mock.channels = channels twitch_mock.streams = streams - with patch("twitch.TwitchClient", return_value=twitch_mock): + with patch( + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock + ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True sensor_state = hass.states.get(ENTITY_ID) @@ -59,7 +61,7 @@ async def test_init(hass): assert sensor_state.attributes["icon"] == "mdi:twitch" assert sensor_state.attributes["friendly_name"] == "channel123" assert sensor_state.attributes["views"] == 24 - assert sensor_state.attributes["following"] == 42 + assert sensor_state.attributes["follower"] == 42 async def test_offline(hass): @@ -71,7 +73,7 @@ async def test_offline(hass): twitch_mock.streams.get_stream_by_user.return_value = None with patch( - "twitch.TwitchClient", return_value=twitch_mock, + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True @@ -89,7 +91,7 @@ async def test_streaming(hass): twitch_mock.streams.get_stream_by_user.return_value = STREAM_OBJECT_ONLINE with patch( - "twitch.TwitchClient", return_value=twitch_mock, + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True @@ -112,7 +114,7 @@ async def test_oauth_without_sub_and_follow(hass): twitch_mock.users.check_follows_channel.side_effect = HTTPError() with patch( - "twitch.TwitchClient", return_value=twitch_mock, + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, ): assert ( await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True @@ -135,7 +137,7 @@ async def test_oauth_with_sub(hass): twitch_mock.users.check_follows_channel.side_effect = HTTPError() with patch( - "twitch.TwitchClient", return_value=twitch_mock, + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, ): assert ( await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True @@ -160,7 +162,7 @@ async def test_oauth_with_follow(hass): twitch_mock.users.check_follows_channel.return_value = FOLLOW_ACTIVE with patch( - "twitch.TwitchClient", return_value=twitch_mock, + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, ): assert ( await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True From 77da1a5cb5ed36e89abaf21c5d0f44369e5ac21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20P=C3=B6schl?= Date: Mon, 17 Feb 2020 13:28:19 +0100 Subject: [PATCH 4/6] Use CONF_TOKEN constant for config --- homeassistant/components/twitch/sensor.py | 7 +++---- tests/components/twitch/test_twitch.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index e37b2ff9e6e84a..3cfea2bf11932c 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -24,7 +24,6 @@ CONF_CHANNELS = "channels" CONF_CLIENT_ID = "client_id" -CONF_OAUTH_TOKEN = "oauth_token" ICON = "mdi:twitch" @@ -35,7 +34,7 @@ { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_OAUTH_TOKEN): cv.string, + vol.Optional(CONF_TOKEN): cv.string, } ) @@ -44,7 +43,7 @@ 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] - oauth_token = config.get(CONF_OAUTH_TOKEN) + oauth_token = config.get(CONF_TOKEN) client = TwitchClient(client_id, oauth_token) try: diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py index 3cf51dfe6aff75..13d125b2d1fb6e 100644 --- a/tests/components/twitch/test_twitch.py +++ b/tests/components/twitch/test_twitch.py @@ -20,7 +20,7 @@ "platform": "twitch", "client_id": "1234", "channels": ["channel123"], - "oauth_token": "9876", + "token": "9876", } } From d945e9647cf2b86c0144715dbcced7b8565b51bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20P=C3=B6schl?= Date: Mon, 17 Feb 2020 13:29:18 +0100 Subject: [PATCH 5/6] Remove twitch from .coveragerc --- .coveragerc | 1 - 1 file changed, 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index cc2402e480b82d..4d55a94fa6784d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -748,7 +748,6 @@ omit = homeassistant/components/twentemilieu/sensor.py homeassistant/components/twilio_call/notify.py homeassistant/components/twilio_sms/notify.py - homeassistant/components/twitch/sensor.py homeassistant/components/twitter/notify.py homeassistant/components/ubee/device_tracker.py homeassistant/components/ubus/device_tracker.py From eccd86b2feb09246034876057185024e85c587b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20P=C3=B6schl?= Date: Tue, 18 Feb 2020 10:46:34 +0100 Subject: [PATCH 6/6] Update homeassistant/components/twitch/sensor.py Lets be consistent Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> --- homeassistant/components/twitch/sensor.py | 2 +- tests/components/twitch/test_twitch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 3cfea2bf11932c..1bf66810e5b090 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -19,7 +19,7 @@ ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted" ATTR_FOLLOW = "following" ATTR_FOLLOW_SINCE = "following_since" -ATTR_FOLLOWING = "follower" +ATTR_FOLLOWING = "followers" ATTR_VIEWS = "views" CONF_CHANNELS = "channels" diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py index 13d125b2d1fb6e..6c656f874d0cc3 100644 --- a/tests/components/twitch/test_twitch.py +++ b/tests/components/twitch/test_twitch.py @@ -61,7 +61,7 @@ async def test_init(hass): assert sensor_state.attributes["icon"] == "mdi:twitch" assert sensor_state.attributes["friendly_name"] == "channel123" assert sensor_state.attributes["views"] == 24 - assert sensor_state.attributes["follower"] == 42 + assert sensor_state.attributes["followers"] == 42 async def test_offline(hass):