mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
TelldusLive config flow (#18758)
* update TelldusLive to use config flow * fixes from Martin * Update homeassistant/components/tellduslive/config_flow.py Co-Authored-By: fredrike <fredrik.e@gmail.com> * revert changes in entry.py * tox tests * tox fixes * woof woof (fix for hound) * lint ignore * unload entry * coverall toxtests * fix some toxtests
This commit is contained in:
parent
f4f42176bd
commit
92e19f6001
@ -9,22 +9,35 @@ https://home-assistant.io/components/binary_sensor.tellduslive/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components import tellduslive
|
||||
from homeassistant.components import binary_sensor, tellduslive
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.tellduslive.entry import TelldusLiveEntity
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up Tellstick sensors."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Old way of setting up TelldusLive.
|
||||
|
||||
Can only be called when a user accidentally mentions the platform in their
|
||||
config. But even in that case it would have been ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up tellduslive sensors dynamically."""
|
||||
async def async_discover_binary_sensor(device_id):
|
||||
"""Discover and add a discovered sensor."""
|
||||
client = hass.data[tellduslive.DOMAIN]
|
||||
add_entities(
|
||||
TelldusLiveSensor(client, binary_sensor)
|
||||
for binary_sensor in discovery_info
|
||||
)
|
||||
async_add_entities([TelldusLiveSensor(client, device_id)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
tellduslive.TELLDUS_DISCOVERY_NEW.format(binary_sensor.DOMAIN,
|
||||
tellduslive.DOMAIN),
|
||||
async_discover_binary_sensor)
|
||||
|
||||
|
||||
class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice):
|
||||
|
@ -8,20 +8,36 @@ https://home-assistant.io/components/cover.tellduslive/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components import tellduslive
|
||||
from homeassistant.components import cover, tellduslive
|
||||
from homeassistant.components.cover import CoverDevice
|
||||
from homeassistant.components.tellduslive.entry import TelldusLiveEntity
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Telldus Live covers."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Old way of setting up TelldusLive.
|
||||
|
||||
Can only be called when a user accidentally mentions the platform in their
|
||||
config. But even in that case it would have been ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up tellduslive sensors dynamically."""
|
||||
async def async_discover_cover(device_id):
|
||||
"""Discover and add a discovered sensor."""
|
||||
client = hass.data[tellduslive.DOMAIN]
|
||||
add_entities(TelldusLiveCover(client, cover) for cover in discovery_info)
|
||||
async_add_entities([TelldusLiveCover(client, device_id)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
tellduslive.TELLDUS_DISCOVERY_NEW.format(cover.DOMAIN,
|
||||
tellduslive.DOMAIN),
|
||||
async_discover_cover,
|
||||
)
|
||||
|
||||
|
||||
class TelldusLiveCover(TelldusLiveEntity, CoverDevice):
|
||||
|
@ -49,6 +49,7 @@ CONFIG_ENTRY_HANDLERS = {
|
||||
SERVICE_DECONZ: 'deconz',
|
||||
'google_cast': 'cast',
|
||||
SERVICE_HUE: 'hue',
|
||||
SERVICE_TELLDUSLIVE: 'tellduslive',
|
||||
SERVICE_IKEA_TRADFRI: 'tradfri',
|
||||
'sonos': 'sonos',
|
||||
}
|
||||
@ -62,7 +63,6 @@ SERVICE_HANDLERS = {
|
||||
SERVICE_APPLE_TV: ('apple_tv', None),
|
||||
SERVICE_WINK: ('wink', None),
|
||||
SERVICE_XIAOMI_GW: ('xiaomi_aqara', None),
|
||||
SERVICE_TELLDUSLIVE: ('tellduslive', None),
|
||||
SERVICE_DAIKIN: ('daikin', None),
|
||||
SERVICE_SABNZBD: ('sabnzbd', None),
|
||||
SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'),
|
||||
|
@ -8,20 +8,37 @@ https://home-assistant.io/components/light.tellduslive/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components import tellduslive
|
||||
from homeassistant.components import light, tellduslive
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
|
||||
from homeassistant.components.tellduslive.entry import TelldusLiveEntity
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Tellstick Net lights."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Old way of setting up TelldusLive.
|
||||
|
||||
Can only be called when a user accidentally mentions the platform in their
|
||||
config. But even in that case it would have been ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up tellduslive sensors dynamically."""
|
||||
async def async_discover_light(device_id):
|
||||
"""Discover and add a discovered sensor."""
|
||||
client = hass.data[tellduslive.DOMAIN]
|
||||
add_entities(TelldusLiveLight(client, light) for light in discovery_info)
|
||||
async_add_entities([TelldusLiveLight(client, device_id)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
tellduslive.TELLDUS_DISCOVERY_NEW.format(light.DOMAIN,
|
||||
tellduslive.DOMAIN),
|
||||
async_discover_light,
|
||||
)
|
||||
|
||||
|
||||
class TelldusLiveLight(TelldusLiveEntity, Light):
|
||||
|
@ -6,11 +6,12 @@ https://home-assistant.io/components/sensor.tellduslive/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components import tellduslive
|
||||
from homeassistant.components import sensor, tellduslive
|
||||
from homeassistant.components.tellduslive.entry import TelldusLiveEntity
|
||||
from homeassistant.const import (
|
||||
DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE,
|
||||
TEMP_CELSIUS)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -46,12 +47,25 @@ SENSOR_TYPES = {
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Tellstick sensors."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Old way of setting up TelldusLive.
|
||||
|
||||
Can only be called when a user accidentally mentions the platform in their
|
||||
config. But even in that case it would have been ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up tellduslive sensors dynamically."""
|
||||
async def async_discover_sensor(device_id):
|
||||
"""Discover and add a discovered sensor."""
|
||||
client = hass.data[tellduslive.DOMAIN]
|
||||
add_entities(
|
||||
TelldusLiveSensor(client, sensor) for sensor in discovery_info)
|
||||
async_add_entities([TelldusLiveSensor(client, device_id)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
tellduslive.TELLDUS_DISCOVERY_NEW.format(
|
||||
sensor.DOMAIN, tellduslive.DOMAIN), async_discover_sensor)
|
||||
|
||||
|
||||
class TelldusLiveSensor(TelldusLiveEntity):
|
||||
|
@ -9,20 +9,36 @@ https://home-assistant.io/components/switch.tellduslive/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components import tellduslive
|
||||
from homeassistant.components import switch, tellduslive
|
||||
from homeassistant.components.tellduslive.entry import TelldusLiveEntity
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up Tellstick switches."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
"""Old way of setting up TelldusLive.
|
||||
|
||||
Can only be called when a user accidentally mentions the platform in their
|
||||
config. But even in that case it would have been ignored.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up tellduslive sensors dynamically."""
|
||||
async def async_discover_switch(device_id):
|
||||
"""Discover and add a discovered sensor."""
|
||||
client = hass.data[tellduslive.DOMAIN]
|
||||
add_entities(
|
||||
TelldusLiveSwitch(client, switch) for switch in discovery_info)
|
||||
async_add_entities([TelldusLiveSwitch(client, device_id)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
tellduslive.TELLDUS_DISCOVERY_NEW.format(switch.DOMAIN,
|
||||
tellduslive.DOMAIN),
|
||||
async_discover_switch,
|
||||
)
|
||||
|
||||
|
||||
class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity):
|
||||
|
24
homeassistant/components/tellduslive/.translations/en.json
Normal file
24
homeassistant/components/tellduslive/.translations/en.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Telldus Live",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Pick endpoint.",
|
||||
"description": "",
|
||||
"data": {
|
||||
"host": "Host"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authenticate against TelldusLive",
|
||||
"description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"authorize_url_timeout": "Timeout generating authorize url.",
|
||||
"authorize_url_fail": "Unknown error generating an authorize url.",
|
||||
"all_configured": "TelldusLive is already configured",
|
||||
"unknown": "Unknown error occurred"
|
||||
}
|
||||
}
|
||||
}
|
@ -4,20 +4,22 @@ Support for Telldus Live.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/tellduslive/
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.discovery import SERVICE_TELLDUSLIVE
|
||||
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant import config_entries
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
from homeassistant.helpers.event import track_time_interval
|
||||
from homeassistant.util.json import load_json, save_json
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import DOMAIN, SIGNAL_UPDATE_ENTITY
|
||||
from . import config_flow # noqa pylint_disable=unused-import
|
||||
from .const import (
|
||||
CONF_HOST, CONF_UPDATE_INTERVAL, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL,
|
||||
KEY_SESSION, MIN_UPDATE_INTERVAL, NOT_SO_PRIVATE_KEY, PUBLIC_KEY,
|
||||
SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, TELLDUS_DISCOVERY_NEW)
|
||||
|
||||
APPLICATION_NAME = 'Home Assistant'
|
||||
|
||||
@ -25,198 +27,105 @@ REQUIREMENTS = ['tellduslive==0.10.4']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TELLLDUS_CONFIG_FILE = 'tellduslive.conf'
|
||||
KEY_CONFIG = 'tellduslive_config'
|
||||
|
||||
CONF_TOKEN_SECRET = 'token_secret'
|
||||
CONF_UPDATE_INTERVAL = 'update_interval'
|
||||
|
||||
PUBLIC_KEY = 'THUPUNECH5YEQA3RE6UYUPRUZ2DUGUGA'
|
||||
NOT_SO_PRIVATE_KEY = 'PHES7U2RADREWAFEBUSTUBAWRASWUTUS'
|
||||
|
||||
MIN_UPDATE_INTERVAL = timedelta(seconds=5)
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): (
|
||||
vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)))
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN:
|
||||
vol.Schema({
|
||||
vol.Optional(CONF_HOST, default=DOMAIN):
|
||||
cv.string,
|
||||
vol.Optional(CONF_UPDATE_INTERVAL, default=SCAN_INTERVAL):
|
||||
(vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)))
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
CONFIG_INSTRUCTIONS = """
|
||||
To link your TelldusLive account:
|
||||
DATA_CONFIG_ENTRY_LOCK = 'tellduslive_config_entry_lock'
|
||||
CONFIG_ENTRY_IS_SETUP = 'telldus_config_entry_is_setup'
|
||||
|
||||
1. Click the link below
|
||||
|
||||
2. Login to Telldus Live
|
||||
|
||||
3. Authorize {app_name}.
|
||||
|
||||
4. Click the Confirm button.
|
||||
|
||||
[Link TelldusLive account]({auth_url})
|
||||
"""
|
||||
INTERVAL_TRACKER = '{}_INTERVAL'.format(DOMAIN)
|
||||
|
||||
|
||||
def setup(hass, config, session=None):
|
||||
"""Set up the Telldus Live component."""
|
||||
from tellduslive import Session, supports_local_api
|
||||
config_filename = hass.config.path(TELLLDUS_CONFIG_FILE)
|
||||
conf = load_json(config_filename)
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Create a tellduslive session."""
|
||||
from tellduslive import Session
|
||||
conf = entry.data[KEY_SESSION]
|
||||
|
||||
def request_configuration(host=None):
|
||||
"""Request TelldusLive authorization."""
|
||||
configurator = hass.components.configurator
|
||||
hass.data.setdefault(KEY_CONFIG, {})
|
||||
data_key = host or DOMAIN
|
||||
|
||||
# Configuration already in progress
|
||||
if hass.data[KEY_CONFIG].get(data_key):
|
||||
return
|
||||
|
||||
_LOGGER.info('Configuring TelldusLive %s',
|
||||
'local client: {}'.format(host) if host else
|
||||
'cloud service')
|
||||
|
||||
session = Session(public_key=PUBLIC_KEY,
|
||||
private_key=NOT_SO_PRIVATE_KEY,
|
||||
host=host,
|
||||
application=APPLICATION_NAME)
|
||||
|
||||
auth_url = session.authorize_url
|
||||
if not auth_url:
|
||||
_LOGGER.warning('Failed to retrieve authorization URL')
|
||||
return
|
||||
|
||||
_LOGGER.debug('Got authorization URL %s', auth_url)
|
||||
|
||||
def configuration_callback(callback_data):
|
||||
"""Handle the submitted configuration."""
|
||||
session.authorize()
|
||||
res = setup(hass, config, session)
|
||||
if not res:
|
||||
configurator.notify_errors(
|
||||
hass.data[KEY_CONFIG].get(data_key),
|
||||
'Unable to connect.')
|
||||
return
|
||||
|
||||
conf.update(
|
||||
{host: {CONF_HOST: host,
|
||||
CONF_TOKEN: session.access_token}} if host else
|
||||
{DOMAIN: {CONF_TOKEN: session.access_token,
|
||||
CONF_TOKEN_SECRET: session.access_token_secret}})
|
||||
save_json(config_filename, conf)
|
||||
# Close all open configurators: for now, we only support one
|
||||
# tellstick device, and configuration via either cloud service
|
||||
# or via local API, not both at the same time
|
||||
for instance in hass.data[KEY_CONFIG].values():
|
||||
configurator.request_done(instance)
|
||||
|
||||
hass.data[KEY_CONFIG][data_key] = \
|
||||
configurator.request_config(
|
||||
'TelldusLive ({})'.format(
|
||||
'LocalAPI' if host
|
||||
else 'Cloud service'),
|
||||
configuration_callback,
|
||||
description=CONFIG_INSTRUCTIONS.format(
|
||||
app_name=APPLICATION_NAME,
|
||||
auth_url=auth_url),
|
||||
submit_caption='Confirm',
|
||||
entity_picture='/static/images/logo_tellduslive.png',
|
||||
)
|
||||
|
||||
def tellstick_discovered(service, info):
|
||||
"""Run when a Tellstick is discovered."""
|
||||
_LOGGER.info('Discovered tellstick device')
|
||||
|
||||
if DOMAIN in hass.data:
|
||||
_LOGGER.debug('Tellstick already configured')
|
||||
return
|
||||
|
||||
host, device = info[:2]
|
||||
|
||||
if not supports_local_api(device):
|
||||
_LOGGER.debug('Tellstick does not support local API')
|
||||
# Configure the cloud service
|
||||
hass.add_job(request_configuration)
|
||||
return
|
||||
|
||||
_LOGGER.debug('Tellstick does support local API')
|
||||
|
||||
# Ignore any known devices
|
||||
if conf and host in conf:
|
||||
_LOGGER.debug('Discovered already known device: %s', host)
|
||||
return
|
||||
|
||||
# Offer configuration of both live and local API
|
||||
request_configuration()
|
||||
request_configuration(host)
|
||||
|
||||
discovery.listen(hass, SERVICE_TELLDUSLIVE, tellstick_discovered)
|
||||
|
||||
if session:
|
||||
_LOGGER.debug('Continuing setup configured by configurator')
|
||||
elif conf and CONF_HOST in next(iter(conf.values())):
|
||||
# For now, only one local device is supported
|
||||
_LOGGER.debug('Using Local API pre-configured by configurator')
|
||||
session = Session(**next(iter(conf.values())))
|
||||
elif DOMAIN in conf:
|
||||
_LOGGER.debug('Using TelldusLive cloud service '
|
||||
'pre-configured by configurator')
|
||||
session = Session(PUBLIC_KEY, NOT_SO_PRIVATE_KEY,
|
||||
application=APPLICATION_NAME, **conf[DOMAIN])
|
||||
elif config.get(DOMAIN):
|
||||
_LOGGER.info('Found entry in configuration.yaml. '
|
||||
'Requesting TelldusLive cloud service configuration')
|
||||
request_configuration()
|
||||
|
||||
if CONF_HOST in config.get(DOMAIN, {}):
|
||||
_LOGGER.info('Found TelldusLive host entry in configuration.yaml. '
|
||||
'Requesting Telldus Local API configuration')
|
||||
request_configuration(config.get(DOMAIN).get(CONF_HOST))
|
||||
|
||||
return True
|
||||
if KEY_HOST in conf:
|
||||
session = Session(**conf)
|
||||
else:
|
||||
_LOGGER.info('Tellstick discovered, awaiting discovery callback')
|
||||
return True
|
||||
session = Session(
|
||||
PUBLIC_KEY,
|
||||
NOT_SO_PRIVATE_KEY,
|
||||
application=APPLICATION_NAME,
|
||||
**conf,
|
||||
)
|
||||
|
||||
if not session.is_authorized:
|
||||
_LOGGER.error('Authentication Error')
|
||||
return False
|
||||
|
||||
client = TelldusLiveClient(hass, config, session)
|
||||
hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock()
|
||||
hass.data[CONFIG_ENTRY_IS_SETUP] = set()
|
||||
|
||||
client = TelldusLiveClient(hass, entry, session)
|
||||
hass.data[DOMAIN] = client
|
||||
client.update()
|
||||
|
||||
interval = config.get(DOMAIN, {}).get(CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL)
|
||||
await client.update()
|
||||
|
||||
interval = timedelta(seconds=entry.data[KEY_SCAN_INTERVAL])
|
||||
_LOGGER.debug('Update interval %s', interval)
|
||||
track_time_interval(hass, client.update, interval)
|
||||
hass.data[INTERVAL_TRACKER] = async_track_time_interval(
|
||||
hass, client.update, interval)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the Telldus Live component."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={'source': config_entries.SOURCE_IMPORT},
|
||||
data={
|
||||
KEY_HOST: config[DOMAIN].get(CONF_HOST),
|
||||
KEY_SCAN_INTERVAL: config[DOMAIN].get(CONF_UPDATE_INTERVAL),
|
||||
}))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, config_entry):
|
||||
"""Unload a config entry."""
|
||||
interval_tracker = hass.data.pop(INTERVAL_TRACKER)
|
||||
interval_tracker()
|
||||
await asyncio.wait([
|
||||
hass.config_entries.async_forward_entry_unload(config_entry, component)
|
||||
for component in hass.data.pop(CONFIG_ENTRY_IS_SETUP)
|
||||
])
|
||||
del hass.data[DOMAIN]
|
||||
del hass.data[DATA_CONFIG_ENTRY_LOCK]
|
||||
return True
|
||||
|
||||
|
||||
class TelldusLiveClient:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, hass, config, session):
|
||||
def __init__(self, hass, config_entry, session):
|
||||
"""Initialize the Tellus data object."""
|
||||
self._known_devices = set()
|
||||
|
||||
self._hass = hass
|
||||
self._config = config
|
||||
self._config_entry = config_entry
|
||||
self._client = session
|
||||
|
||||
def update(self, *args):
|
||||
"""Update local list of devices."""
|
||||
_LOGGER.debug('Updating')
|
||||
if not self._client.update():
|
||||
_LOGGER.warning('Failed request')
|
||||
|
||||
@staticmethod
|
||||
def identify_device(device):
|
||||
"""Find out what type of HA component to create."""
|
||||
if device.is_sensor:
|
||||
return 'sensor'
|
||||
from tellduslive import (DIM, UP, TURNON)
|
||||
if device.methods & DIM:
|
||||
return 'light'
|
||||
@ -226,28 +135,41 @@ class TelldusLiveClient:
|
||||
return 'switch'
|
||||
if device.methods == 0:
|
||||
return 'binary_sensor'
|
||||
_LOGGER.warning(
|
||||
"Unidentified device type (methods: %d)", device.methods)
|
||||
_LOGGER.warning("Unidentified device type (methods: %d)",
|
||||
device.methods)
|
||||
return 'switch'
|
||||
|
||||
def discover(device_id, component):
|
||||
async def _discover(self, device_id):
|
||||
"""Discover the component."""
|
||||
discovery.load_platform(
|
||||
self._hass, component, DOMAIN, [device_id], self._config)
|
||||
|
||||
for device in self._client.devices:
|
||||
if device.device_id in self._known_devices:
|
||||
continue
|
||||
device = self._client.device(device_id)
|
||||
component = self.identify_device(device)
|
||||
async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]:
|
||||
if component not in self._hass.data[CONFIG_ENTRY_IS_SETUP]:
|
||||
await self._hass.config_entries.async_forward_entry_setup(
|
||||
self._config_entry, component)
|
||||
self._hass.data[CONFIG_ENTRY_IS_SETUP].add(component)
|
||||
device_ids = []
|
||||
if device.is_sensor:
|
||||
for item in device.items:
|
||||
discover((device.device_id, item.name, item.scale),
|
||||
'sensor')
|
||||
device_ids.append((device.device_id, item.name, item.scale))
|
||||
else:
|
||||
discover(device.device_id,
|
||||
identify_device(device))
|
||||
self._known_devices.add(device.device_id)
|
||||
device_ids.append(device_id)
|
||||
for _id in device_ids:
|
||||
async_dispatcher_send(
|
||||
self._hass, TELLDUS_DISCOVERY_NEW.format(component, DOMAIN),
|
||||
_id)
|
||||
|
||||
dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
|
||||
async def update(self, *args):
|
||||
"""Periodically poll the servers for current state."""
|
||||
_LOGGER.debug('Updating')
|
||||
if not self._client.update():
|
||||
_LOGGER.warning('Failed request')
|
||||
|
||||
dev_ids = {dev.device_id for dev in self._client.devices}
|
||||
new_devices = dev_ids - self._known_devices
|
||||
await asyncio.gather(*[self._discover(d_id) for d_id in new_devices])
|
||||
self._known_devices |= new_devices
|
||||
async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
|
||||
|
||||
def device(self, device_id):
|
||||
"""Return device representation."""
|
||||
|
150
homeassistant/components/tellduslive/config_flow.py
Normal file
150
homeassistant/components/tellduslive/config_flow.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Config flow for Tellduslive."""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.util.json import load_json
|
||||
|
||||
from .const import (
|
||||
APPLICATION_NAME, CLOUD_NAME, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL,
|
||||
KEY_SESSION, NOT_SO_PRIVATE_KEY, PUBLIC_KEY, SCAN_INTERVAL,
|
||||
TELLDUS_CONFIG_FILE)
|
||||
|
||||
KEY_TOKEN = 'token'
|
||||
KEY_TOKEN_SECRET = 'token_secret'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register('tellduslive')
|
||||
class FlowHandler(config_entries.ConfigFlow):
|
||||
"""Handle a config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
def __init__(self):
|
||||
"""Init config flow."""
|
||||
self._hosts = [CLOUD_NAME]
|
||||
self._host = None
|
||||
self._session = None
|
||||
self._scan_interval = SCAN_INTERVAL
|
||||
|
||||
def _get_auth_url(self):
|
||||
return self._session.authorize_url
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Let user select host or cloud."""
|
||||
if self.hass.config_entries.async_entries(DOMAIN):
|
||||
return self.async_abort(reason='already_setup')
|
||||
|
||||
if user_input is not None or len(self._hosts) == 1:
|
||||
if user_input is not None and user_input[KEY_HOST] != CLOUD_NAME:
|
||||
self._host = user_input[KEY_HOST]
|
||||
return await self.async_step_auth()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='user',
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(KEY_HOST):
|
||||
vol.In(list(self._hosts))
|
||||
}))
|
||||
|
||||
async def async_step_auth(self, user_input=None):
|
||||
"""Handle the submitted configuration."""
|
||||
if not self._session:
|
||||
from tellduslive import Session
|
||||
self._session = Session(
|
||||
public_key=PUBLIC_KEY,
|
||||
private_key=NOT_SO_PRIVATE_KEY,
|
||||
host=self._host,
|
||||
application=APPLICATION_NAME,
|
||||
)
|
||||
|
||||
if user_input is not None and self._session.authorize():
|
||||
host = self._host or CLOUD_NAME
|
||||
if self._host:
|
||||
session = {
|
||||
KEY_HOST: host,
|
||||
KEY_TOKEN: self._session.access_token
|
||||
}
|
||||
else:
|
||||
session = {
|
||||
KEY_TOKEN: self._session.access_token,
|
||||
KEY_TOKEN_SECRET: self._session.access_token_secret
|
||||
}
|
||||
return self.async_create_entry(
|
||||
title=host, data={
|
||||
KEY_HOST: host,
|
||||
KEY_SCAN_INTERVAL: self._scan_interval.seconds,
|
||||
KEY_SESSION: session,
|
||||
})
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(10):
|
||||
auth_url = await self.hass.async_add_executor_job(
|
||||
self._get_auth_url)
|
||||
except asyncio.TimeoutError:
|
||||
return self.async_abort(reason='authorize_url_timeout')
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected error generating auth url")
|
||||
return self.async_abort(reason='authorize_url_fail')
|
||||
|
||||
_LOGGER.debug('Got authorization URL %s', auth_url)
|
||||
return self.async_show_form(
|
||||
step_id='auth',
|
||||
description_placeholders={
|
||||
'app_name': APPLICATION_NAME,
|
||||
'auth_url': auth_url,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_discovery(self, user_input):
|
||||
"""Run when a Tellstick is discovered."""
|
||||
from tellduslive import supports_local_api
|
||||
_LOGGER.info('Discovered tellstick device: %s', user_input)
|
||||
# Ignore any known devices
|
||||
for entry in self._async_current_entries():
|
||||
if entry.data[KEY_HOST] == user_input[0]:
|
||||
return self.async_abort(reason='already_configured')
|
||||
|
||||
if not supports_local_api(user_input[1]):
|
||||
_LOGGER.debug('Tellstick does not support local API')
|
||||
# Configure the cloud service
|
||||
return await self.async_step_auth()
|
||||
|
||||
self._hosts.append(user_input[0])
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Import a config entry."""
|
||||
if self.hass.config_entries.async_entries(DOMAIN):
|
||||
return self.async_abort(reason='already_setup')
|
||||
|
||||
self._scan_interval = user_input[KEY_SCAN_INTERVAL]
|
||||
if user_input[KEY_HOST] != DOMAIN:
|
||||
self._hosts.append(user_input[KEY_HOST])
|
||||
|
||||
if not await self.hass.async_add_executor_job(
|
||||
os.path.isfile, self.hass.config.path(TELLDUS_CONFIG_FILE)):
|
||||
return await self.async_step_user()
|
||||
|
||||
conf = await self.hass.async_add_executor_job(
|
||||
load_json, self.hass.config.path(TELLDUS_CONFIG_FILE))
|
||||
host = next(iter(conf))
|
||||
|
||||
if user_input[KEY_HOST] != host:
|
||||
return await self.async_step_user()
|
||||
|
||||
host = CLOUD_NAME if host == 'tellduslive' else host
|
||||
return self.async_create_entry(
|
||||
title=host,
|
||||
data={
|
||||
KEY_HOST: host,
|
||||
KEY_SCAN_INTERVAL: self._scan_interval.seconds,
|
||||
KEY_SESSION: next(iter(conf.values())),
|
||||
})
|
@ -1,5 +1,34 @@
|
||||
"""Consts used by TelldusLive."""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import ( # noqa pylint: disable=unused-import
|
||||
ATTR_BATTERY_LEVEL, CONF_HOST, CONF_TOKEN, DEVICE_DEFAULT_NAME)
|
||||
|
||||
APPLICATION_NAME = 'Home Assistant'
|
||||
|
||||
DOMAIN = 'tellduslive'
|
||||
|
||||
TELLDUS_CONFIG_FILE = 'tellduslive.conf'
|
||||
KEY_CONFIG = 'tellduslive_config'
|
||||
|
||||
SIGNAL_UPDATE_ENTITY = 'tellduslive_update'
|
||||
|
||||
KEY_HOST = 'host'
|
||||
KEY_SESSION = 'session'
|
||||
KEY_SCAN_INTERVAL = 'scan_interval'
|
||||
|
||||
CONF_TOKEN_SECRET = 'token_secret'
|
||||
CONF_UPDATE_INTERVAL = 'update_interval'
|
||||
|
||||
PUBLIC_KEY = 'THUPUNECH5YEQA3RE6UYUPRUZ2DUGUGA'
|
||||
NOT_SO_PRIVATE_KEY = 'PHES7U2RADREWAFEBUSTUBAWRASWUTUS'
|
||||
|
||||
MIN_UPDATE_INTERVAL = timedelta(seconds=5)
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
ATTR_LAST_UPDATED = 'time_last_updated'
|
||||
|
||||
SIGNAL_UPDATE_ENTITY = 'tellduslive_update'
|
||||
TELLDUS_DISCOVERY_NEW = 'telldus_new_{}_{}'
|
||||
|
||||
CLOUD_NAME = 'Cloud API'
|
||||
|
24
homeassistant/components/tellduslive/strings.json
Normal file
24
homeassistant/components/tellduslive/strings.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Telldus Live",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Pick endpoint.",
|
||||
"description": "",
|
||||
"data": {
|
||||
"host": "Host"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authenticate against TelldusLive",
|
||||
"description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"authorize_url_timeout": "Timeout generating authorize url.",
|
||||
"authorize_url_fail": "Unknown error generating an authorize url.",
|
||||
"all_configured": "TelldusLive is already configured",
|
||||
"unknown": "Unknown error occurred"
|
||||
}
|
||||
}
|
||||
}
|
@ -155,6 +155,7 @@ FLOWS = [
|
||||
'simplisafe',
|
||||
'smhi',
|
||||
'sonos',
|
||||
'tellduslive',
|
||||
'tradfri',
|
||||
'twilio',
|
||||
'unifi',
|
||||
|
1
tests/components/tellduslive/__init__.py
Normal file
1
tests/components/tellduslive/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the TelldusLive component."""
|
223
tests/components/tellduslive/test_config_flow.py
Normal file
223
tests/components/tellduslive/test_config_flow.py
Normal file
@ -0,0 +1,223 @@
|
||||
# flake8: noqa pylint: skip-file
|
||||
"""Tests for the TelldusLive config flow."""
|
||||
import asyncio
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.tellduslive import (
|
||||
APPLICATION_NAME, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL, SCAN_INTERVAL,
|
||||
config_flow)
|
||||
|
||||
from tests.common import MockConfigEntry, MockDependency, mock_coro
|
||||
|
||||
|
||||
def init_config_flow(hass, side_effect=None):
|
||||
"""Init a configuration flow."""
|
||||
flow = config_flow.FlowHandler()
|
||||
flow.hass = hass
|
||||
if side_effect:
|
||||
flow._get_auth_url = Mock(side_effect=side_effect)
|
||||
return flow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def supports_local_api():
|
||||
"""Set TelldusLive supports_local_api."""
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authorize():
|
||||
"""Set TelldusLive authorize."""
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tellduslive(supports_local_api, authorize):
|
||||
"""Mock tellduslive."""
|
||||
with MockDependency('tellduslive') as mock_tellduslive_:
|
||||
mock_tellduslive_.supports_local_api.return_value = supports_local_api
|
||||
mock_tellduslive_.Session().authorize.return_value = authorize
|
||||
mock_tellduslive_.Session().access_token = 'token'
|
||||
mock_tellduslive_.Session().access_token_secret = 'token_secret'
|
||||
mock_tellduslive_.Session().authorize_url = 'https://example.com'
|
||||
yield mock_tellduslive_
|
||||
|
||||
|
||||
async def test_abort_if_already_setup(hass):
|
||||
"""Test we abort if TelldusLive is already setup."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
|
||||
result = await flow.async_step_user()
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result['reason'] == 'already_setup'
|
||||
|
||||
with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
|
||||
result = await flow.async_step_import(None)
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result['reason'] == 'already_setup'
|
||||
|
||||
|
||||
async def test_full_flow_implementation(hass, mock_tellduslive):
|
||||
"""Test registering an implementation and finishing flow works."""
|
||||
flow = init_config_flow(hass)
|
||||
result = await flow.async_step_discovery(['localhost', 'tellstick'])
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'user'
|
||||
|
||||
result = await flow.async_step_user()
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'user'
|
||||
|
||||
result = await flow.async_step_user({'host': 'localhost'})
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'auth'
|
||||
assert result['description_placeholders'] == {
|
||||
'auth_url': 'https://example.com',
|
||||
'app_name': APPLICATION_NAME,
|
||||
}
|
||||
|
||||
result = await flow.async_step_auth('')
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result['title'] == 'localhost'
|
||||
assert result['data']['host'] == 'localhost'
|
||||
assert result['data']['scan_interval'] == 60
|
||||
assert result['data']['session'] == {'token': 'token', 'host': 'localhost'}
|
||||
|
||||
|
||||
async def test_step_import(hass, mock_tellduslive):
|
||||
"""Test that we trigger auth when configuring from import."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
result = await flow.async_step_import({
|
||||
KEY_HOST: DOMAIN,
|
||||
KEY_SCAN_INTERVAL: 0,
|
||||
})
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'auth'
|
||||
|
||||
|
||||
async def test_step_import_add_host(hass, mock_tellduslive):
|
||||
"""Test that we add host and trigger user when configuring from import."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
result = await flow.async_step_import({
|
||||
KEY_HOST: 'localhost',
|
||||
KEY_SCAN_INTERVAL: 0,
|
||||
})
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'user'
|
||||
|
||||
|
||||
async def test_step_import_no_config_file(hass, mock_tellduslive):
|
||||
"""Test that we trigger user with no config_file configuring from import."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
result = await flow.async_step_import({ KEY_HOST: 'localhost', KEY_SCAN_INTERVAL: 0, })
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'user'
|
||||
|
||||
|
||||
async def test_step_import_load_json_matching_host(hass, mock_tellduslive):
|
||||
"""Test that we add host and trigger user when configuring from import."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
with patch('homeassistant.components.tellduslive.config_flow.load_json',
|
||||
return_value={'tellduslive': {}}), \
|
||||
patch('os.path.isfile'):
|
||||
result = await flow.async_step_import({ KEY_HOST: 'Cloud API', KEY_SCAN_INTERVAL: 0, })
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'user'
|
||||
|
||||
|
||||
async def test_step_import_load_json(hass, mock_tellduslive):
|
||||
"""Test that we create entry when configuring from import."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
with patch('homeassistant.components.tellduslive.config_flow.load_json',
|
||||
return_value={'localhost': {}}), \
|
||||
patch('os.path.isfile'):
|
||||
result = await flow.async_step_import({ KEY_HOST: 'localhost', KEY_SCAN_INTERVAL: SCAN_INTERVAL, })
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result['title'] == 'localhost'
|
||||
assert result['data']['host'] == 'localhost'
|
||||
assert result['data']['scan_interval'] == 60
|
||||
assert result['data']['session'] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('supports_local_api', [False])
|
||||
async def test_step_disco_no_local_api(hass, mock_tellduslive):
|
||||
"""Test that we trigger when configuring from discovery, not supporting local api."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
result = await flow.async_step_discovery(['localhost', 'tellstick'])
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'auth'
|
||||
|
||||
|
||||
async def test_step_auth(hass, mock_tellduslive):
|
||||
"""Test that create cloud entity from auth."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
result = await flow.async_step_auth(['localhost', 'tellstick'])
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result['title'] == 'Cloud API'
|
||||
assert result['data']['host'] == 'Cloud API'
|
||||
assert result['data']['scan_interval'] == 60
|
||||
assert result['data']['session'] == {
|
||||
'token': 'token',
|
||||
'token_secret': 'token_secret',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('authorize', [False])
|
||||
async def test_wrong_auth_flow_implementation(hass, mock_tellduslive):
|
||||
"""Test wrong auth."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
await flow.async_step_user()
|
||||
result = await flow.async_step_auth('')
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'auth'
|
||||
|
||||
|
||||
async def test_not_pick_host_if_only_one(hass, mock_tellduslive):
|
||||
"""Test not picking host if we have just one."""
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
result = await flow.async_step_user()
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result['step_id'] == 'auth'
|
||||
|
||||
|
||||
async def test_abort_if_timeout_generating_auth_url(hass, mock_tellduslive):
|
||||
"""Test abort if generating authorize url timeout."""
|
||||
flow = init_config_flow(hass, side_effect=asyncio.TimeoutError)
|
||||
|
||||
result = await flow.async_step_user()
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result['reason'] == 'authorize_url_timeout'
|
||||
|
||||
|
||||
async def test_abort_if_exception_generating_auth_url(hass, mock_tellduslive):
|
||||
"""Test we abort if generating authorize url blows up."""
|
||||
flow = init_config_flow(hass, side_effect=ValueError)
|
||||
|
||||
result = await flow.async_step_user()
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result['reason'] == 'authorize_url_fail'
|
||||
|
||||
async def test_discovery_already_configured(hass, mock_tellduslive):
|
||||
"""Test abort if alredy configured fires from discovery."""
|
||||
MockConfigEntry(
|
||||
domain='tellduslive',
|
||||
data={'host': 'some-host'}
|
||||
).add_to_hass(hass)
|
||||
flow = init_config_flow(hass)
|
||||
|
||||
result = await flow.async_step_discovery(['some-host', ''])
|
||||
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result['reason'] == 'already_configured'
|
Loading…
x
Reference in New Issue
Block a user