Skip to content
Closed
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
42b1745
Add samsungtv zeroconf
escoand May 16, 2020
965becd
update unique id
escoand May 16, 2020
53454a3
Update config_flow.py
escoand May 16, 2020
a8c6a6f
Update config_flow.py
escoand May 16, 2020
d115cba
.
escoand May 18, 2020
4deffb2
flake
escoand May 18, 2020
9ee5b11
hassfest
escoand May 18, 2020
6b1b832
Update __init__.py
escoand May 18, 2020
78fdb62
Update const.py
escoand May 18, 2020
3958f39
add device info
escoand May 20, 2020
a87d6e2
Merge branch 'patch-1' of https://github.com/escoand/home-assistant i…
escoand May 20, 2020
70f6b81
Merge remote-tracking branch 'upstream/dev' into samsungtv_zeroconf
escoand May 20, 2020
62f21fd
fix tests
escoand May 20, 2020
f5877ab
minor
escoand May 20, 2020
d26a940
Update config_flow.py
escoand May 21, 2020
5608d9b
fix tests
escoand May 28, 2020
ad3a951
Merge branch 'patch-1' of github.com:escoand/home-assistant into sams…
escoand May 28, 2020
a4c38fd
merge upstream/dev
escoand May 28, 2020
1b3ec69
add update old test
escoand May 28, 2020
71f500f
fix pylint
escoand May 28, 2020
62d63bf
Fix connections tuple
steve-gombos Jun 19, 2020
2007542
Merge pull request #1 from steve-gombos/patch-2
escoand Jun 20, 2020
4c5c6f6
Increase timeout
steve-gombos Jun 28, 2020
282c5dc
Merge pull request #2 from steve-gombos/patch-3
escoand Jun 28, 2020
1435350
Update tests.
steve-gombos Jun 28, 2020
fc3116d
Merge pull request #3 from steve-gombos/patch-2
escoand Jun 29, 2020
0a17c28
Merge branch 'dev' into patch-1
escoand Jun 29, 2020
334cbde
Merge branch 'dev' into patch-1
escoand Aug 5, 2020
164d9bd
ssl before plain websocket port
escoand Aug 5, 2020
5a81c5a
Update __init__.py
escoand Aug 11, 2020
7b73c70
fix comments
escoand Aug 11, 2020
fa4c0a8
Update const.py
escoand Aug 11, 2020
8d43b53
Update media_player.py
escoand Aug 11, 2020
4d370d8
Update config_flow.py
escoand Aug 12, 2020
b27b3c0
Update media_player.py
escoand Aug 20, 2020
854fb47
Update test_init.py
escoand Aug 20, 2020
e13cb71
Merge branch 'dev' into patch-1
escoand Aug 20, 2020
3ab3dad
fix media_player tests
escoand Aug 20, 2020
d2f830a
fix config_flow tests
escoand Aug 20, 2020
7b4d75f
fix device info
escoand Sep 8, 2020
295558b
Merge branch 'dev' into patch-1
escoand Sep 8, 2020
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
14 changes: 4 additions & 10 deletions homeassistant/components/samsungtv/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
"""The Samsung TV integration."""
import socket

import voluptuous as vol

from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
Expand All @@ -13,7 +11,7 @@
def ensure_unique_hosts(value):
"""Validate that all configs have a unique host."""
vol.Schema(vol.Unique("duplicate host entries found"))(
[socket.gethostbyname(entry[CONF_HOST]) for entry in value]
[entry[CONF_HOST] for entry in value]
)
return value

Expand Down Expand Up @@ -42,15 +40,11 @@ def ensure_unique_hosts(value):

async def async_setup(hass, config):
"""Set up the Samsung TV integration."""
hass.data[DOMAIN] = {}
if DOMAIN in config:
hass.data[DOMAIN] = {}
for entry_config in config[DOMAIN]:
ip_address = await hass.async_add_executor_job(
socket.gethostbyname, entry_config[CONF_HOST]
)
hass.data[DOMAIN][ip_address] = {
CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION)
}
host = entry_config[CONF_HOST]
hass.data[DOMAIN][host] = {CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION)}
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": "import"}, data=entry_config
Expand Down
27 changes: 23 additions & 4 deletions homeassistant/components/samsungtv/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from samsungctl import Remote
from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse
from samsungtvws import SamsungTVWS
from samsungtvws.exceptions import ConnectionFailure
from samsungtvws.exceptions import ConnectionFailure, HttpApiError
from websocket import WebSocketException

from homeassistant.const import (
Expand All @@ -27,6 +27,7 @@
RESULT_SUCCESS,
VALUE_CONF_ID,
VALUE_CONF_NAME,
WEBSOCKET_PORTS,
)


Expand Down Expand Up @@ -58,6 +59,10 @@ def register_reauth_callback(self, func):
def try_connect(self):
"""Try to connect to the TV."""

@abstractmethod
def device_info(self):
"""Try to gather infos of this TV."""

def is_on(self):
"""Tells if the TV is on."""
self.close_remote()
Expand Down Expand Up @@ -166,6 +171,10 @@ def try_connect(self):
LOGGER.debug("Failing config: %s, error: %s", config, err)
return RESULT_NOT_SUCCESSFUL

def device_info(self):
"""Try to gather infos of this device."""
return None

def _get_remote(self):
"""Create or return a remote control instance."""
if self._remote is None:
Expand Down Expand Up @@ -196,7 +205,7 @@ def __init__(self, method, host, port, token=None):

def try_connect(self):
"""Try to connect to the Websocket TV."""
for self.port in (8001, 8002):
for self.port in WEBSOCKET_PORTS:
config = {
CONF_NAME: VALUE_CONF_NAME,
CONF_HOST: self.host,
Expand Down Expand Up @@ -234,6 +243,17 @@ def try_connect(self):

return RESULT_NOT_SUCCESSFUL

def device_info(self):
"""Try to gather infos of this TV."""
remote = self._get_remote()
if remote:
try:
return remote.rest_device_info()
except HttpApiError:
# unable to get, ignore
pass
return None

def _send_key(self, key):
"""Send the key using websocket protocol."""
if key == "KEY_POWEROFF":
Expand All @@ -258,7 +278,6 @@ def _get_remote(self):
# A removed auth will lead to socket timeout because waiting for auth popup is just an open socket
except ConnectionFailure:
self._notify_callback()
raise
except WebSocketException:
except (WebSocketException, OSError):
self._remote = None
return self._remote
153 changes: 99 additions & 54 deletions homeassistant/components/samsungtv/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
"""Config flow for Samsung TV."""
import socket
from socket import gethostbyname
from urllib.parse import urlparse

import voluptuous as vol

from homeassistant import config_entries
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.ssdp import (
ATTR_SSDP_LOCATION,
ATTR_UPNP_MANUFACTURER,
ATTR_UPNP_MODEL_NAME,
ATTR_UPNP_UDN,
)
from homeassistant.components.zeroconf import ATTR_PROPERTIES
from homeassistant.const import (
CONF_HOST,
CONF_ID,
CONF_IP_ADDRESS,
CONF_MAC,
CONF_METHOD,
CONF_NAME,
CONF_PORT,
Expand All @@ -32,19 +34,15 @@
METHOD_WEBSOCKET,
RESULT_AUTH_MISSING,
RESULT_NOT_SUCCESSFUL,
RESULT_NOT_SUPPORTED,
RESULT_SUCCESS,
WEBSOCKET_PORTS,
)

DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str})
SUPPORTED_METHODS = [METHOD_LEGACY, METHOD_WEBSOCKET]


def _get_ip(host):
if host is None:
return None
return socket.gethostbyname(host)


class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Samsung TV config flow."""

Expand All @@ -56,19 +54,19 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self):
"""Initialize flow."""
self._host = None
self._ip = None
self._mac = None
self._manufacturer = None
self._model = None
self._name = None
self._title = None
self._id = None
self._bridge = None
self._device_info = None

def _get_entry(self):
data = {
CONF_HOST: self._host,
CONF_ID: self._id,
CONF_IP_ADDRESS: self._ip,
CONF_MAC: self._mac,
CONF_MANUFACTURER: self._manufacturer,
CONF_METHOD: self._bridge.method,
CONF_MODEL: self._model,
Expand All @@ -77,17 +75,61 @@ def _get_entry(self):
}
if self._bridge.token:
data[CONF_TOKEN] = self._bridge.token
return self.async_create_entry(title=self._title, data=data,)
return self.async_create_entry(title=self._title, data=data)

def _abort_if_already_configured(self):
device_ip = gethostbyname(self._host)
Comment thread
escoand marked this conversation as resolved.
Outdated
for entry in self._async_current_entries():
Comment thread
MartinHjelmare marked this conversation as resolved.

# update user configured or unique_id=ip entries
if self._id and not entry.unique_id or device_ip == entry.unique_id:
data = {
key: value
for key, value in entry.data.items()
# clean up old entries
if key not in (CONF_ID, CONF_IP_ADDRESS)
}
if self._manufacturer and not data.get(CONF_MANUFACTURER):
data[CONF_MANUFACTURER] = self._manufacturer
if self._model and not data.get(CONF_MODEL):
data[CONF_MODEL] = self._model
self.hass.config_entries.async_update_entry(
Comment thread
MartinHjelmare marked this conversation as resolved.
entry, unique_id=self._id, data=data
)
raise data_entry_flow.AbortFlow("already_configured")
Comment thread
escoand marked this conversation as resolved.

if (
self._host == entry.data[CONF_HOST]
or (self._id and self._id == entry.unique_id)
or (self._mac and self._mac == entry.data.get(CONF_MAC))
):
raise data_entry_flow.AbortFlow("already_configured")

def _try_connect(self):
"""Try to connect and check auth."""
for method in SUPPORTED_METHODS:
self._bridge = SamsungTVBridge.get_bridge(method, self._host)
result = self._bridge.try_connect()
if result == RESULT_SUCCESS:
return
if result != RESULT_NOT_SUCCESSFUL:
return result
raise data_entry_flow.AbortFlow(result)
Comment thread
escoand marked this conversation as resolved.
LOGGER.debug("No working config found")
return RESULT_NOT_SUCCESSFUL
raise data_entry_flow.AbortFlow(RESULT_NOT_SUCCESSFUL)

def _get_and_check_device_info(self):
"""Try to get the device info."""
for port in WEBSOCKET_PORTS:
self._device_info = SamsungTVBridge.get_bridge(
METHOD_WEBSOCKET, self._host, port
).device_info()
if self._device_info:
device_type = self._device_info.get("device", {}).get("type")
if device_type and device_type != "Samsung SmartTV":
raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED)
self._model = self._device_info.get("device", {}).get("modelName")
return
raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED)

async def async_step_import(self, user_input=None):
"""Handle configuration by yaml file."""
Expand All @@ -96,81 +138,84 @@ async def async_step_import(self, user_input=None):
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if user_input is not None:
ip_address = await self.hass.async_add_executor_job(
_get_ip, user_input[CONF_HOST]
)

await self.async_set_unique_id(ip_address)
self._abort_if_unique_id_configured()

self._host = user_input.get(CONF_HOST)
self._ip = self.context[CONF_IP_ADDRESS] = ip_address
self._name = user_input.get(CONF_NAME)
self._host = user_input[CONF_HOST]
self._name = user_input[CONF_NAME]
self._title = self._name

result = await self.hass.async_add_executor_job(self._try_connect)
self._abort_if_already_configured()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do we set the unique id for the flow in the user step?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that only done when discovering a flow, ssdp or zeroconf, and updating an existing entry created in a user step before aborting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. In a user step we have no unique_id, especially on older legacy devices.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. We still need to guard from setting up the same device more than once in the user flow somehow. Can we at least check the existing host addresses and compare with the user input for host?


await self.hass.async_add_executor_job(self._try_connect)

if result != RESULT_SUCCESS:
return self.async_abort(reason=result)
return self._get_entry()

return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)

async def async_step_ssdp(self, discovery_info):
"""Handle a flow initialized by discovery."""
host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname
ip_address = await self.hass.async_add_executor_job(_get_ip, host)

self._host = host
self._ip = self.context[CONF_IP_ADDRESS] = ip_address
self._manufacturer = discovery_info.get(ATTR_UPNP_MANUFACTURER)
self._model = discovery_info.get(ATTR_UPNP_MODEL_NAME)
self._name = f"Samsung {self._model}"
self._id = discovery_info.get(ATTR_UPNP_UDN)
self._title = self._model
async def async_step_ssdp(self, user_input=None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def async_step_ssdp(self, user_input=None):
async def async_step_ssdp(self, discovery_info):

"""Handle a flow initialized by ssdp discovery."""
self._host = urlparse(user_input[ATTR_SSDP_LOCATION]).hostname
self._id = user_input.get(ATTR_UPNP_UDN)

# probably access denied
if self._id is None:
return self.async_abort(reason=RESULT_AUTH_MISSING)
if self._id.startswith("uuid:"):
self._id = self._id[5:]
Comment thread
escoand marked this conversation as resolved.

await self.async_set_unique_id(ip_address)
self._abort_if_unique_id_configured(
{
CONF_ID: self._id,
CONF_MANUFACTURER: self._manufacturer,
CONF_MODEL: self._model,
}
)
await self.async_set_unique_id(self._id)
await self.hass.async_add_executor_job(self._get_and_check_device_info)

self._manufacturer = user_input.get(ATTR_UPNP_MANUFACTURER)
if not self._model:
self._model = user_input.get(ATTR_UPNP_MODEL_NAME)
self._name = f"{self._manufacturer} {self._model}"
self._title = self._model

await self.hass.async_add_executor_job(self._abort_if_already_configured())
Comment thread
escoand marked this conversation as resolved.
Outdated

self.context["title_placeholders"] = {"model": self._model}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it ok to have model be None here?

return await self.async_step_confirm()

async def async_step_zeroconf(self, user_input=None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def async_step_zeroconf(self, user_input=None):
async def async_step_zeroconf(self, discovery_info):

"""Handle a flow initialized by zeroconf discovery."""
self._host = user_input[CONF_HOST]
self._id = user_input[ATTR_PROPERTIES].get("serialNumber")

if self._id:
await self.async_set_unique_id(self._id)
await self.hass.async_add_executor_job(self._get_and_check_device_info)

self._mac = user_input[ATTR_PROPERTIES].get("deviceid")
self._manufacturer = user_input[ATTR_PROPERTIES].get("manufacturer")
if not self._model:
self._model = user_input[ATTR_PROPERTIES].get("model")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use dict.get if we don't handle a None value returned.

self._name = f"{self._manufacturer} {self._model}"
self._title = self._model

await self.hass.async_add_executor_job(self._abort_if_already_configured())

self.context["title_placeholders"] = {"model": self._model}
return await self.async_step_confirm()

async def async_step_confirm(self, user_input=None):
"""Handle user-confirmation of discovered node."""
if user_input is not None:
result = await self.hass.async_add_executor_job(self._try_connect)
await self.hass.async_add_executor_job(self._try_connect)

if result != RESULT_SUCCESS:
return self.async_abort(reason=result)
return self._get_entry()

return self.async_show_form(
step_id="confirm", description_placeholders={"model": self._model}
)

async def async_step_reauth(self, user_input=None):
async def async_step_reauth(self, user_input):
"""Handle configuration by re-auth."""
self._host = user_input[CONF_HOST]
self._id = user_input.get(CONF_ID)
self._ip = user_input[CONF_IP_ADDRESS]
self._manufacturer = user_input.get(CONF_MANUFACTURER)
self._model = user_input.get(CONF_MODEL)
self._name = user_input.get(CONF_NAME)
self._title = self._model or self._name

await self.async_set_unique_id(self._ip)
await self.async_set_unique_id(self.unique_id)
self.context["title_placeholders"] = {"model": self._title}

return await self.async_step_confirm()
3 changes: 3 additions & 0 deletions homeassistant/components/samsungtv/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CONF_MANUFACTURER = "manufacturer"
CONF_MODEL = "model"
CONF_ON_ACTION = "turn_on_action"
CONF_SERIALNO = "serial_number"

RESULT_AUTH_MISSING = "auth_missing"
RESULT_SUCCESS = "success"
Expand All @@ -21,3 +22,5 @@

METHOD_LEGACY = "legacy"
METHOD_WEBSOCKET = "websocket"

WEBSOCKET_PORTS = (8002, 8001)
1 change: 1 addition & 0 deletions homeassistant/components/samsungtv/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"st": "urn:samsung.com:device:RemoteControlReceiver:1"
}
],
"zeroconf": ["_airplay._tcp.local."],
"codeowners": ["@escoand"],
"config_flow": true
}
Loading