From 6c8ea5777d1742f6777b51faa128a6f69cb0b8c7 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Mar 2020 01:13:59 -0500 Subject: [PATCH 1/9] add remote platform to directv. --- homeassistant/components/directv/remote py | 136 +++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 homeassistant/components/directv/remote py diff --git a/homeassistant/components/directv/remote py b/homeassistant/components/directv/remote py new file mode 100644 index 0000000000000..4343a48d8c1bb --- /dev/null +++ b/homeassistant/components/directv/remote py @@ -0,0 +1,136 @@ +"""Support for the DIRECTV remote.""" +import logging +from typing import Callable, List + +from DirectPy import DIRECTV +from requests.exceptions import RequestException + +from homeassistant.components.remote import RemoteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + DATA_CLIENT, + DATA_LOCATIONS, + DATA_VERSION_INFO, + DEFAULT_MANUFACTURER, + DOMAIN, + MODEL_CLIENT, + MODEL_HOST, +) + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List, bool], None], +) -> bool: + """Load DirecTV remote based on a config entry.""" + locations = hass.data[DOMAIN][entry.entry_id][DATA_LOCATIONS] + version_info = hass.data[DOMAIN][entry.entry_id][DATA_VERSION_INFO] + entities = [] + + for loc in locations["locations"]: + if "locationName" not in loc or "clientAddr" not in loc: + continue + + if loc["clientAddr"] != "0": + dtv = DIRECTV( + entry.data[CONF_HOST], + DEFAULT_PORT, + loc["clientAddr"], + determine_state=False, + ) + else: + dtv = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + + entities.append( + DirecTvRemote( + str.title(loc["locationName"]), loc["clientAddr"], dtv, version_info, + ) + ) + + async_add_entities(entities, True) + + +class DirecTvRemote(RemoteDevice): + """Device that sends commands to a DirecTV receiver.""" + + def __init__( + self, + name: str, + device: str, + dtv: DIRECTV, + version_info: Optional[Dict] = None, + ): + """Initialize the DirecTV device.""" + self.dtv = dtv + self._name = name + self._unique_id = None + self._is_client = device != " + self._receiver_id = None + self._software_version = None + + if self._is_client: + self._model = MODEL_CLIENT + self._unique_id = device + + if version_info: + self._receiver_id = "".join(version_info["receiverId"].split()) + + if not self._is_client: + self._unique_id = self._receiver_id + self._model = MODEL_HOST + self._software_version = version_info["stbSoftwareVersion"] + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return device specific attributes.""" + return { + "name": self.name, + "identifiers": {(DOMAIN, self.unique_id)}, + "manufacturer": DEFAULT_MANUFACTURER, + "model": self._model, + "sw_version": self._software_version, + "via_device": (DOMAIN, self._receiver_id), + } + + @property + def should_poll(self): + """No polling needed.""" + return False + + def _send_key(self, key): + """Send a key press command. + + Supported keys: power, poweron, poweroff, format, + pause, rew, replay, stop, advance, ffwd, record, + play, guide, active, list, exit, back, menu, info, + up, down, left, right, select, red, green, yellow, + blue, chanup, chandown, prev, 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, dash, enter + """ + _LOGGER.debug("Sending key: '%s'", key) + try: + self.dtv.key_press(key) + except RequestException as ex: + _LOGGER.error( + "Transmit of key failed, %s, exception: %s", key, ex + ) + + def send_command(self, command, **kwargs): + """Send a command to a device.""" + for single_command in command: + self._send_key(single_command) From 5d74f255a55a98185bd73a43b3e8d7db424baa73 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Mar 2020 01:16:18 -0500 Subject: [PATCH 2/9] Update __init__.py --- homeassistant/components/directv/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index 0be5957a29a26..677487945be87 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -32,7 +32,7 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["media_player"] +PLATFORMS = ["media_player", "remote"] SCAN_INTERVAL = timedelta(seconds=30) From df2a4a9947248618ef55d269e90b0c1b8fa43fd7 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Mar 2020 11:47:40 -0500 Subject: [PATCH 3/9] Update .coveragerc --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 851922e4f3a99..08f6f8f7e3d9a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -137,6 +137,7 @@ omit = homeassistant/components/dht/sensor.py homeassistant/components/digital_ocean/* homeassistant/components/digitalloggers/switch.py + homeassistant/components/directv/remote.py homeassistant/components/discogs/sensor.py homeassistant/components/discord/notify.py homeassistant/components/dlib_face_detect/image_processing.py From 2da2cdde9323f8f8e46d9b028c7ad56f7292791c Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Mar 2020 11:55:14 -0500 Subject: [PATCH 4/9] Rename remote py to remote.py --- homeassistant/components/directv/{remote py => remote.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename homeassistant/components/directv/{remote py => remote.py} (100%) diff --git a/homeassistant/components/directv/remote py b/homeassistant/components/directv/remote.py similarity index 100% rename from homeassistant/components/directv/remote py rename to homeassistant/components/directv/remote.py From 7478245d73a4e016b428f1c6c696b292688ebec9 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Mar 2020 12:06:27 -0500 Subject: [PATCH 5/9] Update remote.py --- homeassistant/components/directv/remote.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 4343a48d8c1bb..96ff1f368563e 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, @@ -69,7 +70,7 @@ def __init__( self.dtv = dtv self._name = name self._unique_id = None - self._is_client = device != " + self._is_client = device != "0" self._receiver_id = None self._software_version = None From 05955f5b3ea6f9a799a4a6f1bcbac87edf54f126 Mon Sep 17 00:00:00 2001 From: docker Date: Tue, 31 Mar 2020 22:32:23 -0500 Subject: [PATCH 6/9] squash. --- .coveragerc | 1 - .../components/directv/manifest.json | 2 +- homeassistant/components/directv/remote.py | 129 ++++++------------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/directv/test_remote.py | 128 +++++++++++++++++ 6 files changed, 173 insertions(+), 91 deletions(-) create mode 100644 tests/components/directv/test_remote.py diff --git a/.coveragerc b/.coveragerc index 08f6f8f7e3d9a..851922e4f3a99 100644 --- a/.coveragerc +++ b/.coveragerc @@ -137,7 +137,6 @@ omit = homeassistant/components/dht/sensor.py homeassistant/components/digital_ocean/* homeassistant/components/digitalloggers/switch.py - homeassistant/components/directv/remote.py homeassistant/components/discogs/sensor.py homeassistant/components/discord/notify.py homeassistant/components/dlib_face_detect/image_processing.py diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 4a712ba053ec8..8474849bdaa57 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -2,7 +2,7 @@ "domain": "directv", "name": "DirecTV", "documentation": "https://www.home-assistant.io/integrations/directv", - "requirements": ["directv==0.2.0"], + "requirements": ["directv==0.3.0"], "dependencies": [], "codeowners": ["@ctalkington"], "quality_scale": "gold", diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 96ff1f368563e..12d784137ec69 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -1,24 +1,15 @@ """Support for the DIRECTV remote.""" import logging -from typing import Callable, List +from typing import Any, Callable, Dict, Iterable, List, Optional -from DirectPy import DIRECTV -from requests.exceptions import RequestException +from directv import DIRECTV, DIRECTVError from homeassistant.components.remote import RemoteDevice from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST from homeassistant.helpers.typing import HomeAssistantType -from .const import ( - DATA_CLIENT, - DATA_LOCATIONS, - DATA_VERSION_INFO, - DEFAULT_MANUFACTURER, - DOMAIN, - MODEL_CLIENT, - MODEL_HOST, -) +from . import DIRECTVEntity +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -29,92 +20,61 @@ async def async_setup_entry( async_add_entities: Callable[[List, bool], None], ) -> bool: """Load DirecTV remote based on a config entry.""" - locations = hass.data[DOMAIN][entry.entry_id][DATA_LOCATIONS] - version_info = hass.data[DOMAIN][entry.entry_id][DATA_VERSION_INFO] + dtv = hass.data[DOMAIN][entry.entry_id] entities = [] - for loc in locations["locations"]: - if "locationName" not in loc or "clientAddr" not in loc: - continue - - if loc["clientAddr"] != "0": - dtv = DIRECTV( - entry.data[CONF_HOST], - DEFAULT_PORT, - loc["clientAddr"], - determine_state=False, - ) - else: - dtv = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] - + for location in dtv.device.locations: entities.append( - DirecTvRemote( - str.title(loc["locationName"]), loc["clientAddr"], dtv, version_info, + DIRECTVRemote( + dtv=dtv, name=str.title(location.name), address=location.address, ) ) async_add_entities(entities, True) -class DirecTvRemote(RemoteDevice): +class DIRECTVRemote(DIRECTVEntity, RemoteDevice): """Device that sends commands to a DirecTV receiver.""" - def __init__( - self, - name: str, - device: str, - dtv: DIRECTV, - version_info: Optional[Dict] = None, - ): - """Initialize the DirecTV device.""" - self.dtv = dtv - self._name = name - self._unique_id = None - self._is_client = device != "0" - self._receiver_id = None - self._software_version = None - - if self._is_client: - self._model = MODEL_CLIENT - self._unique_id = device - - if version_info: - self._receiver_id = "".join(version_info["receiverId"].split()) - - if not self._is_client: - self._unique_id = self._receiver_id - self._model = MODEL_HOST - self._software_version = version_info["stbSoftwareVersion"] - - @property - def name(self): - """Return the name of the device.""" - return self._name + def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + """Initialize DirecTV remote.""" + super().__init__( + dtv=dtv, name=name, address=address, + ) @property def unique_id(self): """Return a unique ID.""" - return self._unique_id + if self._address == "0": + return self.dtv.device.info.receiver_id + + return self._address @property - def device_info(self): - """Return device specific attributes.""" - return { - "name": self.name, - "identifiers": {(DOMAIN, self.unique_id)}, - "manufacturer": DEFAULT_MANUFACTURER, - "model": self._model, - "sw_version": self._software_version, - "via_device": (DOMAIN, self._receiver_id), - } + def is_on(self) -> bool: + """Return True if entity is on.""" + status = await self.dtv.status(self._address) + + if status == "active": + return True + + return False @property def should_poll(self): """No polling needed.""" return False - def _send_key(self, key): - """Send a key press command. + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.dtv.remote("poweron", self._address) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.dtv.remote("poweroff", self._address) + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to a device. Supported keys: power, poweron, poweroff, format, pause, rew, replay, stop, advance, ffwd, record, @@ -123,15 +83,10 @@ def _send_key(self, key): blue, chanup, chandown, prev, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, dash, enter """ - _LOGGER.debug("Sending key: '%s'", key) - try: - self.dtv.key_press(key) - except RequestException as ex: - _LOGGER.error( - "Transmit of key failed, %s, exception: %s", key, ex - ) - - def send_command(self, command, **kwargs): - """Send a command to a device.""" for single_command in command: - self._send_key(single_command) + try: + await self.dtv.remote(single_command, self._address) + except DIRECTVError: + _LOGGER.exception( + "Sending command %s to device %s failed", single_command, self._device_id, + ) diff --git a/requirements_all.txt b/requirements_all.txt index 3239eef2b2e43..e9f4fabbff538 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -447,7 +447,7 @@ deluge-client==1.7.1 denonavr==0.8.1 # homeassistant.components.directv -directv==0.2.0 +directv==0.3.0 # homeassistant.components.discogs discogs_client==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4419872e5a3e..d9b2202d3c528 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -178,7 +178,7 @@ defusedxml==0.6.0 denonavr==0.8.1 # homeassistant.components.directv -directv==0.2.0 +directv==0.3.0 # homeassistant.components.updater distro==1.4.0 diff --git a/tests/components/directv/test_remote.py b/tests/components/directv/test_remote.py new file mode 100644 index 0000000000000..aa13721f341c2 --- /dev/null +++ b/tests/components/directv/test_remote.py @@ -0,0 +1,128 @@ +"""The tests for the DirecTV remote platform.""" +from typing import Any, List, Optional + +from asynctest import patch + +from homeassistant.components.remote import ( + ATTR_COMMAND, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_NUM_REPEATS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ( + ENTITY_MATCH_ALL, + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.helpers.typing import HomeAssistantType +from tests.components.directv import setup_integration +from tests.test_util.aiohttp import AiohttpClientMocker + +ATTR_UNIQUE_ID = "unique_id" +CLIENT_ENTITY_ID = f"{REMOTE_DOMAIN}.client" +MAIN_ENTITY_ID = f"{REMOTE_DOMAIN}.host" +UNAVAILABLE_ENTITY_ID = f"{REMOTE_DOMAIN}.unavailable_client" + +# pylint: disable=redefined-outer-name + + +async def async_send_command( + hass: HomeAssistantType, + command: List[str], + entity_id: Any = ENTITY_MATCH_ALL, + device: str = None, + num_repeats: str = None, + delay_secs: str = None, +) -> None: + """Send a command to a device.""" + data = {ATTR_COMMAND: command} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + if device: + data[ATTR_DEVICE] = device + + if num_repeats: + data[ATTR_NUM_REPEATS] = num_repeats + + if delay_secs: + data[ATTR_DELAY_SECS] = delay_secs + + await hass.services.async_call(REMOTE_DOMAIN, SERVICE_SEND_COMMAND, data) + + +async def async_turn_on( + hass: HomeAssistantType, entity_id: Any = ENTITY_MATCH_ALL +) -> None: + """Turn on device.""" + data = {} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(REMOTE_DOMAIN, SERVICE_TURN_ON, data) + + +async def async_turn_off( + hass: HomeAssistantType, entity_id: Any = ENTITY_MATCH_ALL +) -> None: + """Turn off remote.""" + data = {} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(REMOTE_DOMAIN, SERVICE_TURN_OFF, data) + + +async def test_setup( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with basic config.""" + await setup_integration(hass, aioclient_mock) + assert hass.states.get(MAIN_ENTITY_ID) + assert hass.states.get(CLIENT_ENTITY_ID) + assert hass.states.get(UNAVAILABLE_ENTITY_ID) + + +async def test_unique_id( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test unique id.""" + await setup_integration(hass, aioclient_mock) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + main = entity_registry.async_get(MAIN_ENTITY_ID) + assert main.unique_id == "028877455858" + + client = entity_registry.async_get(CLIENT_ENTITY_ID) + assert client.unique_id == "2CA17D1CD30X" + + unavailable_client = entity_registry.async_get(UNAVAILABLE_ENTITY_ID) + assert unavailable_client.unique_id == "9XXXXXXXXXX9" + +async def test_main_services( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the different services.""" + await setup_integration(hass, aioclient_mock) + + with patch("directv.DIRECTV.remote") as remote_mock: + await async_turn_off(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + remote_mock.assert_called_once_with("poweroff", "0") + + with patch("directv.DIRECTV.remote") as remote_mock: + await async_turn_on(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + remote_mock.assert_called_once_with("poweron", "0") + + with patch("directv.DIRECTV.remote") as remote_mock: + await async_send_command(hass, ["dash"], MAIN_ENTITY_ID) + await hass.async_block_till_done() + remote_mock.assert_called_once_with("dash", "0") From dcc14bfef6134f938953764e6abd0f28aba5421f Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Tue, 31 Mar 2020 23:16:31 -0500 Subject: [PATCH 7/9] Update remote.py --- homeassistant/components/directv/remote.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 12d784137ec69..479a0cc8ba128 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -53,12 +53,7 @@ def unique_id(self): @property def is_on(self) -> bool: """Return True if entity is on.""" - status = await self.dtv.status(self._address) - - if status == "active": - return True - - return False + return True @property def should_poll(self): From a3335cc95359f219e2cd264d3d2ea00c170b042e Mon Sep 17 00:00:00 2001 From: docker Date: Wed, 1 Apr 2020 09:39:01 -0500 Subject: [PATCH 8/9] squash. --- homeassistant/components/directv/remote.py | 33 +++++++++++++++++----- tests/components/directv/test_remote.py | 6 ++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 479a0cc8ba128..8cf115ec771dd 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -1,6 +1,7 @@ """Support for the DIRECTV remote.""" +from datetime import timedelta import logging -from typing import Any, Callable, Dict, Iterable, List, Optional +from typing import Any, Callable, Iterable, List from directv import DIRECTV, DIRECTVError @@ -13,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=2) + async def async_setup_entry( hass: HomeAssistantType, @@ -42,6 +45,14 @@ def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: dtv=dtv, name=name, address=address, ) + self._available = False + self._is_on = True + + @property + def available(self): + """Return if able to retrieve information from device or not.""" + return self._available + @property def unique_id(self): """Return a unique ID.""" @@ -53,12 +64,18 @@ def unique_id(self): @property def is_on(self) -> bool: """Return True if entity is on.""" - return True + return self._is_on - @property - def should_poll(self): - """No polling needed.""" - return False + async def async_update(self) -> None: + """Update device state.""" + status = await self.dtv.status(self._address) + + if status == "active" or status == "standby": + self._available = True + self._is_on = status == "active" + else: + self._available = False + self._is_on = False async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -83,5 +100,7 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non await self.dtv.remote(single_command, self._address) except DIRECTVError: _LOGGER.exception( - "Sending command %s to device %s failed", single_command, self._device_id, + "Sending command %s to device %s failed", + single_command, + self._device_id, ) diff --git a/tests/components/directv/test_remote.py b/tests/components/directv/test_remote.py index aa13721f341c2..1e598b358928b 100644 --- a/tests/components/directv/test_remote.py +++ b/tests/components/directv/test_remote.py @@ -1,5 +1,5 @@ """The tests for the DirecTV remote platform.""" -from typing import Any, List, Optional +from typing import Any, List from asynctest import patch @@ -12,12 +12,13 @@ SERVICE_SEND_COMMAND, ) from homeassistant.const import ( - ENTITY_MATCH_ALL, ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.helpers.typing import HomeAssistantType + from tests.components.directv import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -106,6 +107,7 @@ async def test_unique_id( unavailable_client = entity_registry.async_get(UNAVAILABLE_ENTITY_ID) assert unavailable_client.unique_id == "9XXXXXXXXXX9" + async def test_main_services( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: From b7f4f361a45f6c63352079aeb1ffb3c604d56f7d Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 1 Apr 2020 10:23:09 -0500 Subject: [PATCH 9/9] Update remote.py --- homeassistant/components/directv/remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 8cf115ec771dd..8bc7c2208338b 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -70,7 +70,7 @@ async def async_update(self) -> None: """Update device state.""" status = await self.dtv.status(self._address) - if status == "active" or status == "standby": + if status in ("active", "standby"): self._available = True self._is_on = status == "active" else: