Add config flow and 2FA support for Blink (#35396)

This commit is contained in:
Kevin Fronczak 2020-05-13 09:50:29 -04:00 committed by GitHub
parent 0a94d9b284
commit 85726b67b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 378 additions and 155 deletions

View File

@ -1,81 +1,40 @@
"""Support for Blink Home Camera System."""
from datetime import timedelta
import asyncio
import logging
from blinkpy import blinkpy
from blinkpy.blinkpy import Blink
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_BINARY_SENSORS,
CONF_FILENAME,
CONF_MODE,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
CONF_OFFSET,
CONF_PASSWORD,
CONF_PIN,
CONF_SCAN_INTERVAL,
CONF_SENSORS,
CONF_USERNAME,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers import config_validation as cv
from .const import (
DEFAULT_OFFSET,
DEFAULT_SCAN_INTERVAL,
DEVICE_ID,
DOMAIN,
PLATFORMS,
SERVICE_REFRESH,
SERVICE_SAVE_VIDEO,
SERVICE_SEND_PIN,
SERVICE_TRIGGER,
)
_LOGGER = logging.getLogger(__name__)
DOMAIN = "blink"
BLINK_DATA = "blink"
CONF_CAMERA = "camera"
CONF_ALARM_CONTROL_PANEL = "alarm_control_panel"
DEFAULT_BRAND = "Blink"
DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com"
SIGNAL_UPDATE_BLINK = "blink_update"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=300)
TYPE_CAMERA_ARMED = "motion_enabled"
TYPE_MOTION_DETECTED = "motion_detected"
TYPE_TEMPERATURE = "temperature"
TYPE_BATTERY = "battery"
TYPE_WIFI_STRENGTH = "wifi_strength"
SERVICE_REFRESH = "blink_update"
SERVICE_TRIGGER = "trigger_camera"
SERVICE_SAVE_VIDEO = "save_video"
BINARY_SENSORS = {
TYPE_CAMERA_ARMED: ["Camera Armed", "mdi:verified"],
TYPE_MOTION_DETECTED: ["Motion Detected", "mdi:run-fast"],
}
SENSORS = {
TYPE_TEMPERATURE: ["Temperature", TEMP_FAHRENHEIT, "mdi:thermometer"],
TYPE_BATTERY: ["Battery", "", "mdi:battery-80"],
TYPE_WIFI_STRENGTH: ["Wifi Signal", "dBm", "mdi:wifi-strength-2"],
}
BINARY_SENSOR_SCHEMA = vol.Schema(
{
vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): vol.All(
cv.ensure_list, [vol.In(BINARY_SENSORS)]
)
}
)
SENSOR_SCHEMA = vol.Schema(
{
vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All(
cv.ensure_list, [vol.In(SENSORS)]
)
}
)
SERVICE_TRIGGER_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema(
{vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string}
)
SERVICE_SEND_PIN_SCHEMA = vol.Schema({vol.Optional(CONF_PIN): cv.string})
CONFIG_SCHEMA = vol.Schema(
{
@ -83,13 +42,7 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(
CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
): cv.time_period,
vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA,
vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
vol.Optional(CONF_OFFSET, default=1): int,
vol.Optional(CONF_MODE, default=""): cv.string,
vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int,
}
)
},
@ -97,61 +50,127 @@ CONFIG_SCHEMA = vol.Schema(
)
def setup(hass, config):
"""Set up Blink System."""
conf = config[BLINK_DATA]
username = conf[CONF_USERNAME]
password = conf[CONF_PASSWORD]
scan_interval = conf[CONF_SCAN_INTERVAL]
is_legacy = bool(conf[CONF_MODE] == "legacy")
motion_interval = conf[CONF_OFFSET]
hass.data[BLINK_DATA] = blinkpy.Blink(
username=username,
password=password,
motion_interval=motion_interval,
legacy_subdomain=is_legacy,
def _blink_startup_wrapper(entry):
"""Startup wrapper for blink."""
blink = Blink(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
motion_interval=DEFAULT_OFFSET,
legacy_subdomain=False,
no_prompt=True,
device_id=DEVICE_ID,
)
hass.data[BLINK_DATA].refresh_rate = scan_interval.total_seconds()
hass.data[BLINK_DATA].start()
blink.refresh_rate = entry.data[CONF_SCAN_INTERVAL]
platforms = [
("alarm_control_panel", {}),
("binary_sensor", conf[CONF_BINARY_SENSORS]),
("camera", {}),
("sensor", conf[CONF_SENSORS]),
]
try:
blink.login_response = entry.data["login_response"]
blink.setup_params(entry.data["login_response"])
except KeyError:
blink.get_auth_token()
for component, schema in platforms:
discovery.load_platform(hass, component, DOMAIN, schema, config)
blink.setup_params(entry.data["login_response"])
blink.setup_post_verify()
return blink
def trigger_camera(call):
"""Trigger a camera."""
cameras = hass.data[BLINK_DATA].cameras
name = call.data[CONF_NAME]
if name in cameras:
cameras[name].snap_picture()
hass.data[BLINK_DATA].refresh(force_cache=True)
def blink_refresh(event_time):
"""Call blink to refresh info."""
hass.data[BLINK_DATA].refresh(force_cache=True)
async def async_setup(hass, config):
"""Set up a config entry."""
hass.data[DOMAIN] = {}
if DOMAIN not in config:
return True
async def async_save_video(call):
"""Call save video service handler."""
await async_handle_save_video_service(hass, call)
conf = config.get(DOMAIN, {})
if not hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
hass.services.register(DOMAIN, SERVICE_REFRESH, blink_refresh)
hass.services.register(
DOMAIN, SERVICE_TRIGGER, trigger_camera, schema=SERVICE_TRIGGER_SCHEMA
)
hass.services.register(
DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA
)
return True
async def async_handle_save_video_service(hass, call):
async def async_setup_entry(hass, entry):
"""Set up Blink via config entry."""
hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job(
_blink_startup_wrapper, entry
)
if not hass.data[DOMAIN][entry.entry_id].available:
_LOGGER.error("Blink unavailable for setup")
return False
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
def trigger_camera(call):
"""Trigger a camera."""
cameras = hass.data[DOMAIN][entry.entry_id].cameras
name = call.data[CONF_NAME]
if name in cameras:
cameras[name].snap_picture()
blink_refresh()
def blink_refresh(event_time=None):
"""Call blink to refresh info."""
hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True)
async def async_save_video(call):
"""Call save video service handler."""
await async_handle_save_video_service(hass, entry, call)
def send_pin(call):
"""Call blink to send new pin."""
pin = call.data[CONF_PIN]
hass.data[DOMAIN][entry.entry_id].login_handler.send_auth_key(
hass.data[DOMAIN][entry.entry_id], pin,
)
hass.services.async_register(DOMAIN, SERVICE_REFRESH, blink_refresh)
hass.services.async_register(
DOMAIN, SERVICE_TRIGGER, trigger_camera, schema=SERVICE_TRIGGER_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_SEND_PIN, send_pin, schema=SERVICE_SEND_PIN_SCHEMA
)
return True
async def async_unload_entry(hass, entry):
"""Unload Blink entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if not unload_ok:
return False
hass.data[DOMAIN].pop(entry.entry_id)
if len(hass.data[DOMAIN]) != 0:
return True
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
hass.services.async_remove(DOMAIN, SERVICE_TRIGGER)
hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO_SCHEMA)
hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN)
return True
async def async_handle_save_video_service(hass, entry, call):
"""Handle save video service calls."""
camera_name = call.data[CONF_NAME]
video_path = call.data[CONF_FILENAME]
@ -161,7 +180,7 @@ async def async_handle_save_video_service(hass, call):
def _write_video(camera_name, video_path):
"""Call video write."""
all_cameras = hass.data[BLINK_DATA].cameras
all_cameras = hass.data[DOMAIN][entry.entry_id].cameras
if camera_name in all_cameras:
all_cameras[camera_name].video_to_file(video_path)

View File

@ -9,23 +9,21 @@ from homeassistant.const import (
STATE_ALARM_DISARMED,
)
from . import BLINK_DATA, DEFAULT_ATTRIBUTION
from .const import DEFAULT_ATTRIBUTION, DOMAIN
_LOGGER = logging.getLogger(__name__)
ICON = "mdi:security"
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Arlo Alarm Control Panels."""
if discovery_info is None:
return
data = hass.data[BLINK_DATA]
async def async_setup_entry(hass, config, async_add_entities):
"""Set up the Blink Alarm Control Panels."""
data = hass.data[DOMAIN][config.entry_id]
sync_modules = []
for sync_name, sync_module in data.sync.items():
sync_modules.append(BlinkSyncModule(data, sync_name, sync_module))
add_entities(sync_modules, True)
async_add_entities(sync_modules)
class BlinkSyncModule(AlarmControlPanelEntity):
@ -61,7 +59,7 @@ class BlinkSyncModule(AlarmControlPanelEntity):
@property
def name(self):
"""Return the name of the panel."""
return f"{BLINK_DATA} {self._name}"
return f"{DOMAIN} {self._name}"
@property
def device_state_attributes(self):

View File

@ -1,21 +1,23 @@
"""Support for Blink system camera control."""
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_MONITORED_CONDITIONS
from . import BINARY_SENSORS, BLINK_DATA
from .const import DOMAIN, TYPE_CAMERA_ARMED, TYPE_MOTION_DETECTED
BINARY_SENSORS = {
TYPE_CAMERA_ARMED: ["Camera Armed", "mdi:verified"],
TYPE_MOTION_DETECTED: ["Motion Detected", "mdi:run-fast"],
}
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config, async_add_entities):
"""Set up the blink binary sensors."""
if discovery_info is None:
return
data = hass.data[BLINK_DATA]
data = hass.data[DOMAIN][config.entry_id]
devs = []
entities = []
for camera in data.cameras:
for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
devs.append(BlinkBinarySensor(data, camera, sensor_type))
add_entities(devs, True)
for sensor_type in BINARY_SENSORS:
entities.append(BlinkBinarySensor(data, camera, sensor_type))
async_add_entities(entities)
class BlinkBinarySensor(BinarySensorEntity):
@ -26,7 +28,7 @@ class BlinkBinarySensor(BinarySensorEntity):
self.data = data
self._type = sensor_type
name, icon = BINARY_SENSORS[sensor_type]
self._name = f"{BLINK_DATA} {camera} {name}"
self._name = f"{DOMAIN} {camera} {name}"
self._icon = icon
self._camera = data.cameras[camera]
self._state = None

View File

@ -3,7 +3,7 @@ import logging
from homeassistant.components.camera import Camera
from . import BLINK_DATA, DEFAULT_BRAND
from .const import DEFAULT_BRAND, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -11,16 +11,14 @@ ATTR_VIDEO_CLIP = "video"
ATTR_IMAGE = "image"
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config, async_add_entities):
"""Set up a Blink Camera."""
if discovery_info is None:
return
data = hass.data[BLINK_DATA]
devs = []
data = hass.data[DOMAIN][config.entry_id]
entities = []
for name, camera in data.cameras.items():
devs.append(BlinkCamera(data, name, camera))
entities.append(BlinkCamera(data, name, camera))
add_entities(devs)
async_add_entities(entities)
class BlinkCamera(Camera):
@ -30,7 +28,7 @@ class BlinkCamera(Camera):
"""Initialize a camera."""
super().__init__()
self.data = data
self._name = f"{BLINK_DATA} {name}"
self._name = f"{DOMAIN} {name}"
self._camera = camera
self._unique_id = f"{camera.serial}-camera"
self.response = None

View File

@ -0,0 +1,115 @@
"""Config flow to configure Blink."""
import logging
from blinkpy.blinkpy import Blink
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import (
CONF_PASSWORD,
CONF_PIN,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
)
from .const import DEFAULT_OFFSET, DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: core.HomeAssistant, blink):
"""Validate the user input allows us to connect."""
response = await hass.async_add_executor_job(blink.get_auth_token)
if not response:
raise InvalidAuth
if blink.key_required:
raise Require2FA
return blink.login_response
class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Blink config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize the blink flow."""
self.blink = None
self.data = {
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
"login_response": None,
}
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is not None:
self.data[CONF_USERNAME] = user_input["username"]
self.data[CONF_PASSWORD] = user_input["password"]
await self.async_set_unique_id(self.data[CONF_USERNAME])
if CONF_SCAN_INTERVAL in user_input:
self.data[CONF_SCAN_INTERVAL] = user_input["scan_interval"]
self.blink = Blink(
username=self.data[CONF_USERNAME],
password=self.data[CONF_PASSWORD],
motion_interval=DEFAULT_OFFSET,
legacy_subdomain=False,
no_prompt=True,
device_id=DEVICE_ID,
)
try:
response = await validate_input(self.hass, self.blink)
self.data["login_response"] = response
return self.async_create_entry(title=DOMAIN, data=self.data,)
except Require2FA:
return await self.async_step_2fa()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
data_schema = {
vol.Required("username"): str,
vol.Required("password"): str,
}
return self.async_show_form(
step_id="user", data_schema=vol.Schema(data_schema), errors=errors,
)
async def async_step_2fa(self, user_input=None):
"""Handle 2FA step."""
if user_input is not None:
pin = user_input.get(CONF_PIN)
if await self.hass.async_add_executor_job(
self.blink.login_handler.send_auth_key, self.blink, pin
):
return await self.async_step_user(user_input=self.data)
return self.async_show_form(
step_id="2fa",
data_schema=vol.Schema(
{vol.Optional("pin"): vol.All(str, vol.Length(min=1))}
),
)
async def async_step_import(self, import_data):
"""Import blink config from configuration.yaml."""
return await self.async_step_user(import_data)
class Require2FA(exceptions.HomeAssistantError):
"""Error to indicate we require 2FA."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -0,0 +1,25 @@
"""Constants for Blink."""
DOMAIN = "blink"
DEVICE_ID = "Home Assistant"
CONF_CAMERA = "camera"
CONF_ALARM_CONTROL_PANEL = "alarm_control_panel"
DEFAULT_BRAND = "Blink"
DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com"
DEFAULT_SCAN_INTERVAL = 300
DEFAULT_OFFSET = 1
SIGNAL_UPDATE_BLINK = "blink_update"
TYPE_CAMERA_ARMED = "motion_enabled"
TYPE_MOTION_DETECTED = "motion_detected"
TYPE_TEMPERATURE = "temperature"
TYPE_BATTERY = "battery"
TYPE_WIFI_STRENGTH = "wifi_strength"
SERVICE_REFRESH = "blink_update"
SERVICE_TRIGGER = "trigger_camera"
SERVICE_SAVE_VIDEO = "save_video"
SERVICE_SEND_PIN = "send_pin"
PLATFORMS = ("alarm_control_panel", "binary_sensor", "camera", "sensor")

View File

@ -2,6 +2,7 @@
"domain": "blink",
"name": "Blink",
"documentation": "https://www.home-assistant.io/integrations/blink",
"requirements": ["blinkpy==0.14.3"],
"codeowners": ["@fronzbot"]
"requirements": ["blinkpy==0.15.0"],
"codeowners": ["@fronzbot"],
"config_flow": true
}

View File

@ -1,25 +1,29 @@
"""Support for Blink system camera sensors."""
import logging
from homeassistant.const import CONF_MONITORED_CONDITIONS
from homeassistant.const import TEMP_FAHRENHEIT
from homeassistant.helpers.entity import Entity
from . import BLINK_DATA, SENSORS
from .const import DOMAIN, TYPE_BATTERY, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH
_LOGGER = logging.getLogger(__name__)
SENSORS = {
TYPE_TEMPERATURE: ["Temperature", TEMP_FAHRENHEIT, "mdi:thermometer"],
TYPE_BATTERY: ["Battery", "", "mdi:battery-80"],
TYPE_WIFI_STRENGTH: ["Wifi Signal", "dBm", "mdi:wifi-strength-2"],
}
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up a Blink sensor."""
if discovery_info is None:
return
data = hass.data[BLINK_DATA]
devs = []
async def async_setup_entry(hass, config, async_add_entities):
"""Initialize a Blink sensor."""
data = hass.data[DOMAIN][config.entry_id]
entities = []
for camera in data.cameras:
for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
devs.append(BlinkSensor(data, camera, sensor_type))
for sensor_type in SENSORS:
entities.append(BlinkSensor(data, camera, sensor_type))
add_entities(devs, True)
async_add_entities(entities)
class BlinkSensor(Entity):
@ -28,7 +32,7 @@ class BlinkSensor(Entity):
def __init__(self, data, camera, sensor_type):
"""Initialize sensors from Blink camera."""
name, units, icon = SENSORS[sensor_type]
self._name = f"{BLINK_DATA} {camera} {name}"
self._name = f"{DOMAIN} {camera} {name}"
self._camera_name = name
self._type = sensor_type
self.data = data

View File

@ -19,3 +19,10 @@ save_video:
filename:
description: Filename to writable path (directory may need to be included in whitelist_dirs in config)
example: "/tmp/video.mp4"
send_pin:
description: Send a new pin to blink for 2FA.
fields:
pin:
description: Pin received from blink. Leave empty if you only received a verification email.
example: "abc123"

View File

@ -0,0 +1,25 @@
{
"config": {
"step": {
"user": {
"title": "Sign-in with Blink account",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"2fa": {
"title": "Two-factor authentication",
"data": { "2fa": "Two-factor code" },
"description": "Enter the pin sent to your email. If the email does not contain a pin, leave blank"
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -0,0 +1,28 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"2fa": {
"data": {
"2fa": "Two-factor code"
},
"title": "Two-factor authentication",
"description": "Enter the pin sent to your email. If the email does not contain a pin, leave blank"
},
"user": {
"data": {
"password": "Password",
"username": "Username",
"scan_interval": "Scan Interval"
},
"title": "Sign-in with Blink account"
}
}
}
}

View File

@ -18,6 +18,7 @@ FLOWS = [
"august",
"axis",
"blebox",
"blink",
"braviatv",
"brother",
"bsblan",

View File

@ -345,7 +345,7 @@ bizkaibus==0.1.1
blebox_uniapi==1.3.2
# homeassistant.components.blink
blinkpy==0.14.3
blinkpy==0.15.0
# homeassistant.components.blinksticklight
blinkstick==1.1.8