mirror of
https://github.com/home-assistant/core.git
synced 2025-08-16 02:51:40 +02:00
@@ -27,6 +27,7 @@ omit =
|
|||||||
homeassistant/components/ambient_station/*
|
homeassistant/components/ambient_station/*
|
||||||
homeassistant/components/amcrest/*
|
homeassistant/components/amcrest/*
|
||||||
homeassistant/components/android_ip_webcam/*
|
homeassistant/components/android_ip_webcam/*
|
||||||
|
homeassistant/components/androidtv/*
|
||||||
homeassistant/components/apcupsd/*
|
homeassistant/components/apcupsd/*
|
||||||
homeassistant/components/apiai/*
|
homeassistant/components/apiai/*
|
||||||
homeassistant/components/apple_tv/*
|
homeassistant/components/apple_tv/*
|
||||||
@@ -68,6 +69,7 @@ omit =
|
|||||||
homeassistant/components/camera/xiaomi.py
|
homeassistant/components/camera/xiaomi.py
|
||||||
homeassistant/components/camera/yi.py
|
homeassistant/components/camera/yi.py
|
||||||
homeassistant/components/cast/*
|
homeassistant/components/cast/*
|
||||||
|
homeassistant/components/cisco_mobility_express/device_tracker.py
|
||||||
homeassistant/components/climate/coolmaster.py
|
homeassistant/components/climate/coolmaster.py
|
||||||
homeassistant/components/climate/ephember.py
|
homeassistant/components/climate/ephember.py
|
||||||
homeassistant/components/climate/eq3btsmart.py
|
homeassistant/components/climate/eq3btsmart.py
|
||||||
@@ -109,6 +111,7 @@ omit =
|
|||||||
homeassistant/components/device_tracker/bt_home_hub_5.py
|
homeassistant/components/device_tracker/bt_home_hub_5.py
|
||||||
homeassistant/components/device_tracker/bt_smarthub.py
|
homeassistant/components/device_tracker/bt_smarthub.py
|
||||||
homeassistant/components/device_tracker/cisco_ios.py
|
homeassistant/components/device_tracker/cisco_ios.py
|
||||||
|
homeassistant/components/cppm_tracker/device_tracker.py
|
||||||
homeassistant/components/device_tracker/ddwrt.py
|
homeassistant/components/device_tracker/ddwrt.py
|
||||||
homeassistant/components/device_tracker/fritz.py
|
homeassistant/components/device_tracker/fritz.py
|
||||||
homeassistant/components/device_tracker/google_maps.py
|
homeassistant/components/device_tracker/google_maps.py
|
||||||
@@ -138,6 +141,7 @@ omit =
|
|||||||
homeassistant/components/device_tracker/trackr.py
|
homeassistant/components/device_tracker/trackr.py
|
||||||
homeassistant/components/device_tracker/ubee.py
|
homeassistant/components/device_tracker/ubee.py
|
||||||
homeassistant/components/device_tracker/ubus.py
|
homeassistant/components/device_tracker/ubus.py
|
||||||
|
homeassistant/components/device_tracker/xfinity.py
|
||||||
homeassistant/components/digital_ocean/*
|
homeassistant/components/digital_ocean/*
|
||||||
homeassistant/components/dominos/*
|
homeassistant/components/dominos/*
|
||||||
homeassistant/components/doorbird/*
|
homeassistant/components/doorbird/*
|
||||||
@@ -154,6 +158,7 @@ omit =
|
|||||||
homeassistant/components/elkm1/*
|
homeassistant/components/elkm1/*
|
||||||
homeassistant/components/emoncms_history/*
|
homeassistant/components/emoncms_history/*
|
||||||
homeassistant/components/emulated_hue/upnp.py
|
homeassistant/components/emulated_hue/upnp.py
|
||||||
|
homeassistant/components/enigma2/media_player.py
|
||||||
homeassistant/components/enocean/*
|
homeassistant/components/enocean/*
|
||||||
homeassistant/components/envisalink/*
|
homeassistant/components/envisalink/*
|
||||||
homeassistant/components/esphome/__init__.py
|
homeassistant/components/esphome/__init__.py
|
||||||
@@ -233,7 +238,7 @@ omit =
|
|||||||
homeassistant/components/light/limitlessled.py
|
homeassistant/components/light/limitlessled.py
|
||||||
homeassistant/components/light/lw12wifi.py
|
homeassistant/components/light/lw12wifi.py
|
||||||
homeassistant/components/light/mystrom.py
|
homeassistant/components/light/mystrom.py
|
||||||
homeassistant/components/light/nanoleaf_aurora.py
|
homeassistant/components/light/nanoleaf.py
|
||||||
homeassistant/components/light/niko_home_control.py
|
homeassistant/components/light/niko_home_control.py
|
||||||
homeassistant/components/light/opple.py
|
homeassistant/components/light/opple.py
|
||||||
homeassistant/components/light/osramlightify.py
|
homeassistant/components/light/osramlightify.py
|
||||||
@@ -280,7 +285,6 @@ omit =
|
|||||||
homeassistant/components/media_player/dunehd.py
|
homeassistant/components/media_player/dunehd.py
|
||||||
homeassistant/components/media_player/emby.py
|
homeassistant/components/media_player/emby.py
|
||||||
homeassistant/components/media_player/epson.py
|
homeassistant/components/media_player/epson.py
|
||||||
homeassistant/components/media_player/firetv.py
|
|
||||||
homeassistant/components/media_player/frontier_silicon.py
|
homeassistant/components/media_player/frontier_silicon.py
|
||||||
homeassistant/components/media_player/gpmdp.py
|
homeassistant/components/media_player/gpmdp.py
|
||||||
homeassistant/components/media_player/gstreamer.py
|
homeassistant/components/media_player/gstreamer.py
|
||||||
@@ -632,6 +636,7 @@ omit =
|
|||||||
homeassistant/components/thingspeak/*
|
homeassistant/components/thingspeak/*
|
||||||
homeassistant/components/thinkingcleaner/*
|
homeassistant/components/thinkingcleaner/*
|
||||||
homeassistant/components/tibber/*
|
homeassistant/components/tibber/*
|
||||||
|
homeassistant/components/tof/sensor.py
|
||||||
homeassistant/components/toon/*
|
homeassistant/components/toon/*
|
||||||
homeassistant/components/tplink_lte/*
|
homeassistant/components/tplink_lte/*
|
||||||
homeassistant/components/tradfri/*
|
homeassistant/components/tradfri/*
|
||||||
|
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,3 +1,7 @@
|
|||||||
|
## Breaking Change:
|
||||||
|
|
||||||
|
<!-- What is breaking and why we have to break it. Remove this section only if it was NOT a breaking change. -->
|
||||||
|
|
||||||
## Description:
|
## Description:
|
||||||
|
|
||||||
|
|
||||||
|
13
.travis.yml
13
.travis.yml
@@ -1,8 +1,18 @@
|
|||||||
sudo: false
|
sudo: false
|
||||||
|
dist: xenial
|
||||||
addons:
|
addons:
|
||||||
apt:
|
apt:
|
||||||
|
sources:
|
||||||
|
- sourceline: "ppa:jonathonf/ffmpeg-4"
|
||||||
packages:
|
packages:
|
||||||
- libudev-dev
|
- libudev-dev
|
||||||
|
- libavformat-dev
|
||||||
|
- libavcodec-dev
|
||||||
|
- libavdevice-dev
|
||||||
|
- libavutil-dev
|
||||||
|
- libswscale-dev
|
||||||
|
- libswresample-dev
|
||||||
|
- libavfilter-dev
|
||||||
matrix:
|
matrix:
|
||||||
fast_finish: true
|
fast_finish: true
|
||||||
include:
|
include:
|
||||||
@@ -19,15 +29,12 @@ matrix:
|
|||||||
env: TOXENV=py36
|
env: TOXENV=py36
|
||||||
- python: "3.7"
|
- python: "3.7"
|
||||||
env: TOXENV=py37
|
env: TOXENV=py37
|
||||||
dist: xenial
|
|
||||||
- python: "3.8-dev"
|
- python: "3.8-dev"
|
||||||
env: TOXENV=py38
|
env: TOXENV=py38
|
||||||
dist: xenial
|
|
||||||
if: branch = dev AND type = push
|
if: branch = dev AND type = push
|
||||||
allow_failures:
|
allow_failures:
|
||||||
- python: "3.8-dev"
|
- python: "3.8-dev"
|
||||||
env: TOXENV=py38
|
env: TOXENV=py38
|
||||||
dist: xenial
|
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
directories:
|
directories:
|
||||||
|
@@ -51,6 +51,7 @@ homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell
|
|||||||
homeassistant/components/binary_sensor/hikvision.py @mezz64
|
homeassistant/components/binary_sensor/hikvision.py @mezz64
|
||||||
homeassistant/components/binary_sensor/threshold.py @fabaff
|
homeassistant/components/binary_sensor/threshold.py @fabaff
|
||||||
homeassistant/components/binary_sensor/uptimerobot.py @ludeeus
|
homeassistant/components/binary_sensor/uptimerobot.py @ludeeus
|
||||||
|
homeassistant/components/camera/push.py @dgomes
|
||||||
homeassistant/components/camera/yi.py @bachya
|
homeassistant/components/camera/yi.py @bachya
|
||||||
homeassistant/components/climate/coolmaster.py @OnFreund
|
homeassistant/components/climate/coolmaster.py @OnFreund
|
||||||
homeassistant/components/climate/ephember.py @ttroy50
|
homeassistant/components/climate/ephember.py @ttroy50
|
||||||
@@ -62,12 +63,13 @@ homeassistant/components/cover/group.py @cdce8p
|
|||||||
homeassistant/components/cover/template.py @PhracturedBlue
|
homeassistant/components/cover/template.py @PhracturedBlue
|
||||||
homeassistant/components/device_tracker/asuswrt.py @kennedyshead
|
homeassistant/components/device_tracker/asuswrt.py @kennedyshead
|
||||||
homeassistant/components/device_tracker/automatic.py @armills
|
homeassistant/components/device_tracker/automatic.py @armills
|
||||||
|
homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme
|
||||||
homeassistant/components/device_tracker/huawei_router.py @abmantis
|
homeassistant/components/device_tracker/huawei_router.py @abmantis
|
||||||
homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan
|
homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan
|
||||||
homeassistant/components/device_tracker/tile.py @bachya
|
homeassistant/components/device_tracker/tile.py @bachya
|
||||||
homeassistant/components/device_tracker/traccar.py @ludeeus
|
homeassistant/components/device_tracker/traccar.py @ludeeus
|
||||||
homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme
|
|
||||||
homeassistant/components/device_tracker/synology_srm.py @aerialls
|
homeassistant/components/device_tracker/synology_srm.py @aerialls
|
||||||
|
homeassistant/components/device_tracker/xfinity.py @cisasteelersfan
|
||||||
homeassistant/components/light/lifx_legacy.py @amelchio
|
homeassistant/components/light/lifx_legacy.py @amelchio
|
||||||
homeassistant/components/light/yeelight.py @rytilahti
|
homeassistant/components/light/yeelight.py @rytilahti
|
||||||
homeassistant/components/light/yeelightsunflower.py @lindsaymarkward
|
homeassistant/components/light/yeelightsunflower.py @lindsaymarkward
|
||||||
@@ -95,6 +97,7 @@ homeassistant/components/sensor/bitcoin.py @fabaff
|
|||||||
homeassistant/components/sensor/cpuspeed.py @fabaff
|
homeassistant/components/sensor/cpuspeed.py @fabaff
|
||||||
homeassistant/components/sensor/cups.py @fabaff
|
homeassistant/components/sensor/cups.py @fabaff
|
||||||
homeassistant/components/sensor/darksky.py @fabaff
|
homeassistant/components/sensor/darksky.py @fabaff
|
||||||
|
homeassistant/components/sensor/discogs.py @thibmaek
|
||||||
homeassistant/components/sensor/file.py @fabaff
|
homeassistant/components/sensor/file.py @fabaff
|
||||||
homeassistant/components/sensor/filter.py @dgomes
|
homeassistant/components/sensor/filter.py @dgomes
|
||||||
homeassistant/components/sensor/fixer.py @fabaff
|
homeassistant/components/sensor/fixer.py @fabaff
|
||||||
@@ -112,6 +115,7 @@ homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel
|
|||||||
homeassistant/components/sensor/min_max.py @fabaff
|
homeassistant/components/sensor/min_max.py @fabaff
|
||||||
homeassistant/components/sensor/moon.py @fabaff
|
homeassistant/components/sensor/moon.py @fabaff
|
||||||
homeassistant/components/sensor/netdata.py @fabaff
|
homeassistant/components/sensor/netdata.py @fabaff
|
||||||
|
homeassistant/components/sensor/nmbs.py @thibmaek
|
||||||
homeassistant/components/sensor/nsw_fuel_station.py @nickw444
|
homeassistant/components/sensor/nsw_fuel_station.py @nickw444
|
||||||
homeassistant/components/sensor/pi_hole.py @fabaff
|
homeassistant/components/sensor/pi_hole.py @fabaff
|
||||||
homeassistant/components/sensor/pollen.py @bachya
|
homeassistant/components/sensor/pollen.py @bachya
|
||||||
|
@@ -100,9 +100,21 @@ class AuthManager:
|
|||||||
"""Return a list of available auth modules."""
|
"""Return a list of available auth modules."""
|
||||||
return list(self._mfa_modules.values())
|
return list(self._mfa_modules.values())
|
||||||
|
|
||||||
|
def get_auth_provider(self, provider_type: str, provider_id: str) \
|
||||||
|
-> Optional[AuthProvider]:
|
||||||
|
"""Return an auth provider, None if not found."""
|
||||||
|
return self._providers.get((provider_type, provider_id))
|
||||||
|
|
||||||
|
def get_auth_providers(self, provider_type: str) \
|
||||||
|
-> List[AuthProvider]:
|
||||||
|
"""Return a List of auth provider of one type, Empty if not found."""
|
||||||
|
return [provider
|
||||||
|
for (p_type, _), provider in self._providers.items()
|
||||||
|
if p_type == provider_type]
|
||||||
|
|
||||||
def get_auth_mfa_module(self, module_id: str) \
|
def get_auth_mfa_module(self, module_id: str) \
|
||||||
-> Optional[MultiFactorAuthModule]:
|
-> Optional[MultiFactorAuthModule]:
|
||||||
"""Return an multi-factor auth module, None if not found."""
|
"""Return a multi-factor auth module, None if not found."""
|
||||||
return self._mfa_modules.get(module_id)
|
return self._mfa_modules.get(module_id)
|
||||||
|
|
||||||
async def async_get_users(self) -> List[models.User]:
|
async def async_get_users(self) -> List[models.User]:
|
||||||
@@ -113,6 +125,11 @@ class AuthManager:
|
|||||||
"""Retrieve a user."""
|
"""Retrieve a user."""
|
||||||
return await self._store.async_get_user(user_id)
|
return await self._store.async_get_user(user_id)
|
||||||
|
|
||||||
|
async def async_get_owner(self) -> Optional[models.User]:
|
||||||
|
"""Retrieve the owner."""
|
||||||
|
users = await self.async_get_users()
|
||||||
|
return next((user for user in users if user.is_owner), None)
|
||||||
|
|
||||||
async def async_get_group(self, group_id: str) -> Optional[models.Group]:
|
async def async_get_group(self, group_id: str) -> Optional[models.Group]:
|
||||||
"""Retrieve all groups."""
|
"""Retrieve all groups."""
|
||||||
return await self._store.async_get_group(group_id)
|
return await self._store.async_get_group(group_id)
|
||||||
|
@@ -11,13 +11,14 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
|
from .const import GROUP_ID_ADMIN, GROUP_ID_USER, GROUP_ID_READ_ONLY
|
||||||
from .permissions import PermissionLookup, system_policies
|
from .permissions import PermissionLookup, system_policies
|
||||||
from .permissions.types import PolicyType # noqa: F401
|
from .permissions.types import PolicyType # noqa: F401
|
||||||
|
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
STORAGE_KEY = 'auth'
|
STORAGE_KEY = 'auth'
|
||||||
GROUP_NAME_ADMIN = 'Administrators'
|
GROUP_NAME_ADMIN = 'Administrators'
|
||||||
|
GROUP_NAME_USER = "Users"
|
||||||
GROUP_NAME_READ_ONLY = 'Read Only'
|
GROUP_NAME_READ_ONLY = 'Read Only'
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class AuthStore:
|
|||||||
self._perm_lookup = None # type: Optional[PermissionLookup]
|
self._perm_lookup = None # type: Optional[PermissionLookup]
|
||||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY,
|
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY,
|
||||||
private=True)
|
private=True)
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def async_get_groups(self) -> List[models.Group]:
|
async def async_get_groups(self) -> List[models.Group]:
|
||||||
"""Retrieve all users."""
|
"""Retrieve all users."""
|
||||||
@@ -272,8 +274,16 @@ class AuthStore:
|
|||||||
|
|
||||||
async def _async_load(self) -> None:
|
async def _async_load(self) -> None:
|
||||||
"""Load the users."""
|
"""Load the users."""
|
||||||
[ent_reg, data] = await asyncio.gather(
|
async with self._lock:
|
||||||
|
if self._users is not None:
|
||||||
|
return
|
||||||
|
await self._async_load_task()
|
||||||
|
|
||||||
|
async def _async_load_task(self) -> None:
|
||||||
|
"""Load the users."""
|
||||||
|
[ent_reg, dev_reg, data] = await asyncio.gather(
|
||||||
self.hass.helpers.entity_registry.async_get_registry(),
|
self.hass.helpers.entity_registry.async_get_registry(),
|
||||||
|
self.hass.helpers.device_registry.async_get_registry(),
|
||||||
self._store.async_load(),
|
self._store.async_load(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,7 +292,9 @@ class AuthStore:
|
|||||||
if self._users is not None:
|
if self._users is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._perm_lookup = perm_lookup = PermissionLookup(ent_reg)
|
self._perm_lookup = perm_lookup = PermissionLookup(
|
||||||
|
ent_reg, dev_reg
|
||||||
|
)
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
self._set_defaults()
|
self._set_defaults()
|
||||||
@@ -297,6 +309,7 @@ class AuthStore:
|
|||||||
# 1. Data from a recent version which has a single group without policy
|
# 1. Data from a recent version which has a single group without policy
|
||||||
# 2. Data from old version which has no groups
|
# 2. Data from old version which has no groups
|
||||||
has_admin_group = False
|
has_admin_group = False
|
||||||
|
has_user_group = False
|
||||||
has_read_only_group = False
|
has_read_only_group = False
|
||||||
group_without_policy = None
|
group_without_policy = None
|
||||||
|
|
||||||
@@ -314,6 +327,13 @@ class AuthStore:
|
|||||||
policy = system_policies.ADMIN_POLICY
|
policy = system_policies.ADMIN_POLICY
|
||||||
system_generated = True
|
system_generated = True
|
||||||
|
|
||||||
|
elif group_dict['id'] == GROUP_ID_USER:
|
||||||
|
has_user_group = True
|
||||||
|
|
||||||
|
name = GROUP_NAME_USER
|
||||||
|
policy = system_policies.USER_POLICY
|
||||||
|
system_generated = True
|
||||||
|
|
||||||
elif group_dict['id'] == GROUP_ID_READ_ONLY:
|
elif group_dict['id'] == GROUP_ID_READ_ONLY:
|
||||||
has_read_only_group = True
|
has_read_only_group = True
|
||||||
|
|
||||||
@@ -361,6 +381,10 @@ class AuthStore:
|
|||||||
read_only_group = _system_read_only_group()
|
read_only_group = _system_read_only_group()
|
||||||
groups[read_only_group.id] = read_only_group
|
groups[read_only_group.id] = read_only_group
|
||||||
|
|
||||||
|
if not has_user_group:
|
||||||
|
user_group = _system_user_group()
|
||||||
|
groups[user_group.id] = user_group
|
||||||
|
|
||||||
for user_dict in data['users']:
|
for user_dict in data['users']:
|
||||||
# Collect the users group.
|
# Collect the users group.
|
||||||
user_groups = []
|
user_groups = []
|
||||||
@@ -475,7 +499,7 @@ class AuthStore:
|
|||||||
'name': group.name
|
'name': group.name
|
||||||
} # type: Dict[str, Any]
|
} # type: Dict[str, Any]
|
||||||
|
|
||||||
if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN):
|
if not group.system_generated:
|
||||||
g_dict['policy'] = group.policy
|
g_dict['policy'] = group.policy
|
||||||
|
|
||||||
groups.append(g_dict)
|
groups.append(g_dict)
|
||||||
@@ -528,6 +552,8 @@ class AuthStore:
|
|||||||
groups = OrderedDict() # type: Dict[str, models.Group]
|
groups = OrderedDict() # type: Dict[str, models.Group]
|
||||||
admin_group = _system_admin_group()
|
admin_group = _system_admin_group()
|
||||||
groups[admin_group.id] = admin_group
|
groups[admin_group.id] = admin_group
|
||||||
|
user_group = _system_user_group()
|
||||||
|
groups[user_group.id] = user_group
|
||||||
read_only_group = _system_read_only_group()
|
read_only_group = _system_read_only_group()
|
||||||
groups[read_only_group.id] = read_only_group
|
groups[read_only_group.id] = read_only_group
|
||||||
self._groups = groups
|
self._groups = groups
|
||||||
@@ -543,6 +569,16 @@ def _system_admin_group() -> models.Group:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _system_user_group() -> models.Group:
|
||||||
|
"""Create system user group."""
|
||||||
|
return models.Group(
|
||||||
|
name=GROUP_NAME_USER,
|
||||||
|
id=GROUP_ID_USER,
|
||||||
|
policy=system_policies.USER_POLICY,
|
||||||
|
system_generated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _system_read_only_group() -> models.Group:
|
def _system_read_only_group() -> models.Group:
|
||||||
"""Create read only group."""
|
"""Create read only group."""
|
||||||
return models.Group(
|
return models.Group(
|
||||||
|
@@ -5,4 +5,5 @@ ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
|||||||
MFA_SESSION_EXPIRATION = timedelta(minutes=5)
|
MFA_SESSION_EXPIRATION = timedelta(minutes=5)
|
||||||
|
|
||||||
GROUP_ID_ADMIN = 'system-admin'
|
GROUP_ID_ADMIN = 'system-admin'
|
||||||
|
GROUP_ID_USER = 'system-users'
|
||||||
GROUP_ID_READ_ONLY = 'system-read-only'
|
GROUP_ID_READ_ONLY = 'system-read-only'
|
||||||
|
@@ -1,12 +1,14 @@
|
|||||||
"""Entity permissions."""
|
"""Entity permissions."""
|
||||||
from functools import wraps
|
from collections import OrderedDict
|
||||||
from typing import Callable, List, Union # noqa: F401
|
from typing import Callable, Optional # noqa: F401
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT
|
from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT
|
||||||
from .models import PermissionLookup
|
from .models import PermissionLookup
|
||||||
from .types import CategoryType, ValueType
|
from .types import CategoryType, SubCategoryDict, ValueType
|
||||||
|
# pylint: disable=unused-import
|
||||||
|
from .util import SubCatLookupType, lookup_all, compile_policy # noqa
|
||||||
|
|
||||||
SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({
|
SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({
|
||||||
vol.Optional(POLICY_READ): True,
|
vol.Optional(POLICY_READ): True,
|
||||||
@@ -15,6 +17,7 @@ SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
ENTITY_DOMAINS = 'domains'
|
ENTITY_DOMAINS = 'domains'
|
||||||
|
ENTITY_AREAS = 'area_ids'
|
||||||
ENTITY_DEVICE_IDS = 'device_ids'
|
ENTITY_DEVICE_IDS = 'device_ids'
|
||||||
ENTITY_ENTITY_IDS = 'entity_ids'
|
ENTITY_ENTITY_IDS = 'entity_ids'
|
||||||
|
|
||||||
@@ -24,148 +27,65 @@ ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({
|
|||||||
|
|
||||||
ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({
|
ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({
|
||||||
vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA,
|
vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA,
|
||||||
|
vol.Optional(ENTITY_AREAS): ENTITY_VALUES_SCHEMA,
|
||||||
vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA,
|
vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA,
|
||||||
vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA,
|
vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA,
|
||||||
vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA,
|
vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
def _entity_allowed(schema: ValueType, key: str) \
|
def _lookup_domain(perm_lookup: PermissionLookup,
|
||||||
-> Union[bool, None]:
|
domains_dict: SubCategoryDict,
|
||||||
"""Test if an entity is allowed based on the keys."""
|
entity_id: str) -> Optional[ValueType]:
|
||||||
if schema is None or isinstance(schema, bool):
|
"""Look up entity permissions by domain."""
|
||||||
return schema
|
return domains_dict.get(entity_id.split(".", 1)[0])
|
||||||
assert isinstance(schema, dict)
|
|
||||||
return schema.get(key)
|
|
||||||
|
def _lookup_area(perm_lookup: PermissionLookup, area_dict: SubCategoryDict,
|
||||||
|
entity_id: str) -> Optional[ValueType]:
|
||||||
|
"""Look up entity permissions by area."""
|
||||||
|
entity_entry = perm_lookup.entity_registry.async_get(entity_id)
|
||||||
|
|
||||||
|
if entity_entry is None or entity_entry.device_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
device_entry = perm_lookup.device_registry.async_get(
|
||||||
|
entity_entry.device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if device_entry is None or device_entry.area_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return area_dict.get(device_entry.area_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_device(perm_lookup: PermissionLookup,
|
||||||
|
devices_dict: SubCategoryDict,
|
||||||
|
entity_id: str) -> Optional[ValueType]:
|
||||||
|
"""Look up entity permissions by device."""
|
||||||
|
entity_entry = perm_lookup.entity_registry.async_get(entity_id)
|
||||||
|
|
||||||
|
if entity_entry is None or entity_entry.device_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return devices_dict.get(entity_entry.device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_entity_id(perm_lookup: PermissionLookup,
|
||||||
|
entities_dict: SubCategoryDict,
|
||||||
|
entity_id: str) -> Optional[ValueType]:
|
||||||
|
"""Look up entity permission by entity id."""
|
||||||
|
return entities_dict.get(entity_id)
|
||||||
|
|
||||||
|
|
||||||
def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \
|
def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \
|
||||||
-> Callable[[str, str], bool]:
|
-> Callable[[str, str], bool]:
|
||||||
"""Compile policy into a function that tests policy."""
|
"""Compile policy into a function that tests policy."""
|
||||||
# None, Empty Dict, False
|
subcategories = OrderedDict() # type: SubCatLookupType
|
||||||
if not policy:
|
subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id
|
||||||
def apply_policy_deny_all(entity_id: str, key: str) -> bool:
|
subcategories[ENTITY_DEVICE_IDS] = _lookup_device
|
||||||
"""Decline all."""
|
subcategories[ENTITY_AREAS] = _lookup_area
|
||||||
return False
|
subcategories[ENTITY_DOMAINS] = _lookup_domain
|
||||||
|
subcategories[SUBCAT_ALL] = lookup_all
|
||||||
|
|
||||||
return apply_policy_deny_all
|
return compile_policy(policy, subcategories, perm_lookup)
|
||||||
|
|
||||||
if policy is True:
|
|
||||||
def apply_policy_allow_all(entity_id: str, key: str) -> bool:
|
|
||||||
"""Approve all."""
|
|
||||||
return True
|
|
||||||
|
|
||||||
return apply_policy_allow_all
|
|
||||||
|
|
||||||
assert isinstance(policy, dict)
|
|
||||||
|
|
||||||
domains = policy.get(ENTITY_DOMAINS)
|
|
||||||
device_ids = policy.get(ENTITY_DEVICE_IDS)
|
|
||||||
entity_ids = policy.get(ENTITY_ENTITY_IDS)
|
|
||||||
all_entities = policy.get(SUBCAT_ALL)
|
|
||||||
|
|
||||||
funcs = [] # type: List[Callable[[str, str], Union[None, bool]]]
|
|
||||||
|
|
||||||
# The order of these functions matter. The more precise are at the top.
|
|
||||||
# If a function returns None, they cannot handle it.
|
|
||||||
# If a function returns a boolean, that's the result to return.
|
|
||||||
|
|
||||||
# Setting entity_ids to a boolean is final decision for permissions
|
|
||||||
# So return right away.
|
|
||||||
if isinstance(entity_ids, bool):
|
|
||||||
def allowed_entity_id_bool(entity_id: str, key: str) -> bool:
|
|
||||||
"""Test if allowed entity_id."""
|
|
||||||
return entity_ids # type: ignore
|
|
||||||
|
|
||||||
return allowed_entity_id_bool
|
|
||||||
|
|
||||||
if entity_ids is not None:
|
|
||||||
def allowed_entity_id_dict(entity_id: str, key: str) \
|
|
||||||
-> Union[None, bool]:
|
|
||||||
"""Test if allowed entity_id."""
|
|
||||||
return _entity_allowed(
|
|
||||||
entity_ids.get(entity_id), key) # type: ignore
|
|
||||||
|
|
||||||
funcs.append(allowed_entity_id_dict)
|
|
||||||
|
|
||||||
if isinstance(device_ids, bool):
|
|
||||||
def allowed_device_id_bool(entity_id: str, key: str) \
|
|
||||||
-> Union[None, bool]:
|
|
||||||
"""Test if allowed device_id."""
|
|
||||||
return device_ids
|
|
||||||
|
|
||||||
funcs.append(allowed_device_id_bool)
|
|
||||||
|
|
||||||
elif device_ids is not None:
|
|
||||||
def allowed_device_id_dict(entity_id: str, key: str) \
|
|
||||||
-> Union[None, bool]:
|
|
||||||
"""Test if allowed device_id."""
|
|
||||||
entity_entry = perm_lookup.entity_registry.async_get(entity_id)
|
|
||||||
|
|
||||||
if entity_entry is None or entity_entry.device_id is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return _entity_allowed(
|
|
||||||
device_ids.get(entity_entry.device_id), key # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
funcs.append(allowed_device_id_dict)
|
|
||||||
|
|
||||||
if isinstance(domains, bool):
|
|
||||||
def allowed_domain_bool(entity_id: str, key: str) \
|
|
||||||
-> Union[None, bool]:
|
|
||||||
"""Test if allowed domain."""
|
|
||||||
return domains
|
|
||||||
|
|
||||||
funcs.append(allowed_domain_bool)
|
|
||||||
|
|
||||||
elif domains is not None:
|
|
||||||
def allowed_domain_dict(entity_id: str, key: str) \
|
|
||||||
-> Union[None, bool]:
|
|
||||||
"""Test if allowed domain."""
|
|
||||||
domain = entity_id.split(".", 1)[0]
|
|
||||||
return _entity_allowed(domains.get(domain), key) # type: ignore
|
|
||||||
|
|
||||||
funcs.append(allowed_domain_dict)
|
|
||||||
|
|
||||||
if isinstance(all_entities, bool):
|
|
||||||
def allowed_all_entities_bool(entity_id: str, key: str) \
|
|
||||||
-> Union[None, bool]:
|
|
||||||
"""Test if allowed domain."""
|
|
||||||
return all_entities
|
|
||||||
funcs.append(allowed_all_entities_bool)
|
|
||||||
|
|
||||||
elif all_entities is not None:
|
|
||||||
def allowed_all_entities_dict(entity_id: str, key: str) \
|
|
||||||
-> Union[None, bool]:
|
|
||||||
"""Test if allowed domain."""
|
|
||||||
return _entity_allowed(all_entities, key)
|
|
||||||
funcs.append(allowed_all_entities_dict)
|
|
||||||
|
|
||||||
# Can happen if no valid subcategories specified
|
|
||||||
if not funcs:
|
|
||||||
def apply_policy_deny_all_2(entity_id: str, key: str) -> bool:
|
|
||||||
"""Decline all."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
return apply_policy_deny_all_2
|
|
||||||
|
|
||||||
if len(funcs) == 1:
|
|
||||||
func = funcs[0]
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def apply_policy_func(entity_id: str, key: str) -> bool:
|
|
||||||
"""Apply a single policy function."""
|
|
||||||
return func(entity_id, key) is True
|
|
||||||
|
|
||||||
return apply_policy_func
|
|
||||||
|
|
||||||
def apply_policy_funcs(entity_id: str, key: str) -> bool:
|
|
||||||
"""Apply several policy functions."""
|
|
||||||
for func in funcs:
|
|
||||||
result = func(entity_id, key)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
return False
|
|
||||||
|
|
||||||
return apply_policy_funcs
|
|
||||||
|
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
|
|||||||
from homeassistant.helpers import ( # noqa
|
from homeassistant.helpers import ( # noqa
|
||||||
entity_registry as ent_reg,
|
entity_registry as ent_reg,
|
||||||
)
|
)
|
||||||
|
from homeassistant.helpers import ( # noqa
|
||||||
|
device_registry as dev_reg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@attr.s(slots=True)
|
@attr.s(slots=True)
|
||||||
@@ -15,3 +18,4 @@ class PermissionLookup:
|
|||||||
"""Class to hold data for permission lookups."""
|
"""Class to hold data for permission lookups."""
|
||||||
|
|
||||||
entity_registry = attr.ib(type='ent_reg.EntityRegistry')
|
entity_registry = attr.ib(type='ent_reg.EntityRegistry')
|
||||||
|
device_registry = attr.ib(type='dev_reg.DeviceRegistry')
|
||||||
|
@@ -5,6 +5,10 @@ ADMIN_POLICY = {
|
|||||||
CAT_ENTITIES: True,
|
CAT_ENTITIES: True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
USER_POLICY = {
|
||||||
|
CAT_ENTITIES: True,
|
||||||
|
}
|
||||||
|
|
||||||
READ_ONLY_POLICY = {
|
READ_ONLY_POLICY = {
|
||||||
CAT_ENTITIES: {
|
CAT_ENTITIES: {
|
||||||
SUBCAT_ALL: {
|
SUBCAT_ALL: {
|
||||||
|
@@ -10,9 +10,11 @@ ValueType = Union[
|
|||||||
None
|
None
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Example: entities.domains = { light: … }
|
||||||
|
SubCategoryDict = Mapping[str, ValueType]
|
||||||
|
|
||||||
SubCategoryType = Union[
|
SubCategoryType = Union[
|
||||||
# Example: entities.domains = { light: … }
|
SubCategoryDict,
|
||||||
Mapping[str, ValueType],
|
|
||||||
bool,
|
bool,
|
||||||
None
|
None
|
||||||
]
|
]
|
||||||
|
98
homeassistant/auth/permissions/util.py
Normal file
98
homeassistant/auth/permissions/util.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Helpers to deal with permissions."""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from typing import Callable, Dict, List, Optional, Union, cast # noqa: F401
|
||||||
|
|
||||||
|
from .models import PermissionLookup
|
||||||
|
from .types import CategoryType, SubCategoryDict, ValueType
|
||||||
|
|
||||||
|
LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str],
|
||||||
|
Optional[ValueType]]
|
||||||
|
SubCatLookupType = Dict[str, LookupFunc]
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_all(perm_lookup: PermissionLookup, lookup_dict: SubCategoryDict,
|
||||||
|
object_id: str) -> ValueType:
|
||||||
|
"""Look up permission for all."""
|
||||||
|
# In case of ALL category, lookup_dict IS the schema.
|
||||||
|
return cast(ValueType, lookup_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_policy(
|
||||||
|
policy: CategoryType, subcategories: SubCatLookupType,
|
||||||
|
perm_lookup: PermissionLookup
|
||||||
|
) -> Callable[[str, str], bool]: # noqa
|
||||||
|
"""Compile policy into a function that tests policy.
|
||||||
|
Subcategories are mapping key -> lookup function, ordered by highest
|
||||||
|
priority first.
|
||||||
|
"""
|
||||||
|
# None, False, empty dict
|
||||||
|
if not policy:
|
||||||
|
def apply_policy_deny_all(entity_id: str, key: str) -> bool:
|
||||||
|
"""Decline all."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
return apply_policy_deny_all
|
||||||
|
|
||||||
|
if policy is True:
|
||||||
|
def apply_policy_allow_all(entity_id: str, key: str) -> bool:
|
||||||
|
"""Approve all."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
return apply_policy_allow_all
|
||||||
|
|
||||||
|
assert isinstance(policy, dict)
|
||||||
|
|
||||||
|
funcs = [] # type: List[Callable[[str, str], Union[None, bool]]]
|
||||||
|
|
||||||
|
for key, lookup_func in subcategories.items():
|
||||||
|
lookup_value = policy.get(key)
|
||||||
|
|
||||||
|
# If any lookup value is `True`, it will always be positive
|
||||||
|
if isinstance(lookup_value, bool):
|
||||||
|
return lambda object_id, key: True
|
||||||
|
|
||||||
|
if lookup_value is not None:
|
||||||
|
funcs.append(_gen_dict_test_func(
|
||||||
|
perm_lookup, lookup_func, lookup_value))
|
||||||
|
|
||||||
|
if len(funcs) == 1:
|
||||||
|
func = funcs[0]
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def apply_policy_func(object_id: str, key: str) -> bool:
|
||||||
|
"""Apply a single policy function."""
|
||||||
|
return func(object_id, key) is True
|
||||||
|
|
||||||
|
return apply_policy_func
|
||||||
|
|
||||||
|
def apply_policy_funcs(object_id: str, key: str) -> bool:
|
||||||
|
"""Apply several policy functions."""
|
||||||
|
for func in funcs:
|
||||||
|
result = func(object_id, key)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return False
|
||||||
|
|
||||||
|
return apply_policy_funcs
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_dict_test_func(
|
||||||
|
perm_lookup: PermissionLookup,
|
||||||
|
lookup_func: LookupFunc,
|
||||||
|
lookup_dict: SubCategoryDict
|
||||||
|
) -> Callable[[str, str], Optional[bool]]: # noqa
|
||||||
|
"""Generate a lookup function."""
|
||||||
|
def test_value(object_id: str, key: str) -> Optional[bool]:
|
||||||
|
"""Test if permission is allowed based on the keys."""
|
||||||
|
schema = lookup_func(
|
||||||
|
perm_lookup, lookup_dict, object_id) # type: ValueType
|
||||||
|
|
||||||
|
if schema is None or isinstance(schema, bool):
|
||||||
|
return schema
|
||||||
|
|
||||||
|
assert isinstance(schema, dict)
|
||||||
|
|
||||||
|
return schema.get(key)
|
||||||
|
|
||||||
|
return test_value
|
@@ -4,27 +4,23 @@ Support Legacy API password auth provider.
|
|||||||
It will be removed when auth system production ready
|
It will be removed when auth system production ready
|
||||||
"""
|
"""
|
||||||
import hmac
|
import hmac
|
||||||
from typing import Any, Dict, Optional, cast, TYPE_CHECKING
|
from typing import Any, Dict, Optional, cast
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||||
from .. import AuthManager
|
from .. import AuthManager
|
||||||
from ..models import Credentials, UserMeta, User
|
from ..models import Credentials, UserMeta, User
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
AUTH_PROVIDER_TYPE = 'legacy_api_password'
|
||||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
CONF_API_PASSWORD = 'api_password'
|
||||||
|
|
||||||
|
|
||||||
USER_SCHEMA = vol.Schema({
|
|
||||||
vol.Required('username'): str,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||||
|
vol.Required(CONF_API_PASSWORD): cv.string,
|
||||||
}, extra=vol.PREVENT_EXTRA)
|
}, extra=vol.PREVENT_EXTRA)
|
||||||
|
|
||||||
LEGACY_USER_NAME = 'Legacy API password user'
|
LEGACY_USER_NAME = 'Legacy API password user'
|
||||||
@@ -34,40 +30,45 @@ class InvalidAuthError(HomeAssistantError):
|
|||||||
"""Raised when submitting invalid authentication."""
|
"""Raised when submitting invalid authentication."""
|
||||||
|
|
||||||
|
|
||||||
async def async_get_user(hass: HomeAssistant) -> User:
|
async def async_validate_password(hass: HomeAssistant, password: str)\
|
||||||
"""Return the legacy API password user."""
|
-> Optional[User]:
|
||||||
|
"""Return a user if password is valid. None if not."""
|
||||||
auth = cast(AuthManager, hass.auth) # type: ignore
|
auth = cast(AuthManager, hass.auth) # type: ignore
|
||||||
found = None
|
providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE)
|
||||||
|
if not providers:
|
||||||
for prv in auth.auth_providers:
|
|
||||||
if prv.type == 'legacy_api_password':
|
|
||||||
found = prv
|
|
||||||
break
|
|
||||||
|
|
||||||
if found is None:
|
|
||||||
raise ValueError('Legacy API password provider not found')
|
raise ValueError('Legacy API password provider not found')
|
||||||
|
|
||||||
return await auth.async_get_or_create_user(
|
try:
|
||||||
await found.async_get_or_create_credentials({})
|
provider = cast(LegacyApiPasswordAuthProvider, providers[0])
|
||||||
)
|
provider.async_validate_login(password)
|
||||||
|
return await auth.async_get_or_create_user(
|
||||||
|
await provider.async_get_or_create_credentials({})
|
||||||
|
)
|
||||||
|
except InvalidAuthError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@AUTH_PROVIDERS.register('legacy_api_password')
|
@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE)
|
||||||
class LegacyApiPasswordAuthProvider(AuthProvider):
|
class LegacyApiPasswordAuthProvider(AuthProvider):
|
||||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
"""An auth provider support legacy api_password."""
|
||||||
|
|
||||||
DEFAULT_TITLE = 'Legacy API Password'
|
DEFAULT_TITLE = 'Legacy API Password'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_password(self) -> str:
|
||||||
|
"""Return api_password."""
|
||||||
|
return str(self.config[CONF_API_PASSWORD])
|
||||||
|
|
||||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||||
"""Return a flow to login."""
|
"""Return a flow to login."""
|
||||||
return LegacyLoginFlow(self)
|
return LegacyLoginFlow(self)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_validate_login(self, password: str) -> None:
|
def async_validate_login(self, password: str) -> None:
|
||||||
"""Validate a username and password."""
|
"""Validate password."""
|
||||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
api_password = str(self.config[CONF_API_PASSWORD])
|
||||||
|
|
||||||
if not hmac.compare_digest(hass_http.api_password.encode('utf-8'),
|
if not hmac.compare_digest(api_password.encode('utf-8'),
|
||||||
password.encode('utf-8')):
|
password.encode('utf-8')):
|
||||||
raise InvalidAuthError
|
raise InvalidAuthError
|
||||||
|
|
||||||
@@ -99,12 +100,6 @@ class LegacyLoginFlow(LoginFlow):
|
|||||||
"""Handle the step of the form."""
|
"""Handle the step of the form."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
hass_http = getattr(self.hass, 'http', None)
|
|
||||||
if hass_http is None or not hass_http.api_password:
|
|
||||||
return self.async_abort(
|
|
||||||
reason='no_api_password_set'
|
|
||||||
)
|
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
cast(LegacyApiPasswordAuthProvider, self._auth_provider)\
|
cast(LegacyApiPasswordAuthProvider, self._auth_provider)\
|
||||||
|
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from time import time
|
from time import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Any, Optional, Dict
|
from typing import Any, Optional, Dict, Set
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -28,8 +28,16 @@ ERROR_LOG_FILENAME = 'home-assistant.log'
|
|||||||
# hass.data key for logging information.
|
# hass.data key for logging information.
|
||||||
DATA_LOGGING = 'logging'
|
DATA_LOGGING = 'logging'
|
||||||
|
|
||||||
FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream',
|
LOGGING_COMPONENT = {'logger', 'system_log'}
|
||||||
'logger', 'introduction', 'frontend', 'history'}
|
|
||||||
|
FIRST_INIT_COMPONENT = {
|
||||||
|
'recorder',
|
||||||
|
'mqtt',
|
||||||
|
'mqtt_eventstream',
|
||||||
|
'introduction',
|
||||||
|
'frontend',
|
||||||
|
'history',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def from_config_dict(config: Dict[str, Any],
|
def from_config_dict(config: Dict[str, Any],
|
||||||
@@ -91,12 +99,12 @@ async def async_from_config_dict(config: Dict[str, Any],
|
|||||||
"This may cause issues")
|
"This may cause issues")
|
||||||
|
|
||||||
core_config = config.get(core.DOMAIN, {})
|
core_config = config.get(core.DOMAIN, {})
|
||||||
has_api_password = bool(config.get('http', {}).get('api_password'))
|
api_password = config.get('http', {}).get('api_password')
|
||||||
trusted_networks = config.get('http', {}).get('trusted_networks')
|
trusted_networks = config.get('http', {}).get('trusted_networks')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await conf_util.async_process_ha_core_config(
|
await conf_util.async_process_ha_core_config(
|
||||||
hass, core_config, has_api_password, trusted_networks)
|
hass, core_config, api_password, trusted_networks)
|
||||||
except vol.Invalid as config_err:
|
except vol.Invalid as config_err:
|
||||||
conf_util.async_log_exception(
|
conf_util.async_log_exception(
|
||||||
config_err, 'homeassistant', core_config, hass)
|
config_err, 'homeassistant', core_config, hass)
|
||||||
@@ -117,12 +125,9 @@ async def async_from_config_dict(config: Dict[str, Any],
|
|||||||
hass, config, core_config.get(conf_util.CONF_PACKAGES, {}))
|
hass, config, core_config.get(conf_util.CONF_PACKAGES, {}))
|
||||||
|
|
||||||
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
||||||
await hass.config_entries.async_load()
|
await hass.config_entries.async_initialize()
|
||||||
|
|
||||||
# Filter out the repeating and common config section [homeassistant]
|
components = _get_components(hass, config)
|
||||||
components = set(key.split(' ')[0] for key in config.keys()
|
|
||||||
if key != core.DOMAIN)
|
|
||||||
components.update(hass.config_entries.async_domains())
|
|
||||||
|
|
||||||
# Resolve all dependencies of all components.
|
# Resolve all dependencies of all components.
|
||||||
for component in list(components):
|
for component in list(components):
|
||||||
@@ -144,17 +149,25 @@ async def async_from_config_dict(config: Dict[str, Any],
|
|||||||
|
|
||||||
_LOGGER.info("Home Assistant core initialized")
|
_LOGGER.info("Home Assistant core initialized")
|
||||||
|
|
||||||
|
# stage 0, load logging components
|
||||||
|
for component in components:
|
||||||
|
if component in LOGGING_COMPONENT:
|
||||||
|
hass.async_create_task(
|
||||||
|
async_setup_component(hass, component, config))
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
# stage 1
|
# stage 1
|
||||||
for component in components:
|
for component in components:
|
||||||
if component not in FIRST_INIT_COMPONENT:
|
if component in FIRST_INIT_COMPONENT:
|
||||||
continue
|
hass.async_create_task(
|
||||||
hass.async_create_task(async_setup_component(hass, component, config))
|
async_setup_component(hass, component, config))
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
# stage 2
|
# stage 2
|
||||||
for component in components:
|
for component in components:
|
||||||
if component in FIRST_INIT_COMPONENT:
|
if component in FIRST_INIT_COMPONENT or component in LOGGING_COMPONENT:
|
||||||
continue
|
continue
|
||||||
hass.async_create_task(async_setup_component(hass, component, config))
|
hass.async_create_task(async_setup_component(hass, component, config))
|
||||||
|
|
||||||
@@ -375,3 +388,21 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
|
|||||||
if lib_dir not in sys.path:
|
if lib_dir not in sys.path:
|
||||||
sys.path.insert(0, lib_dir)
|
sys.path.insert(0, lib_dir)
|
||||||
return deps_dir
|
return deps_dir
|
||||||
|
|
||||||
|
|
||||||
|
@core.callback
|
||||||
|
def _get_components(hass: core.HomeAssistant,
|
||||||
|
config: Dict[str, Any]) -> Set[str]:
|
||||||
|
"""Get components to set up."""
|
||||||
|
# Filter out the repeating and common config section [homeassistant]
|
||||||
|
components = set(key.split(' ')[0] for key in config.keys()
|
||||||
|
if key != core.DOMAIN)
|
||||||
|
|
||||||
|
# Add config entry domains
|
||||||
|
components.update(hass.config_entries.async_domains()) # type: ignore
|
||||||
|
|
||||||
|
# Make sure the Hass.io component is loaded
|
||||||
|
if 'HASSIO' in os.environ:
|
||||||
|
components.add('hassio')
|
||||||
|
|
||||||
|
return components
|
||||||
|
@@ -17,7 +17,7 @@ import voluptuous as vol
|
|||||||
import homeassistant.core as ha
|
import homeassistant.core as ha
|
||||||
import homeassistant.config as conf_util
|
import homeassistant.config as conf_util
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.service import extract_entity_ids
|
from homeassistant.helpers.service import async_extract_entity_ids
|
||||||
from homeassistant.helpers import intent
|
from homeassistant.helpers import intent
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
|
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
|
||||||
@@ -70,7 +70,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]:
|
|||||||
"""Set up general services related to Home Assistant."""
|
"""Set up general services related to Home Assistant."""
|
||||||
async def async_handle_turn_service(service):
|
async def async_handle_turn_service(service):
|
||||||
"""Handle calls to homeassistant.turn_on/off."""
|
"""Handle calls to homeassistant.turn_on/off."""
|
||||||
entity_ids = extract_entity_ids(hass, service)
|
entity_ids = await async_extract_entity_ids(hass, service)
|
||||||
|
|
||||||
# Generic turn on/off method requires entity id
|
# Generic turn on/off method requires entity id
|
||||||
if not entity_ids:
|
if not entity_ids:
|
||||||
@@ -166,6 +166,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]:
|
|||||||
_LOGGER.error(err)
|
_LOGGER.error(err)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# auth only processed during startup
|
||||||
await conf_util.async_process_ha_core_config(
|
await conf_util.async_process_ha_core_config(
|
||||||
hass, conf.get(ha.DOMAIN) or {})
|
hass, conf.get(ha.DOMAIN) or {})
|
||||||
|
|
||||||
|
@@ -11,7 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
REQUIREMENTS = ['opensensemap-api==0.1.4']
|
REQUIREMENTS = ['opensensemap-api==0.1.5']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@@ -342,18 +342,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def message_received(topic, payload, qos):
|
def message_received(msg):
|
||||||
"""Run when new MQTT message has been received."""
|
"""Run when new MQTT message has been received."""
|
||||||
if payload == self._payload_disarm:
|
if msg.payload == self._payload_disarm:
|
||||||
self.async_alarm_disarm(self._code)
|
self.async_alarm_disarm(self._code)
|
||||||
elif payload == self._payload_arm_home:
|
elif msg.payload == self._payload_arm_home:
|
||||||
self.async_alarm_arm_home(self._code)
|
self.async_alarm_arm_home(self._code)
|
||||||
elif payload == self._payload_arm_away:
|
elif msg.payload == self._payload_arm_away:
|
||||||
self.async_alarm_arm_away(self._code)
|
self.async_alarm_arm_away(self._code)
|
||||||
elif payload == self._payload_arm_night:
|
elif msg.payload == self._payload_arm_night:
|
||||||
self.async_alarm_arm_night(self._code)
|
self.async_alarm_arm_night(self._code)
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning("Received unexpected payload: %s", payload)
|
_LOGGER.warning("Received unexpected payload: %s", msg.payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
await mqtt.async_subscribe(
|
await mqtt.async_subscribe(
|
||||||
|
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
|||||||
STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||||
|
|
||||||
|
|
||||||
REQUIREMENTS = ['total_connect_client==0.22']
|
REQUIREMENTS = ['total_connect_client==0.24']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@@ -131,6 +131,11 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel):
|
|||||||
if code:
|
if code:
|
||||||
self.hass.data[DATA_AD].send("{!s}3".format(code))
|
self.hass.data[DATA_AD].send("{!s}3".format(code))
|
||||||
|
|
||||||
|
def alarm_arm_night(self, code=None):
|
||||||
|
"""Send arm night command."""
|
||||||
|
if code:
|
||||||
|
self.hass.data[DATA_AD].send("{!s}33".format(code))
|
||||||
|
|
||||||
def alarm_toggle_chime(self, code=None):
|
def alarm_toggle_chime(self, code=None):
|
||||||
"""Send toggle chime command."""
|
"""Send toggle chime command."""
|
||||||
if code:
|
if code:
|
||||||
|
@@ -89,7 +89,7 @@ async def async_setup(hass, config):
|
|||||||
|
|
||||||
async def async_handle_alert_service(service_call):
|
async def async_handle_alert_service(service_call):
|
||||||
"""Handle calls to alert services."""
|
"""Handle calls to alert services."""
|
||||||
alert_ids = service.extract_entity_ids(hass, service_call)
|
alert_ids = await service.async_extract_entity_ids(hass, service_call)
|
||||||
|
|
||||||
for alert_id in alert_ids:
|
for alert_id in alert_ids:
|
||||||
for alert in entities:
|
for alert in entities:
|
||||||
|
@@ -1,21 +1,21 @@
|
|||||||
"""Support for alexa Smart Home Skill API."""
|
"""Support for alexa Smart Home Skill API."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import OrderedDict
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import async_timeout
|
import async_timeout
|
||||||
|
|
||||||
|
import homeassistant.core as ha
|
||||||
|
import homeassistant.util.color as color_util
|
||||||
from homeassistant.components import (
|
from homeassistant.components import (
|
||||||
alert, automation, binary_sensor, cover, fan, group, http,
|
alert, automation, binary_sensor, cover, fan, group, http,
|
||||||
input_boolean, light, lock, media_player, scene, script, sensor, switch)
|
input_boolean, light, lock, media_player, scene, script, sensor, switch)
|
||||||
from homeassistant.components.climate import const as climate
|
from homeassistant.components.climate import const as climate
|
||||||
from homeassistant.helpers import aiohttp_client
|
|
||||||
from homeassistant.helpers.event import async_track_state_change
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
|
ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
|
||||||
ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES,
|
ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||||
@@ -25,14 +25,14 @@ from homeassistant.const import (
|
|||||||
SERVICE_UNLOCK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET,
|
SERVICE_UNLOCK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET,
|
||||||
SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE,
|
SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE,
|
||||||
STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL)
|
STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL)
|
||||||
import homeassistant.core as ha
|
from homeassistant.helpers import aiohttp_client
|
||||||
import homeassistant.util.color as color_util
|
from homeassistant.helpers.event import async_track_state_change
|
||||||
from homeassistant.util.decorator import Registry
|
from homeassistant.util.decorator import Registry
|
||||||
from homeassistant.util.temperature import convert as convert_temperature
|
from homeassistant.util.temperature import convert as convert_temperature
|
||||||
|
|
||||||
|
from .auth import Auth
|
||||||
from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \
|
from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \
|
||||||
CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT
|
CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT
|
||||||
from .auth import Auth
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1115,12 +1115,15 @@ class SmartHomeView(http.HomeAssistantView):
|
|||||||
the response.
|
the response.
|
||||||
"""
|
"""
|
||||||
hass = request.app['hass']
|
hass = request.app['hass']
|
||||||
|
user = request[http.KEY_HASS_USER]
|
||||||
message = await request.json()
|
message = await request.json()
|
||||||
|
|
||||||
_LOGGER.debug("Received Alexa Smart Home request: %s", message)
|
_LOGGER.debug("Received Alexa Smart Home request: %s", message)
|
||||||
|
|
||||||
response = await async_handle_message(
|
response = await async_handle_message(
|
||||||
hass, self.smart_home_config, message)
|
hass, self.smart_home_config, message,
|
||||||
|
context=ha.Context(user_id=user.id)
|
||||||
|
)
|
||||||
_LOGGER.debug("Sending Alexa Smart Home response: %s", response)
|
_LOGGER.debug("Sending Alexa Smart Home response: %s", response)
|
||||||
return b'' if response is None else self.json(response)
|
return b'' if response is None else self.json(response)
|
||||||
|
|
||||||
|
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"api_key": "\u0e04\u0e35\u0e22\u0e4c API",
|
||||||
|
"app_key": "\u0e23\u0e2b\u0e31\u0e2a\u0e41\u0e2d\u0e1b\u0e1e\u0e25\u0e34\u0e40\u0e04\u0e0a\u0e31\u0e19"
|
||||||
|
},
|
||||||
|
"title": "\u0e01\u0e23\u0e2d\u0e01\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -296,6 +296,7 @@ class AmbientStation:
|
|||||||
def __init__(self, hass, config_entry, client, monitored_conditions):
|
def __init__(self, hass, config_entry, client, monitored_conditions):
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
self._config_entry = config_entry
|
self._config_entry = config_entry
|
||||||
|
self._entry_setup_complete = False
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._watchdog_listener = None
|
self._watchdog_listener = None
|
||||||
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
|
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
|
||||||
@@ -362,12 +363,18 @@ class AmbientStation:
|
|||||||
'name', station['macAddress']),
|
'name', station['macAddress']),
|
||||||
}
|
}
|
||||||
|
|
||||||
for component in ('binary_sensor', 'sensor'):
|
# If the websocket disconnects and reconnects, the on_subscribed
|
||||||
self._hass.async_create_task(
|
# handler will get called again; in that case, we don't want to
|
||||||
self._hass.config_entries.async_forward_entry_setup(
|
# attempt forward setup of the config entry (because it will have
|
||||||
self._config_entry, component))
|
# already been done):
|
||||||
|
if not self._entry_setup_complete:
|
||||||
|
for component in ('binary_sensor', 'sensor'):
|
||||||
|
self._hass.async_create_task(
|
||||||
|
self._hass.config_entries.async_forward_entry_setup(
|
||||||
|
self._config_entry, component))
|
||||||
|
self._entry_setup_complete = True
|
||||||
|
|
||||||
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
|
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
|
||||||
|
|
||||||
self.client.websocket.on_connect(on_connect)
|
self.client.websocket.on_connect(on_connect)
|
||||||
self.client.websocket.on_data(on_data)
|
self.client.websocket.on_data(on_data)
|
||||||
|
@@ -13,7 +13,7 @@ from homeassistant.const import (
|
|||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import discovery
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
REQUIREMENTS = ['amcrest==1.2.3']
|
REQUIREMENTS = ['amcrest==1.2.5']
|
||||||
DEPENDENCIES = ['ffmpeg']
|
DEPENDENCIES = ['ffmpeg']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
"""Support for Amcrest IP cameras."""
|
"""Support for Amcrest IP cameras."""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from requests import RequestException
|
||||||
|
from urllib3.exceptions import ReadTimeoutError
|
||||||
|
|
||||||
from homeassistant.components.amcrest import (
|
from homeassistant.components.amcrest import (
|
||||||
DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT)
|
DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT)
|
||||||
from homeassistant.components.camera import Camera
|
from homeassistant.components.camera import Camera
|
||||||
@@ -43,12 +47,20 @@ class AmcrestCam(Camera):
|
|||||||
self._stream_source = amcrest.stream_source
|
self._stream_source = amcrest.stream_source
|
||||||
self._resolution = amcrest.resolution
|
self._resolution = amcrest.resolution
|
||||||
self._token = self._auth = amcrest.authentication
|
self._token = self._auth = amcrest.authentication
|
||||||
|
self._snapshot_lock = asyncio.Lock()
|
||||||
|
|
||||||
def camera_image(self):
|
async def async_camera_image(self):
|
||||||
"""Return a still image response from the camera."""
|
"""Return a still image response from the camera."""
|
||||||
# Send the request to snap a picture and return raw jpg data
|
async with self._snapshot_lock:
|
||||||
response = self._camera.snapshot(channel=self._resolution)
|
try:
|
||||||
return response.data
|
# Send the request to snap a picture and return raw jpg data
|
||||||
|
response = await self.hass.async_add_executor_job(
|
||||||
|
self._camera.snapshot, self._resolution)
|
||||||
|
return response.data
|
||||||
|
except (RequestException, ReadTimeoutError, ValueError) as error:
|
||||||
|
_LOGGER.error(
|
||||||
|
'Could not get camera image due to error %s', error)
|
||||||
|
return None
|
||||||
|
|
||||||
async def handle_async_mjpeg_stream(self, request):
|
async def handle_async_mjpeg_stream(self, request):
|
||||||
"""Return an MJPEG stream."""
|
"""Return an MJPEG stream."""
|
||||||
@@ -85,3 +97,8 @@ class AmcrestCam(Camera):
|
|||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of this camera."""
|
"""Return the name of this camera."""
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_source(self):
|
||||||
|
"""Return the source of the stream."""
|
||||||
|
return self._camera.rtsp_url(typeno=self._resolution)
|
||||||
|
6
homeassistant/components/androidtv/__init__.py
Normal file
6
homeassistant/components/androidtv/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Support for functionality to interact with Android TV and Fire TV devices.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/media_player.androidtv/
|
||||||
|
"""
|
454
homeassistant/components/androidtv/media_player.py
Normal file
454
homeassistant/components/androidtv/media_player.py
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
"""
|
||||||
|
Support for functionality to interact with Android TV and Fire TV devices.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/media_player.androidtv/
|
||||||
|
"""
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.media_player import (
|
||||||
|
MediaPlayerDevice, PLATFORM_SCHEMA)
|
||||||
|
from homeassistant.components.media_player.const import (
|
||||||
|
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK,
|
||||||
|
SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
|
||||||
|
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP)
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME,
|
||||||
|
CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING,
|
||||||
|
STATE_STANDBY)
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
|
ANDROIDTV_DOMAIN = 'androidtv'
|
||||||
|
|
||||||
|
REQUIREMENTS = ['androidtv==0.0.12']
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUPPORT_ANDROIDTV = SUPPORT_PAUSE | SUPPORT_PLAY | \
|
||||||
|
SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \
|
||||||
|
SUPPORT_NEXT_TRACK | SUPPORT_STOP | SUPPORT_VOLUME_MUTE | \
|
||||||
|
SUPPORT_VOLUME_STEP
|
||||||
|
|
||||||
|
SUPPORT_FIRETV = SUPPORT_PAUSE | SUPPORT_PLAY | \
|
||||||
|
SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \
|
||||||
|
SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP
|
||||||
|
|
||||||
|
CONF_ADBKEY = 'adbkey'
|
||||||
|
CONF_ADB_SERVER_IP = 'adb_server_ip'
|
||||||
|
CONF_ADB_SERVER_PORT = 'adb_server_port'
|
||||||
|
CONF_APPS = 'apps'
|
||||||
|
CONF_GET_SOURCES = 'get_sources'
|
||||||
|
|
||||||
|
DEFAULT_NAME = 'Android TV'
|
||||||
|
DEFAULT_PORT = 5555
|
||||||
|
DEFAULT_ADB_SERVER_PORT = 5037
|
||||||
|
DEFAULT_GET_SOURCES = True
|
||||||
|
DEFAULT_DEVICE_CLASS = 'auto'
|
||||||
|
|
||||||
|
DEVICE_ANDROIDTV = 'androidtv'
|
||||||
|
DEVICE_FIRETV = 'firetv'
|
||||||
|
DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV]
|
||||||
|
|
||||||
|
SERVICE_ADB_COMMAND = 'adb_command'
|
||||||
|
|
||||||
|
SERVICE_ADB_COMMAND_SCHEMA = vol.Schema({
|
||||||
|
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||||
|
vol.Required(ATTR_COMMAND): cv.string,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def has_adb_files(value):
|
||||||
|
"""Check that ADB key files exist."""
|
||||||
|
priv_key = value
|
||||||
|
pub_key = '{}.pub'.format(value)
|
||||||
|
cv.isfile(pub_key)
|
||||||
|
return cv.isfile(priv_key)
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
|
vol.Required(CONF_HOST): cv.string,
|
||||||
|
vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS):
|
||||||
|
vol.In(DEVICE_CLASSES),
|
||||||
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||||
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||||
|
vol.Optional(CONF_ADBKEY): has_adb_files,
|
||||||
|
vol.Optional(CONF_ADB_SERVER_IP): cv.string,
|
||||||
|
vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT):
|
||||||
|
cv.port,
|
||||||
|
vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean,
|
||||||
|
vol.Optional(CONF_APPS, default=dict()):
|
||||||
|
vol.Schema({cv.string: cv.string})
|
||||||
|
})
|
||||||
|
|
||||||
|
# Translate from `AndroidTV` / `FireTV` reported state to HA state.
|
||||||
|
ANDROIDTV_STATES = {'off': STATE_OFF,
|
||||||
|
'idle': STATE_IDLE,
|
||||||
|
'standby': STATE_STANDBY,
|
||||||
|
'playing': STATE_PLAYING,
|
||||||
|
'paused': STATE_PAUSED}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||||
|
"""Set up the Android TV / Fire TV platform."""
|
||||||
|
from androidtv import setup
|
||||||
|
|
||||||
|
hass.data.setdefault(ANDROIDTV_DOMAIN, {})
|
||||||
|
|
||||||
|
host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT])
|
||||||
|
|
||||||
|
if CONF_ADB_SERVER_IP not in config:
|
||||||
|
# Use "python-adb" (Python ADB implementation)
|
||||||
|
if CONF_ADBKEY in config:
|
||||||
|
aftv = setup(host, config[CONF_ADBKEY],
|
||||||
|
device_class=config[CONF_DEVICE_CLASS])
|
||||||
|
adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY])
|
||||||
|
|
||||||
|
else:
|
||||||
|
aftv = setup(host, device_class=config[CONF_DEVICE_CLASS])
|
||||||
|
adb_log = ""
|
||||||
|
else:
|
||||||
|
# Use "pure-python-adb" (communicate with ADB server)
|
||||||
|
aftv = setup(host, adb_server_ip=config[CONF_ADB_SERVER_IP],
|
||||||
|
adb_server_port=config[CONF_ADB_SERVER_PORT],
|
||||||
|
device_class=config[CONF_DEVICE_CLASS])
|
||||||
|
adb_log = " using ADB server at {0}:{1}".format(
|
||||||
|
config[CONF_ADB_SERVER_IP], config[CONF_ADB_SERVER_PORT])
|
||||||
|
|
||||||
|
if not aftv.available:
|
||||||
|
# Determine the name that will be used for the device in the log
|
||||||
|
if CONF_NAME in config:
|
||||||
|
device_name = config[CONF_NAME]
|
||||||
|
elif config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV:
|
||||||
|
device_name = 'Android TV device'
|
||||||
|
elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV:
|
||||||
|
device_name = 'Fire TV device'
|
||||||
|
else:
|
||||||
|
device_name = 'Android TV / Fire TV device'
|
||||||
|
|
||||||
|
_LOGGER.warning("Could not connect to %s at %s%s",
|
||||||
|
device_name, host, adb_log)
|
||||||
|
return
|
||||||
|
|
||||||
|
if host in hass.data[ANDROIDTV_DOMAIN]:
|
||||||
|
_LOGGER.warning("Platform already setup on %s, skipping", host)
|
||||||
|
else:
|
||||||
|
if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV:
|
||||||
|
device = AndroidTVDevice(aftv, config[CONF_NAME],
|
||||||
|
config[CONF_APPS])
|
||||||
|
device_name = config[CONF_NAME] if CONF_NAME in config \
|
||||||
|
else 'Android TV'
|
||||||
|
else:
|
||||||
|
device = FireTVDevice(aftv, config[CONF_NAME], config[CONF_APPS],
|
||||||
|
config[CONF_GET_SOURCES])
|
||||||
|
device_name = config[CONF_NAME] if CONF_NAME in config \
|
||||||
|
else 'Fire TV'
|
||||||
|
|
||||||
|
add_entities([device])
|
||||||
|
_LOGGER.debug("Setup %s at %s%s", device_name, host, adb_log)
|
||||||
|
hass.data[ANDROIDTV_DOMAIN][host] = device
|
||||||
|
|
||||||
|
if hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND):
|
||||||
|
return
|
||||||
|
|
||||||
|
def service_adb_command(service):
|
||||||
|
"""Dispatch service calls to target entities."""
|
||||||
|
cmd = service.data.get(ATTR_COMMAND)
|
||||||
|
entity_id = service.data.get(ATTR_ENTITY_ID)
|
||||||
|
target_devices = [dev for dev in hass.data[ANDROIDTV_DOMAIN].values()
|
||||||
|
if dev.entity_id in entity_id]
|
||||||
|
|
||||||
|
for target_device in target_devices:
|
||||||
|
output = target_device.adb_command(cmd)
|
||||||
|
|
||||||
|
# log the output if there is any
|
||||||
|
if output:
|
||||||
|
_LOGGER.info("Output of command '%s' from '%s': %s",
|
||||||
|
cmd, target_device.entity_id, repr(output))
|
||||||
|
|
||||||
|
hass.services.register(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND,
|
||||||
|
service_adb_command,
|
||||||
|
schema=SERVICE_ADB_COMMAND_SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def adb_decorator(override_available=False):
|
||||||
|
"""Send an ADB command if the device is available and catch exceptions."""
|
||||||
|
def _adb_decorator(func):
|
||||||
|
"""Wait if previous ADB commands haven't finished."""
|
||||||
|
@functools.wraps(func)
|
||||||
|
def _adb_exception_catcher(self, *args, **kwargs):
|
||||||
|
# If the device is unavailable, don't do anything
|
||||||
|
if not self.available and not override_available:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
except self.exceptions as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to execute an ADB command. ADB connection re-"
|
||||||
|
"establishing attempt in the next update. Error: %s", err)
|
||||||
|
self._available = False # pylint: disable=protected-access
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _adb_exception_catcher
|
||||||
|
|
||||||
|
return _adb_decorator
|
||||||
|
|
||||||
|
|
||||||
|
class ADBDevice(MediaPlayerDevice):
|
||||||
|
"""Representation of an Android TV or Fire TV device."""
|
||||||
|
|
||||||
|
def __init__(self, aftv, name, apps):
|
||||||
|
"""Initialize the Android TV / Fire TV device."""
|
||||||
|
from androidtv.constants import APPS, KEYS
|
||||||
|
|
||||||
|
self.aftv = aftv
|
||||||
|
self._name = name
|
||||||
|
self._apps = APPS
|
||||||
|
self._apps.update(apps)
|
||||||
|
self._keys = KEYS
|
||||||
|
|
||||||
|
# ADB exceptions to catch
|
||||||
|
if not self.aftv.adb_server_ip:
|
||||||
|
# Using "python-adb" (Python ADB implementation)
|
||||||
|
from adb.adb_protocol import (InvalidChecksumError,
|
||||||
|
InvalidCommandError,
|
||||||
|
InvalidResponseError)
|
||||||
|
from adb.usb_exceptions import TcpTimeoutException
|
||||||
|
|
||||||
|
self.exceptions = (AttributeError, BrokenPipeError, TypeError,
|
||||||
|
ValueError, InvalidChecksumError,
|
||||||
|
InvalidCommandError, InvalidResponseError,
|
||||||
|
TcpTimeoutException)
|
||||||
|
else:
|
||||||
|
# Using "pure-python-adb" (communicate with ADB server)
|
||||||
|
self.exceptions = (ConnectionResetError,)
|
||||||
|
|
||||||
|
# Property attributes
|
||||||
|
self._available = self.aftv.available
|
||||||
|
self._current_app = None
|
||||||
|
self._state = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_id(self):
|
||||||
|
"""Return the current app."""
|
||||||
|
return self._current_app
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_name(self):
|
||||||
|
"""Return the friendly name of the current app."""
|
||||||
|
return self._apps.get(self._current_app, self._current_app)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
"""Return whether or not the ADB connection is valid."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the device name."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self):
|
||||||
|
"""Device should be polled."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of the player."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def media_play(self):
|
||||||
|
"""Send play command."""
|
||||||
|
self.aftv.media_play()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def media_pause(self):
|
||||||
|
"""Send pause command."""
|
||||||
|
self.aftv.media_pause()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def media_play_pause(self):
|
||||||
|
"""Send play/pause command."""
|
||||||
|
self.aftv.media_play_pause()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def turn_on(self):
|
||||||
|
"""Turn on the device."""
|
||||||
|
self.aftv.turn_on()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def turn_off(self):
|
||||||
|
"""Turn off the device."""
|
||||||
|
self.aftv.turn_off()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def media_previous_track(self):
|
||||||
|
"""Send previous track command (results in rewind)."""
|
||||||
|
self.aftv.media_previous()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def media_next_track(self):
|
||||||
|
"""Send next track command (results in fast-forward)."""
|
||||||
|
self.aftv.media_next()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def adb_command(self, cmd):
|
||||||
|
"""Send an ADB command to an Android TV / Fire TV device."""
|
||||||
|
key = self._keys.get(cmd)
|
||||||
|
if key:
|
||||||
|
return self.aftv.adb_shell('input keyevent {}'.format(key))
|
||||||
|
|
||||||
|
if cmd == 'GET_PROPERTIES':
|
||||||
|
return self.aftv.get_properties_dict()
|
||||||
|
|
||||||
|
return self.aftv.adb_shell(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidTVDevice(ADBDevice):
|
||||||
|
"""Representation of an Android TV device."""
|
||||||
|
|
||||||
|
def __init__(self, aftv, name, apps):
|
||||||
|
"""Initialize the Android TV device."""
|
||||||
|
super().__init__(aftv, name, apps)
|
||||||
|
|
||||||
|
self._device = None
|
||||||
|
self._muted = None
|
||||||
|
self._device_properties = self.aftv.device_properties
|
||||||
|
self._unique_id = 'androidtv-{}-{}'.format(
|
||||||
|
name, self._device_properties['serialno'])
|
||||||
|
self._volume = None
|
||||||
|
|
||||||
|
@adb_decorator(override_available=True)
|
||||||
|
def update(self):
|
||||||
|
"""Update the device state and, if necessary, re-connect."""
|
||||||
|
# Check if device is disconnected.
|
||||||
|
if not self._available:
|
||||||
|
# Try to connect
|
||||||
|
self._available = self.aftv.connect(always_log_errors=False)
|
||||||
|
|
||||||
|
# To be safe, wait until the next update to run ADB commands.
|
||||||
|
return
|
||||||
|
|
||||||
|
# If the ADB connection is not intact, don't update.
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the `state`, `current_app`, and `running_apps`.
|
||||||
|
state, self._current_app, self._device, self._muted, self._volume = \
|
||||||
|
self.aftv.update()
|
||||||
|
|
||||||
|
self._state = ANDROIDTV_STATES[state]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_volume_muted(self):
|
||||||
|
"""Boolean if volume is currently muted."""
|
||||||
|
return self._muted
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self):
|
||||||
|
"""Return the current playback device."""
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self):
|
||||||
|
"""Flag media player features that are supported."""
|
||||||
|
return SUPPORT_ANDROIDTV
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self):
|
||||||
|
"""Return the device unique id."""
|
||||||
|
return self._unique_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_level(self):
|
||||||
|
"""Return the volume level."""
|
||||||
|
return self._volume
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def media_stop(self):
|
||||||
|
"""Send stop command."""
|
||||||
|
self.aftv.media_stop()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def mute_volume(self, mute):
|
||||||
|
"""Mute the volume."""
|
||||||
|
self.aftv.mute_volume()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def volume_down(self):
|
||||||
|
"""Send volume down command."""
|
||||||
|
self.aftv.volume_down()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def volume_up(self):
|
||||||
|
"""Send volume up command."""
|
||||||
|
self.aftv.volume_up()
|
||||||
|
|
||||||
|
|
||||||
|
class FireTVDevice(ADBDevice):
|
||||||
|
"""Representation of a Fire TV device."""
|
||||||
|
|
||||||
|
def __init__(self, aftv, name, apps, get_sources):
|
||||||
|
"""Initialize the Fire TV device."""
|
||||||
|
super().__init__(aftv, name, apps)
|
||||||
|
|
||||||
|
self._get_sources = get_sources
|
||||||
|
self._running_apps = None
|
||||||
|
|
||||||
|
@adb_decorator(override_available=True)
|
||||||
|
def update(self):
|
||||||
|
"""Update the device state and, if necessary, re-connect."""
|
||||||
|
# Check if device is disconnected.
|
||||||
|
if not self._available:
|
||||||
|
# Try to connect
|
||||||
|
self._available = self.aftv.connect(always_log_errors=False)
|
||||||
|
|
||||||
|
# To be safe, wait until the next update to run ADB commands.
|
||||||
|
return
|
||||||
|
|
||||||
|
# If the ADB connection is not intact, don't update.
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the `state`, `current_app`, and `running_apps`.
|
||||||
|
state, self._current_app, self._running_apps = \
|
||||||
|
self.aftv.update(self._get_sources)
|
||||||
|
|
||||||
|
self._state = ANDROIDTV_STATES[state]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self):
|
||||||
|
"""Return the current app."""
|
||||||
|
return self._current_app
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_list(self):
|
||||||
|
"""Return a list of running apps."""
|
||||||
|
return self._running_apps
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self):
|
||||||
|
"""Flag media player features that are supported."""
|
||||||
|
return SUPPORT_FIRETV
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def media_stop(self):
|
||||||
|
"""Send stop (back) command."""
|
||||||
|
self.aftv.back()
|
||||||
|
|
||||||
|
@adb_decorator()
|
||||||
|
def select_source(self, source):
|
||||||
|
"""Select input source.
|
||||||
|
|
||||||
|
If the source starts with a '!', then it will close the app instead of
|
||||||
|
opening it.
|
||||||
|
"""
|
||||||
|
if isinstance(source, str):
|
||||||
|
if not source.startswith('!'):
|
||||||
|
self.aftv.launch_app(source)
|
||||||
|
else:
|
||||||
|
self.aftv.stop_app(source[1:].lstrip())
|
11
homeassistant/components/androidtv/services.yaml
Normal file
11
homeassistant/components/androidtv/services.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Describes the format for available Android TV and Fire TV services
|
||||||
|
|
||||||
|
adb_command:
|
||||||
|
description: Send an ADB command to an Android TV / Fire TV device.
|
||||||
|
fields:
|
||||||
|
entity_id:
|
||||||
|
description: Name(s) of Android TV / Fire TV entities.
|
||||||
|
example: 'media_player.android_tv_living_room'
|
||||||
|
command:
|
||||||
|
description: Either a key command or an ADB shell command.
|
||||||
|
example: 'HOME'
|
@@ -6,7 +6,7 @@ import voluptuous as vol
|
|||||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.components import apcupsd
|
from homeassistant.components import apcupsd
|
||||||
from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES)
|
from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES, POWER_WATT)
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -57,7 +57,7 @@ SENSOR_TYPES = {
|
|||||||
'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'],
|
'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'],
|
||||||
'nominv': ['Nominal Input Voltage', 'V', 'mdi:flash'],
|
'nominv': ['Nominal Input Voltage', 'V', 'mdi:flash'],
|
||||||
'nomoutv': ['Nominal Output Voltage', 'V', 'mdi:flash'],
|
'nomoutv': ['Nominal Output Voltage', 'V', 'mdi:flash'],
|
||||||
'nompower': ['Nominal Output Power', 'W', 'mdi:flash'],
|
'nompower': ['Nominal Output Power', POWER_WATT, 'mdi:flash'],
|
||||||
'nomapnt': ['Nominal Apparent Power', 'VA', 'mdi:flash'],
|
'nomapnt': ['Nominal Apparent Power', 'VA', 'mdi:flash'],
|
||||||
'numxfers': ['Transfer Count', '', 'mdi:counter'],
|
'numxfers': ['Transfer Count', '', 'mdi:counter'],
|
||||||
'outcurnt': ['Output Current', 'A', 'mdi:flash'],
|
'outcurnt': ['Output Current', 'A', 'mdi:flash'],
|
||||||
@@ -93,7 +93,7 @@ INFERRED_UNITS = {
|
|||||||
' Volts': 'V',
|
' Volts': 'V',
|
||||||
' Ampere': 'A',
|
' Ampere': 'A',
|
||||||
' Volt-Ampere': 'VA',
|
' Volt-Ampere': 'VA',
|
||||||
' Watts': 'W',
|
' Watts': POWER_WATT,
|
||||||
' Hz': 'Hz',
|
' Hz': 'Hz',
|
||||||
' C': TEMP_CELSIUS,
|
' C': TEMP_CELSIUS,
|
||||||
' Percent Load Capacity': '%',
|
' Percent Load Capacity': '%',
|
||||||
|
@@ -168,11 +168,11 @@ class APIDiscoveryView(HomeAssistantView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""Get discovery information."""
|
"""Get discovery information."""
|
||||||
hass = request.app['hass']
|
hass = request.app['hass']
|
||||||
needs_auth = hass.config.api.api_password is not None
|
|
||||||
return self.json({
|
return self.json({
|
||||||
ATTR_BASE_URL: hass.config.api.base_url,
|
ATTR_BASE_URL: hass.config.api.base_url,
|
||||||
ATTR_LOCATION_NAME: hass.config.location_name,
|
ATTR_LOCATION_NAME: hass.config.location_name,
|
||||||
ATTR_REQUIRES_API_PASSWORD: needs_auth,
|
# always needs authentication
|
||||||
|
ATTR_REQUIRES_API_PASSWORD: True,
|
||||||
ATTR_VERSION: __version__,
|
ATTR_VERSION: __version__,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ from homeassistant.const import (
|
|||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.discovery import async_load_platform
|
from homeassistant.helpers.discovery import async_load_platform
|
||||||
|
|
||||||
REQUIREMENTS = ['aioasuswrt==1.1.20']
|
REQUIREMENTS = ['aioasuswrt==1.1.21']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
11
homeassistant/components/auth/.translations/th.json
Normal file
11
homeassistant/components/auth/.translations/th.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mfa_setup": {
|
||||||
|
"notify": {
|
||||||
|
"step": {
|
||||||
|
"setup": {
|
||||||
|
"title": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e01\u0e32\u0e23\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -127,6 +127,7 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.auth.models import User, Credentials, \
|
from homeassistant.auth.models import User, Credentials, \
|
||||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||||
|
from homeassistant.loader import bind_hass
|
||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.components.http import KEY_REAL_IP
|
from homeassistant.components.http import KEY_REAL_IP
|
||||||
from homeassistant.components.http.auth import async_sign_path
|
from homeassistant.components.http.auth import async_sign_path
|
||||||
@@ -184,10 +185,18 @@ RESULT_TYPE_USER = 'user'
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bind_hass
|
||||||
|
def create_auth_code(hass, client_id: str, user: User) -> str:
|
||||||
|
"""Create an authorization code to fetch tokens."""
|
||||||
|
return hass.data[DOMAIN](client_id, user)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass, config):
|
async def async_setup(hass, config):
|
||||||
"""Component to allow users to login."""
|
"""Component to allow users to login."""
|
||||||
store_result, retrieve_result = _create_auth_code_store()
|
store_result, retrieve_result = _create_auth_code_store()
|
||||||
|
|
||||||
|
hass.data[DOMAIN] = store_result
|
||||||
|
|
||||||
hass.http.register_view(TokenView(retrieve_result))
|
hass.http.register_view(TokenView(retrieve_result))
|
||||||
hass.http.register_view(LinkUserView(retrieve_result))
|
hass.http.register_view(LinkUserView(retrieve_result))
|
||||||
|
|
||||||
@@ -450,6 +459,7 @@ async def websocket_current_user(
|
|||||||
'id': user.id,
|
'id': user.id,
|
||||||
'name': user.name,
|
'name': user.name,
|
||||||
'is_owner': user.is_owner,
|
'is_owner': user.is_owner,
|
||||||
|
'is_admin': user.is_admin,
|
||||||
'credentials': [{'auth_provider_type': c.auth_provider_type,
|
'credentials': [{'auth_provider_type': c.auth_provider_type,
|
||||||
'auth_provider_id': c.auth_provider_id}
|
'auth_provider_id': c.auth_provider_id}
|
||||||
for c in user.credentials],
|
for c in user.credentials],
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
"""Helpers to resolve client ID/secret."""
|
"""Helpers to resolve client ID/secret."""
|
||||||
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
@@ -9,6 +10,8 @@ from aiohttp.client_exceptions import ClientError
|
|||||||
|
|
||||||
from homeassistant.util.network import is_local
|
from homeassistant.util.network import is_local
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def verify_redirect_uri(hass, client_id, redirect_uri):
|
async def verify_redirect_uri(hass, client_id, redirect_uri):
|
||||||
"""Verify that the client and redirect uri match."""
|
"""Verify that the client and redirect uri match."""
|
||||||
@@ -78,7 +81,8 @@ async def fetch_redirect_uris(hass, url):
|
|||||||
if chunks == 10:
|
if chunks == 10:
|
||||||
break
|
break
|
||||||
|
|
||||||
except (asyncio.TimeoutError, ClientError):
|
except (asyncio.TimeoutError, ClientError) as ex:
|
||||||
|
_LOGGER.error("Error while looking up redirect_uri %s: %s", url, ex)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Authorization endpoints verifying that a redirect_uri is allowed for use
|
# Authorization endpoints verifying that a redirect_uri is allowed for use
|
||||||
|
@@ -7,7 +7,7 @@ import logging
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.setup import async_prepare_setup_platform
|
from homeassistant.setup import async_prepare_setup_platform
|
||||||
from homeassistant.core import CoreState
|
from homeassistant.core import CoreState, Context
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||||
@@ -120,7 +120,7 @@ async def async_setup(hass, config):
|
|||||||
async def trigger_service_handler(service_call):
|
async def trigger_service_handler(service_call):
|
||||||
"""Handle automation triggers."""
|
"""Handle automation triggers."""
|
||||||
tasks = []
|
tasks = []
|
||||||
for entity in component.async_extract_from_service(service_call):
|
for entity in await component.async_extract_from_service(service_call):
|
||||||
tasks.append(entity.async_trigger(
|
tasks.append(entity.async_trigger(
|
||||||
service_call.data.get(ATTR_VARIABLES),
|
service_call.data.get(ATTR_VARIABLES),
|
||||||
skip_condition=True,
|
skip_condition=True,
|
||||||
@@ -133,7 +133,7 @@ async def async_setup(hass, config):
|
|||||||
"""Handle automation turn on/off service calls."""
|
"""Handle automation turn on/off service calls."""
|
||||||
tasks = []
|
tasks = []
|
||||||
method = 'async_{}'.format(service_call.service)
|
method = 'async_{}'.format(service_call.service)
|
||||||
for entity in component.async_extract_from_service(service_call):
|
for entity in await component.async_extract_from_service(service_call):
|
||||||
tasks.append(getattr(entity, method)())
|
tasks.append(getattr(entity, method)())
|
||||||
|
|
||||||
if tasks:
|
if tasks:
|
||||||
@@ -142,7 +142,7 @@ async def async_setup(hass, config):
|
|||||||
async def toggle_service_handler(service_call):
|
async def toggle_service_handler(service_call):
|
||||||
"""Handle automation toggle service calls."""
|
"""Handle automation toggle service calls."""
|
||||||
tasks = []
|
tasks = []
|
||||||
for entity in component.async_extract_from_service(service_call):
|
for entity in await component.async_extract_from_service(service_call):
|
||||||
if entity.is_on:
|
if entity.is_on:
|
||||||
tasks.append(entity.async_turn_off())
|
tasks.append(entity.async_turn_off())
|
||||||
else:
|
else:
|
||||||
@@ -280,15 +280,21 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
|
|||||||
|
|
||||||
This method is a coroutine.
|
This method is a coroutine.
|
||||||
"""
|
"""
|
||||||
if skip_condition or self._cond_func(variables):
|
if not skip_condition and not self._cond_func(variables):
|
||||||
self.async_set_context(context)
|
return
|
||||||
self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, {
|
|
||||||
ATTR_NAME: self._name,
|
# Create a new context referring to the old context.
|
||||||
ATTR_ENTITY_ID: self.entity_id,
|
parent_id = None if context is None else context.id
|
||||||
}, context=context)
|
trigger_context = Context(parent_id=parent_id)
|
||||||
await self._async_action(self.entity_id, variables, context)
|
|
||||||
self._last_triggered = utcnow()
|
self.async_set_context(trigger_context)
|
||||||
await self.async_update_ha_state()
|
self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, {
|
||||||
|
ATTR_NAME: self._name,
|
||||||
|
ATTR_ENTITY_ID: self.entity_id,
|
||||||
|
}, context=trigger_context)
|
||||||
|
await self._async_action(self.entity_id, variables, trigger_context)
|
||||||
|
self._last_triggered = utcnow()
|
||||||
|
await self.async_update_ha_state()
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self):
|
async def async_will_remove_from_hass(self):
|
||||||
"""Remove listeners when removing automation from HASS."""
|
"""Remove listeners when removing automation from HASS."""
|
||||||
|
@@ -29,18 +29,18 @@ async def async_trigger(hass, config, action, automation_info):
|
|||||||
encoding = config[CONF_ENCODING] or None
|
encoding = config[CONF_ENCODING] or None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def mqtt_automation_listener(msg_topic, msg_payload, qos):
|
def mqtt_automation_listener(mqttmsg):
|
||||||
"""Listen for MQTT messages."""
|
"""Listen for MQTT messages."""
|
||||||
if payload is None or payload == msg_payload:
|
if payload is None or payload == mqttmsg.payload:
|
||||||
data = {
|
data = {
|
||||||
'platform': 'mqtt',
|
'platform': 'mqtt',
|
||||||
'topic': msg_topic,
|
'topic': mqttmsg.topic,
|
||||||
'payload': msg_payload,
|
'payload': mqttmsg.payload,
|
||||||
'qos': qos,
|
'qos': mqttmsg.qos,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data['payload_json'] = json.loads(msg_payload)
|
data['payload_json'] = json.loads(mqttmsg.payload)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@@ -17,7 +17,7 @@ from homeassistant.helpers.entity import generate_entity_id
|
|||||||
from homeassistant.helpers.event import async_track_state_change
|
from homeassistant.helpers.event import async_track_state_change
|
||||||
from homeassistant.util import utcnow
|
from homeassistant.util import utcnow
|
||||||
|
|
||||||
REQUIREMENTS = ['numpy==1.16.1']
|
REQUIREMENTS = ['numpy==1.16.2']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@@ -42,6 +42,7 @@ CONF_PROVINCE = 'province'
|
|||||||
CONF_WORKDAYS = 'workdays'
|
CONF_WORKDAYS = 'workdays'
|
||||||
CONF_EXCLUDES = 'excludes'
|
CONF_EXCLUDES = 'excludes'
|
||||||
CONF_OFFSET = 'days_offset'
|
CONF_OFFSET = 'days_offset'
|
||||||
|
CONF_ADD_HOLIDAYS = 'add_holidays'
|
||||||
|
|
||||||
# By default, Monday - Friday are workdays
|
# By default, Monday - Friday are workdays
|
||||||
DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri']
|
DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri']
|
||||||
@@ -59,6 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
|||||||
vol.Optional(CONF_PROVINCE): cv.string,
|
vol.Optional(CONF_PROVINCE): cv.string,
|
||||||
vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS):
|
vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS):
|
||||||
vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]),
|
vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]),
|
||||||
|
vol.Optional(CONF_ADD_HOLIDAYS): vol.All(cv.ensure_list, [cv.string]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
|||||||
workdays = config.get(CONF_WORKDAYS)
|
workdays = config.get(CONF_WORKDAYS)
|
||||||
excludes = config.get(CONF_EXCLUDES)
|
excludes = config.get(CONF_EXCLUDES)
|
||||||
days_offset = config.get(CONF_OFFSET)
|
days_offset = config.get(CONF_OFFSET)
|
||||||
|
add_holidays = config.get(CONF_ADD_HOLIDAYS)
|
||||||
|
|
||||||
year = (get_date(datetime.today()) + timedelta(days=days_offset)).year
|
year = (get_date(datetime.today()) + timedelta(days=days_offset)).year
|
||||||
obj_holidays = getattr(holidays, country)(years=year)
|
obj_holidays = getattr(holidays, country)(years=year)
|
||||||
@@ -92,6 +95,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
|||||||
province, country)
|
province, country)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Add custom holidays
|
||||||
|
try:
|
||||||
|
obj_holidays.append(add_holidays)
|
||||||
|
except TypeError:
|
||||||
|
_LOGGER.debug("No custom holidays or invalid holidays")
|
||||||
|
|
||||||
_LOGGER.debug("Found the following holidays for your configuration:")
|
_LOGGER.debug("Found the following holidays for your configuration:")
|
||||||
for date, name in sorted(obj_holidays.items()):
|
for date, name in sorted(obj_holidays.items()):
|
||||||
_LOGGER.debug("%s %s", date, name)
|
_LOGGER.debug("%s %s", date, name)
|
||||||
|
@@ -28,6 +28,12 @@ from homeassistant.helpers.entity_component import EntityComponent
|
|||||||
from homeassistant.helpers.config_validation import ( # noqa
|
from homeassistant.helpers.config_validation import ( # noqa
|
||||||
PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
|
PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
|
||||||
from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
|
from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
|
||||||
|
from homeassistant.components.media_player.const import (
|
||||||
|
ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
|
||||||
|
SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP)
|
||||||
|
from homeassistant.components.stream import request_stream
|
||||||
|
from homeassistant.components.stream.const import (
|
||||||
|
OUTPUT_FORMATS, FORMAT_CONTENT_TYPE)
|
||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
@@ -39,11 +45,14 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
SERVICE_ENABLE_MOTION = 'enable_motion_detection'
|
SERVICE_ENABLE_MOTION = 'enable_motion_detection'
|
||||||
SERVICE_DISABLE_MOTION = 'disable_motion_detection'
|
SERVICE_DISABLE_MOTION = 'disable_motion_detection'
|
||||||
SERVICE_SNAPSHOT = 'snapshot'
|
SERVICE_SNAPSHOT = 'snapshot'
|
||||||
|
SERVICE_PLAY_STREAM = 'play_stream'
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||||
|
|
||||||
ATTR_FILENAME = 'filename'
|
ATTR_FILENAME = 'filename'
|
||||||
|
ATTR_MEDIA_PLAYER = 'media_player'
|
||||||
|
ATTR_FORMAT = 'format'
|
||||||
|
|
||||||
STATE_RECORDING = 'recording'
|
STATE_RECORDING = 'recording'
|
||||||
STATE_STREAMING = 'streaming'
|
STATE_STREAMING = 'streaming'
|
||||||
@@ -69,6 +78,11 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
|
|||||||
vol.Required(ATTR_FILENAME): cv.template
|
vol.Required(ATTR_FILENAME): cv.template
|
||||||
})
|
})
|
||||||
|
|
||||||
|
CAMERA_SERVICE_PLAY_STREAM = CAMERA_SERVICE_SCHEMA.extend({
|
||||||
|
vol.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(DOMAIN_MP),
|
||||||
|
vol.Optional(ATTR_FORMAT, default='hls'): vol.In(OUTPUT_FORMATS),
|
||||||
|
})
|
||||||
|
|
||||||
WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail'
|
WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail'
|
||||||
SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||||
vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL,
|
vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL,
|
||||||
@@ -176,6 +190,7 @@ async def async_setup(hass, config):
|
|||||||
WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail,
|
WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail,
|
||||||
SCHEMA_WS_CAMERA_THUMBNAIL
|
SCHEMA_WS_CAMERA_THUMBNAIL
|
||||||
)
|
)
|
||||||
|
hass.components.websocket_api.async_register_command(ws_camera_stream)
|
||||||
|
|
||||||
await component.async_setup(config)
|
await component.async_setup(config)
|
||||||
|
|
||||||
@@ -209,6 +224,10 @@ async def async_setup(hass, config):
|
|||||||
SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT,
|
SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT,
|
||||||
async_handle_snapshot_service
|
async_handle_snapshot_service
|
||||||
)
|
)
|
||||||
|
component.async_register_entity_service(
|
||||||
|
SERVICE_PLAY_STREAM, CAMERA_SERVICE_PLAY_STREAM,
|
||||||
|
async_handle_play_stream_service
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -273,6 +292,11 @@ class Camera(Entity):
|
|||||||
"""Return the interval between frames of the mjpeg stream."""
|
"""Return the interval between frames of the mjpeg stream."""
|
||||||
return 0.5
|
return 0.5
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_source(self):
|
||||||
|
"""Return the source of the stream."""
|
||||||
|
return None
|
||||||
|
|
||||||
def camera_image(self):
|
def camera_image(self):
|
||||||
"""Return bytes of camera image."""
|
"""Return bytes of camera image."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
@@ -473,6 +497,33 @@ async def websocket_camera_thumbnail(hass, connection, msg):
|
|||||||
msg['id'], 'image_fetch_failed', 'Unable to fetch image'))
|
msg['id'], 'image_fetch_failed', 'Unable to fetch image'))
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.async_response
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
vol.Required('type'): 'camera/stream',
|
||||||
|
vol.Required('entity_id'): cv.entity_id,
|
||||||
|
vol.Optional('format', default='hls'): vol.In(OUTPUT_FORMATS),
|
||||||
|
})
|
||||||
|
async def ws_camera_stream(hass, connection, msg):
|
||||||
|
"""Handle get camera stream websocket command.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
camera = _get_camera_from_entity_id(hass, msg['entity_id'])
|
||||||
|
|
||||||
|
if not camera.stream_source:
|
||||||
|
raise HomeAssistantError("{} does not support play stream service"
|
||||||
|
.format(camera.entity_id))
|
||||||
|
|
||||||
|
fmt = msg['format']
|
||||||
|
url = request_stream(hass, camera.stream_source, fmt=fmt)
|
||||||
|
connection.send_result(msg['id'], {'url': url})
|
||||||
|
except HomeAssistantError as ex:
|
||||||
|
_LOGGER.error(ex)
|
||||||
|
connection.send_error(
|
||||||
|
msg['id'], 'start_stream_failed', str(ex))
|
||||||
|
|
||||||
|
|
||||||
async def async_handle_snapshot_service(camera, service):
|
async def async_handle_snapshot_service(camera, service):
|
||||||
"""Handle snapshot services calls."""
|
"""Handle snapshot services calls."""
|
||||||
hass = camera.hass
|
hass = camera.hass
|
||||||
@@ -500,3 +551,25 @@ async def async_handle_snapshot_service(camera, service):
|
|||||||
_write_image, snapshot_file, image)
|
_write_image, snapshot_file, image)
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
_LOGGER.error("Can't write image to file: %s", err)
|
_LOGGER.error("Can't write image to file: %s", err)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_handle_play_stream_service(camera, service_call):
|
||||||
|
"""Handle play stream services calls."""
|
||||||
|
if not camera.stream_source:
|
||||||
|
raise HomeAssistantError("{} does not support play stream service"
|
||||||
|
.format(camera.entity_id))
|
||||||
|
|
||||||
|
hass = camera.hass
|
||||||
|
fmt = service_call.data[ATTR_FORMAT]
|
||||||
|
entity_ids = service_call.data[ATTR_MEDIA_PLAYER]
|
||||||
|
|
||||||
|
url = request_stream(hass, camera.stream_source, fmt=fmt)
|
||||||
|
data = {
|
||||||
|
ATTR_ENTITY_ID: entity_ids,
|
||||||
|
ATTR_MEDIA_CONTENT_ID: "{}{}".format(hass.config.api.base_url, url),
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt]
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN_MP, SERVICE_PLAY_MEDIA, data,
|
||||||
|
blocking=True, context=service_call.context)
|
||||||
|
@@ -28,12 +28,14 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
CONF_CONTENT_TYPE = 'content_type'
|
CONF_CONTENT_TYPE = 'content_type'
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change'
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change'
|
||||||
CONF_STILL_IMAGE_URL = 'still_image_url'
|
CONF_STILL_IMAGE_URL = 'still_image_url'
|
||||||
|
CONF_STREAM_SOURCE = 'stream_source'
|
||||||
CONF_FRAMERATE = 'framerate'
|
CONF_FRAMERATE = 'framerate'
|
||||||
|
|
||||||
DEFAULT_NAME = 'Generic Camera'
|
DEFAULT_NAME = 'Generic Camera'
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
vol.Required(CONF_STILL_IMAGE_URL): cv.template,
|
vol.Required(CONF_STILL_IMAGE_URL): cv.template,
|
||||||
|
vol.Optional(CONF_STREAM_SOURCE, default=None): vol.Any(None, cv.string),
|
||||||
vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION):
|
vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION):
|
||||||
vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
|
vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
|
||||||
vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean,
|
vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean,
|
||||||
@@ -62,6 +64,7 @@ class GenericCamera(Camera):
|
|||||||
self._authentication = device_info.get(CONF_AUTHENTICATION)
|
self._authentication = device_info.get(CONF_AUTHENTICATION)
|
||||||
self._name = device_info.get(CONF_NAME)
|
self._name = device_info.get(CONF_NAME)
|
||||||
self._still_image_url = device_info[CONF_STILL_IMAGE_URL]
|
self._still_image_url = device_info[CONF_STILL_IMAGE_URL]
|
||||||
|
self._stream_source = device_info[CONF_STREAM_SOURCE]
|
||||||
self._still_image_url.hass = hass
|
self._still_image_url.hass = hass
|
||||||
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
||||||
self._frame_interval = 1 / device_info[CONF_FRAMERATE]
|
self._frame_interval = 1 / device_info[CONF_FRAMERATE]
|
||||||
@@ -128,7 +131,7 @@ class GenericCamera(Camera):
|
|||||||
url, auth=self._auth)
|
url, auth=self._auth)
|
||||||
self._last_image = await response.read()
|
self._last_image = await response.read()
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
_LOGGER.error("Timeout getting camera image")
|
_LOGGER.error("Timeout getting image from: %s", self._name)
|
||||||
return self._last_image
|
return self._last_image
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error("Error getting new camera image: %s", err)
|
_LOGGER.error("Error getting new camera image: %s", err)
|
||||||
@@ -141,3 +144,8 @@ class GenericCamera(Camera):
|
|||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of this device."""
|
"""Return the name of this device."""
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_source(self):
|
||||||
|
"""Return the source of the stream."""
|
||||||
|
return self._stream_source
|
||||||
|
@@ -11,13 +11,11 @@ from datetime import timedelta
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
|
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
|
||||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \
|
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE
|
||||||
HTTP_HEADER_HA_AUTH
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.util.async_ import run_coroutine_threadsafe
|
from homeassistant.util.async_ import run_coroutine_threadsafe
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
from homeassistant.components.camera import async_get_still_stream
|
|
||||||
|
|
||||||
REQUIREMENTS = ['pillow==5.4.1']
|
REQUIREMENTS = ['pillow==5.4.1']
|
||||||
|
|
||||||
@@ -209,9 +207,6 @@ class ProxyCamera(Camera):
|
|||||||
or config.get(CONF_CACHE_IMAGES))
|
or config.get(CONF_CACHE_IMAGES))
|
||||||
self._last_image_time = dt_util.utc_from_timestamp(0)
|
self._last_image_time = dt_util.utc_from_timestamp(0)
|
||||||
self._last_image = None
|
self._last_image = None
|
||||||
self._headers = (
|
|
||||||
{HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password}
|
|
||||||
if self.hass.config.api.api_password is not None else None)
|
|
||||||
self._mode = config.get(CONF_MODE)
|
self._mode = config.get(CONF_MODE)
|
||||||
|
|
||||||
def camera_image(self):
|
def camera_image(self):
|
||||||
@@ -252,7 +247,7 @@ class ProxyCamera(Camera):
|
|||||||
return await self.hass.components.camera.async_get_mjpeg_stream(
|
return await self.hass.components.camera.async_get_mjpeg_stream(
|
||||||
request, self._proxied_camera)
|
request, self._proxied_camera)
|
||||||
|
|
||||||
return await async_get_still_stream(
|
return await self.hass.components.camera.async_get_still_stream(
|
||||||
request, self._async_stream_image,
|
request, self._async_stream_image,
|
||||||
self.content_type, self.frame_interval)
|
self.content_type, self.frame_interval)
|
||||||
|
|
||||||
|
@@ -38,6 +38,19 @@ snapshot:
|
|||||||
description: Template of a Filename. Variable is entity_id.
|
description: Template of a Filename. Variable is entity_id.
|
||||||
example: '/tmp/snapshot_{{ entity_id }}'
|
example: '/tmp/snapshot_{{ entity_id }}'
|
||||||
|
|
||||||
|
play_stream:
|
||||||
|
description: Play camera stream on supported media player.
|
||||||
|
fields:
|
||||||
|
entity_id:
|
||||||
|
description: Name(s) of entities to stream from.
|
||||||
|
example: 'camera.living_room_camera'
|
||||||
|
media_player:
|
||||||
|
description: Name(s) of media player to stream to.
|
||||||
|
example: 'media_player.living_room_tv'
|
||||||
|
format:
|
||||||
|
description: (Optional) Stream format supported by media player.
|
||||||
|
example: 'hls'
|
||||||
|
|
||||||
local_file_update_file_path:
|
local_file_update_file_path:
|
||||||
description: Update the file_path for a local_file camera.
|
description: Update the file_path for a local_file camera.
|
||||||
fields:
|
fields:
|
||||||
|
@@ -13,7 +13,7 @@ from homeassistant.const import (
|
|||||||
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
|
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
REQUIREMENTS = ['pyxeoma==1.4.0']
|
REQUIREMENTS = ['pyxeoma==1.4.1']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.helpers import config_entry_flow
|
from homeassistant.helpers import config_entry_flow
|
||||||
|
|
||||||
REQUIREMENTS = ['pychromecast==2.5.2']
|
REQUIREMENTS = ['pychromecast==3.0.0']
|
||||||
|
|
||||||
DOMAIN = 'cast'
|
DOMAIN = 'cast'
|
||||||
|
|
||||||
|
@@ -257,6 +257,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
|||||||
_async_setup_platform(hass, cfg, async_add_entities, None)
|
_async_setup_platform(hass, cfg, async_add_entities, None)
|
||||||
for cfg in config])
|
for cfg in config])
|
||||||
if any([task.exception() for task in done]):
|
if any([task.exception() for task in done]):
|
||||||
|
exceptions = [task.exception() for task in done]
|
||||||
|
for exception in exceptions:
|
||||||
|
_LOGGER.debug("Failed to setup chromecast", exc_info=exception)
|
||||||
raise PlatformNotReady
|
raise PlatformNotReady
|
||||||
|
|
||||||
|
|
||||||
@@ -289,7 +292,7 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
|||||||
if cast_device is not None:
|
if cast_device is not None:
|
||||||
async_add_entities([cast_device])
|
async_add_entities([cast_device])
|
||||||
|
|
||||||
remove_handler = async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered)
|
hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered)
|
||||||
# Re-play the callback for all past chromecasts, store the objects in
|
# Re-play the callback for all past chromecasts, store the objects in
|
||||||
# a list to avoid concurrent modification resulting in exception.
|
# a list to avoid concurrent modification resulting in exception.
|
||||||
@@ -306,8 +309,6 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
|||||||
if info.friendly_name is None:
|
if info.friendly_name is None:
|
||||||
_LOGGER.debug("Cannot retrieve detail information for chromecast"
|
_LOGGER.debug("Cannot retrieve detail information for chromecast"
|
||||||
" %s, the device may not be online", info)
|
" %s, the device may not be online", info)
|
||||||
remove_handler()
|
|
||||||
raise PlatformNotReady
|
|
||||||
|
|
||||||
hass.async_add_job(_discover_chromecast, hass, info)
|
hass.async_add_job(_discover_chromecast, hass, info)
|
||||||
|
|
||||||
@@ -477,16 +478,10 @@ class CastDevice(MediaPlayerDevice):
|
|||||||
))
|
))
|
||||||
self._chromecast = chromecast
|
self._chromecast = chromecast
|
||||||
self._status_listener = CastStatusListener(self, chromecast)
|
self._status_listener = CastStatusListener(self, chromecast)
|
||||||
# Initialise connection status as connected because we can only
|
self._available = False
|
||||||
# register the connection listener *after* the initial connection
|
|
||||||
# attempt. If the initial connection failed, we would never reach
|
|
||||||
# this code anyway.
|
|
||||||
self._available = True
|
|
||||||
self.cast_status = chromecast.status
|
self.cast_status = chromecast.status
|
||||||
self.media_status = chromecast.media_controller.status
|
self.media_status = chromecast.media_controller.status
|
||||||
_LOGGER.debug("[%s %s (%s:%s)] Connection successful!",
|
self._chromecast.start()
|
||||||
self.entity_id, self._cast_info.friendly_name,
|
|
||||||
self._cast_info.host, self._cast_info.port)
|
|
||||||
self.async_schedule_update_ha_state()
|
self.async_schedule_update_ha_state()
|
||||||
|
|
||||||
async def async_del_cast_info(self, cast_info):
|
async def async_del_cast_info(self, cast_info):
|
||||||
@@ -562,6 +557,10 @@ class CastDevice(MediaPlayerDevice):
|
|||||||
self.entity_id, self._cast_info.friendly_name,
|
self.entity_id, self._cast_info.friendly_name,
|
||||||
self._cast_info.host, self._cast_info.port,
|
self._cast_info.host, self._cast_info.port,
|
||||||
connection_status.status)
|
connection_status.status)
|
||||||
|
info = self._cast_info
|
||||||
|
if info.friendly_name is None and not info.is_audio_group:
|
||||||
|
# We couldn't find friendly_name when the cast was added, retry
|
||||||
|
self._cast_info = _fill_out_missing_chromecast_info(info)
|
||||||
self._available = new_available
|
self._available = new_available
|
||||||
self.schedule_update_ha_state()
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
|
@@ -0,0 +1 @@
|
|||||||
|
"""Component to embed Cisco Mobility Express."""
|
@@ -0,0 +1,83 @@
|
|||||||
|
"""Support for Cisco Mobility Express."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.components.device_tracker import (
|
||||||
|
DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL)
|
||||||
|
|
||||||
|
|
||||||
|
REQUIREMENTS = ['ciscomobilityexpress==0.1.2']
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SSL = False
|
||||||
|
DEFAULT_VERIFY_SSL = True
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
|
vol.Required(CONF_HOST): cv.string,
|
||||||
|
vol.Required(CONF_USERNAME): cv.string,
|
||||||
|
vol.Required(CONF_PASSWORD): cv.string,
|
||||||
|
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
||||||
|
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_scanner(hass, config):
|
||||||
|
"""Validate the configuration and return a Cisco ME scanner."""
|
||||||
|
from ciscomobilityexpress.ciscome import CiscoMobilityExpress
|
||||||
|
config = config[DOMAIN]
|
||||||
|
|
||||||
|
controller = CiscoMobilityExpress(
|
||||||
|
config[CONF_HOST],
|
||||||
|
config[CONF_USERNAME],
|
||||||
|
config[CONF_PASSWORD],
|
||||||
|
config.get(CONF_SSL),
|
||||||
|
config.get(CONF_VERIFY_SSL))
|
||||||
|
if not controller.is_logged_in():
|
||||||
|
return None
|
||||||
|
return CiscoMEDeviceScanner(controller)
|
||||||
|
|
||||||
|
|
||||||
|
class CiscoMEDeviceScanner(DeviceScanner):
|
||||||
|
"""This class scans for devices associated to a Cisco ME controller."""
|
||||||
|
|
||||||
|
def __init__(self, controller):
|
||||||
|
"""Initialize the scanner."""
|
||||||
|
self.controller = controller
|
||||||
|
self.last_results = {}
|
||||||
|
|
||||||
|
def scan_devices(self):
|
||||||
|
"""Scan for new devices and return a list with found device IDs."""
|
||||||
|
self._update_info()
|
||||||
|
|
||||||
|
return [device.macaddr for device in self.last_results]
|
||||||
|
|
||||||
|
def get_device_name(self, device):
|
||||||
|
"""Return the name of the given device or None if we don't know."""
|
||||||
|
name = next((
|
||||||
|
result.clId for result in self.last_results
|
||||||
|
if result.macaddr == device), None)
|
||||||
|
return name
|
||||||
|
|
||||||
|
def get_extra_attributes(self, device):
|
||||||
|
"""
|
||||||
|
Get extra attributes of a device.
|
||||||
|
|
||||||
|
Some known extra attributes that may be returned in the device tuple
|
||||||
|
include SSID, PT (eg 802.11ac), devtype (eg iPhone 7) among others.
|
||||||
|
"""
|
||||||
|
device = next((
|
||||||
|
result for result in self.last_results
|
||||||
|
if result.macaddr == device), None)
|
||||||
|
return device._asdict()
|
||||||
|
|
||||||
|
def _update_info(self):
|
||||||
|
"""Check the Cisco ME controller for devices."""
|
||||||
|
self.last_results = self.controller.get_associated_devices()
|
||||||
|
_LOGGER.debug("Cisco Mobility Express controller returned:"
|
||||||
|
" %s", self.last_results)
|
@@ -1,4 +1,4 @@
|
|||||||
"""Proides the constants needed for component."""
|
"""Provides the constants needed for component."""
|
||||||
|
|
||||||
ATTR_AUX_HEAT = 'aux_heat'
|
ATTR_AUX_HEAT = 'aux_heat'
|
||||||
ATTR_AWAY_MODE = 'away_mode'
|
ATTR_AWAY_MODE = 'away_mode'
|
||||||
|
@@ -117,7 +117,8 @@ class EphEmberThermostat(ClimateDevice):
|
|||||||
@property
|
@property
|
||||||
def current_operation(self):
|
def current_operation(self):
|
||||||
"""Return current operation ie. heat, cool, idle."""
|
"""Return current operation ie. heat, cool, idle."""
|
||||||
mode = self._ember.get_zone_mode(self._zone_name)
|
from pyephember.pyephember import ZoneMode
|
||||||
|
mode = ZoneMode(self._zone['mode'])
|
||||||
return self.map_mode_eph_hass(mode)
|
return self.map_mode_eph_hass(mode)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@@ -184,7 +184,8 @@ class EQ3BTSmartThermostat(ClimateDevice):
|
|||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Update the data from the thermostat."""
|
"""Update the data from the thermostat."""
|
||||||
from bluepy.btle import BTLEException # pylint: disable=import-error
|
# pylint: disable=import-error,no-name-in-module
|
||||||
|
from bluepy.btle import BTLEException
|
||||||
try:
|
try:
|
||||||
self._thermostat.update()
|
self._thermostat.update()
|
||||||
except BTLEException as ex:
|
except BTLEException as ex:
|
||||||
|
@@ -273,6 +273,11 @@ class HoneywellUSThermostat(ClimateDevice):
|
|||||||
"""Return the current temperature."""
|
"""Return the current temperature."""
|
||||||
return self._device.current_temperature
|
return self._device.current_temperature
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_humidity(self):
|
||||||
|
"""Return the current humidity."""
|
||||||
|
return self._device.current_humidity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target_temperature(self):
|
def target_temperature(self):
|
||||||
"""Return the temperature we try to reach."""
|
"""Return the temperature we try to reach."""
|
||||||
|
@@ -1,43 +1,39 @@
|
|||||||
"""Component to integrate the Home Assistant cloud."""
|
"""Component to integrate the Home Assistant cloud."""
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||||
EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION,
|
|
||||||
CONF_MODE, CONF_NAME)
|
|
||||||
from homeassistant.helpers import entityfilter, config_validation as cv
|
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||||
from homeassistant.components.google_assistant import helpers as ga_h
|
|
||||||
from homeassistant.components.google_assistant import const as ga_c
|
from homeassistant.components.google_assistant import const as ga_c
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START,
|
||||||
|
EVENT_HOMEASSISTANT_STOP)
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_validation as cv, entityfilter
|
||||||
|
from homeassistant.loader import bind_hass
|
||||||
|
from homeassistant.util.aiohttp import MockRequest
|
||||||
|
|
||||||
from . import http_api, iot, auth_api, prefs, cloudhooks
|
from . import http_api
|
||||||
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
from .const import (
|
||||||
|
CONF_ACME_DIRECTORY_SERVER, CONF_ALEXA, CONF_ALIASES,
|
||||||
|
CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG,
|
||||||
|
CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
|
||||||
|
CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL,
|
||||||
|
CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD)
|
||||||
|
from .prefs import CloudPreferences
|
||||||
|
|
||||||
REQUIREMENTS = ['warrant==0.6.1']
|
REQUIREMENTS = ['hass-nabucasa==0.8']
|
||||||
|
DEPENDENCIES = ['http']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONF_ALEXA = 'alexa'
|
DEFAULT_MODE = MODE_PROD
|
||||||
CONF_ALIASES = 'aliases'
|
|
||||||
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
|
|
||||||
CONF_ENTITY_CONFIG = 'entity_config'
|
|
||||||
CONF_FILTER = 'filter'
|
|
||||||
CONF_GOOGLE_ACTIONS = 'google_actions'
|
|
||||||
CONF_RELAYER = 'relayer'
|
|
||||||
CONF_USER_POOL_ID = 'user_pool_id'
|
|
||||||
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
|
|
||||||
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
|
||||||
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
|
||||||
|
|
||||||
DEFAULT_MODE = 'production'
|
SERVICE_REMOTE_CONNECT = 'remote_connect'
|
||||||
DEPENDENCIES = ['http']
|
SERVICE_REMOTE_DISCONNECT = 'remote_disconnect'
|
||||||
|
|
||||||
MODE_DEV = 'development'
|
|
||||||
|
|
||||||
ALEXA_ENTITY_SCHEMA = vol.Schema({
|
ALEXA_ENTITY_SCHEMA = vol.Schema({
|
||||||
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
|
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
|
||||||
@@ -52,7 +48,7 @@ GOOGLE_ENTITY_SCHEMA = vol.Schema({
|
|||||||
})
|
})
|
||||||
|
|
||||||
ASSISTANT_SCHEMA = vol.Schema({
|
ASSISTANT_SCHEMA = vol.Schema({
|
||||||
vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
|
vol.Optional(CONF_FILTER, default=dict): entityfilter.FILTER_SCHEMA,
|
||||||
})
|
})
|
||||||
|
|
||||||
ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
|
ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
|
||||||
@@ -63,205 +59,140 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
|
|||||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
|
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
CONFIG_SCHEMA = vol.Schema({
|
CONFIG_SCHEMA = vol.Schema({
|
||||||
DOMAIN: vol.Schema({
|
DOMAIN: vol.Schema({
|
||||||
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
|
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
|
||||||
vol.In([MODE_DEV] + list(SERVERS)),
|
vol.In([MODE_DEV, MODE_PROD]),
|
||||||
# Change to optional when we include real servers
|
# Change to optional when we include real servers
|
||||||
vol.Optional(CONF_COGNITO_CLIENT_ID): str,
|
vol.Optional(CONF_COGNITO_CLIENT_ID): str,
|
||||||
vol.Optional(CONF_USER_POOL_ID): str,
|
vol.Optional(CONF_USER_POOL_ID): str,
|
||||||
vol.Optional(CONF_REGION): str,
|
vol.Optional(CONF_REGION): str,
|
||||||
vol.Optional(CONF_RELAYER): str,
|
vol.Optional(CONF_RELAYER): str,
|
||||||
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str,
|
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(),
|
||||||
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str,
|
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(),
|
||||||
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str,
|
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): vol.Url(),
|
||||||
|
vol.Optional(CONF_REMOTE_API_URL): vol.Url(),
|
||||||
|
vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
|
||||||
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
|
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
|
||||||
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
||||||
}),
|
}),
|
||||||
}, extra=vol.ALLOW_EXTRA)
|
}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
|
||||||
|
class CloudNotAvailable(HomeAssistantError):
|
||||||
|
"""Raised when an action requires the cloud but it's not available."""
|
||||||
|
|
||||||
|
|
||||||
|
@bind_hass
|
||||||
|
@callback
|
||||||
|
def async_is_logged_in(hass) -> bool:
|
||||||
|
"""Test if user is logged in."""
|
||||||
|
return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in
|
||||||
|
|
||||||
|
|
||||||
|
@bind_hass
|
||||||
|
@callback
|
||||||
|
def async_active_subscription(hass) -> bool:
|
||||||
|
"""Test if user has an active subscription."""
|
||||||
|
return \
|
||||||
|
async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired
|
||||||
|
|
||||||
|
|
||||||
|
@bind_hass
|
||||||
|
async def async_create_cloudhook(hass, webhook_id: str) -> str:
|
||||||
|
"""Create a cloudhook."""
|
||||||
|
if not async_is_logged_in(hass):
|
||||||
|
raise CloudNotAvailable
|
||||||
|
|
||||||
|
hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True)
|
||||||
|
return hook['cloudhook_url']
|
||||||
|
|
||||||
|
|
||||||
|
@bind_hass
|
||||||
|
async def async_delete_cloudhook(hass, webhook_id: str) -> None:
|
||||||
|
"""Delete a cloudhook."""
|
||||||
|
if DOMAIN not in hass.data:
|
||||||
|
raise CloudNotAvailable
|
||||||
|
|
||||||
|
await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bind_hass
|
||||||
|
@callback
|
||||||
|
def async_remote_ui_url(hass) -> str:
|
||||||
|
"""Get the remote UI URL."""
|
||||||
|
if not async_is_logged_in(hass):
|
||||||
|
raise CloudNotAvailable
|
||||||
|
|
||||||
|
return "https://" + hass.data[DOMAIN].remote.instance_domain
|
||||||
|
|
||||||
|
|
||||||
|
def is_cloudhook_request(request):
|
||||||
|
"""Test if a request came from a cloudhook.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
return isinstance(request, MockRequest)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass, config):
|
async def async_setup(hass, config):
|
||||||
"""Initialize the Home Assistant cloud."""
|
"""Initialize the Home Assistant cloud."""
|
||||||
|
from hass_nabucasa import Cloud
|
||||||
|
from .client import CloudClient
|
||||||
|
|
||||||
|
# Process configs
|
||||||
if DOMAIN in config:
|
if DOMAIN in config:
|
||||||
kwargs = dict(config[DOMAIN])
|
kwargs = dict(config[DOMAIN])
|
||||||
else:
|
else:
|
||||||
kwargs = {CONF_MODE: DEFAULT_MODE}
|
kwargs = {CONF_MODE: DEFAULT_MODE}
|
||||||
|
|
||||||
|
# Alexa/Google custom config
|
||||||
alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({})
|
alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({})
|
||||||
|
google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({})
|
||||||
|
|
||||||
if CONF_GOOGLE_ACTIONS not in kwargs:
|
# Cloud settings
|
||||||
kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({})
|
prefs = CloudPreferences(hass)
|
||||||
|
await prefs.async_initialize()
|
||||||
|
|
||||||
kwargs[CONF_ALEXA] = alexa_sh.Config(
|
# Cloud user
|
||||||
endpoint=None,
|
if not prefs.cloud_user:
|
||||||
async_get_access_token=None,
|
user = await hass.auth.async_create_system_user(
|
||||||
should_expose=alexa_conf[CONF_FILTER],
|
'Home Assistant Cloud', [GROUP_ID_ADMIN])
|
||||||
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
|
await prefs.async_update(cloud_user=user.id)
|
||||||
)
|
|
||||||
|
# Initialize Cloud
|
||||||
|
websession = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||||
|
client = CloudClient(hass, prefs, websession, alexa_conf, google_conf)
|
||||||
|
cloud = hass.data[DOMAIN] = Cloud(client, **kwargs)
|
||||||
|
|
||||||
|
async def _startup(event):
|
||||||
|
"""Startup event."""
|
||||||
|
await cloud.start()
|
||||||
|
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _startup)
|
||||||
|
|
||||||
|
async def _shutdown(event):
|
||||||
|
"""Shutdown event."""
|
||||||
|
await cloud.stop()
|
||||||
|
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||||
|
|
||||||
|
async def _service_handler(service):
|
||||||
|
"""Handle service for cloud."""
|
||||||
|
if service.service == SERVICE_REMOTE_CONNECT:
|
||||||
|
await cloud.remote.connect()
|
||||||
|
await prefs.async_update(remote_enabled=True)
|
||||||
|
elif service.service == SERVICE_REMOTE_DISCONNECT:
|
||||||
|
await cloud.remote.disconnect()
|
||||||
|
await prefs.async_update(remote_enabled=False)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler)
|
||||||
|
|
||||||
cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs)
|
|
||||||
await auth_api.async_setup(hass, cloud)
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start)
|
|
||||||
await http_api.async_setup(hass)
|
await http_api.async_setup(hass)
|
||||||
|
hass.async_create_task(hass.helpers.discovery.async_load_platform(
|
||||||
|
'binary_sensor', DOMAIN, {}, config))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Cloud:
|
|
||||||
"""Store the configuration of the cloud connection."""
|
|
||||||
|
|
||||||
def __init__(self, hass, mode, alexa, google_actions,
|
|
||||||
cognito_client_id=None, user_pool_id=None, region=None,
|
|
||||||
relayer=None, google_actions_sync_url=None,
|
|
||||||
subscription_info_url=None, cloudhook_create_url=None):
|
|
||||||
"""Create an instance of Cloud."""
|
|
||||||
self.hass = hass
|
|
||||||
self.mode = mode
|
|
||||||
self.alexa_config = alexa
|
|
||||||
self.google_actions_user_conf = google_actions
|
|
||||||
self._gactions_config = None
|
|
||||||
self.prefs = prefs.CloudPreferences(hass)
|
|
||||||
self.id_token = None
|
|
||||||
self.access_token = None
|
|
||||||
self.refresh_token = None
|
|
||||||
self.iot = iot.CloudIoT(self)
|
|
||||||
self.cloudhooks = cloudhooks.Cloudhooks(self)
|
|
||||||
|
|
||||||
if mode == MODE_DEV:
|
|
||||||
self.cognito_client_id = cognito_client_id
|
|
||||||
self.user_pool_id = user_pool_id
|
|
||||||
self.region = region
|
|
||||||
self.relayer = relayer
|
|
||||||
self.google_actions_sync_url = google_actions_sync_url
|
|
||||||
self.subscription_info_url = subscription_info_url
|
|
||||||
self.cloudhook_create_url = cloudhook_create_url
|
|
||||||
|
|
||||||
else:
|
|
||||||
info = SERVERS[mode]
|
|
||||||
|
|
||||||
self.cognito_client_id = info['cognito_client_id']
|
|
||||||
self.user_pool_id = info['user_pool_id']
|
|
||||||
self.region = info['region']
|
|
||||||
self.relayer = info['relayer']
|
|
||||||
self.google_actions_sync_url = info['google_actions_sync_url']
|
|
||||||
self.subscription_info_url = info['subscription_info_url']
|
|
||||||
self.cloudhook_create_url = info['cloudhook_create_url']
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_logged_in(self):
|
|
||||||
"""Get if cloud is logged in."""
|
|
||||||
return self.id_token is not None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def subscription_expired(self):
|
|
||||||
"""Return a boolean if the subscription has expired."""
|
|
||||||
return dt_util.utcnow() > self.expiration_date + timedelta(days=7)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def expiration_date(self):
|
|
||||||
"""Return the subscription expiration as a UTC datetime object."""
|
|
||||||
return datetime.combine(
|
|
||||||
dt_util.parse_date(self.claims['custom:sub-exp']),
|
|
||||||
datetime.min.time()).replace(tzinfo=dt_util.UTC)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def claims(self):
|
|
||||||
"""Return the claims from the id token."""
|
|
||||||
return self._decode_claims(self.id_token)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_info_path(self):
|
|
||||||
"""Get path to the stored auth."""
|
|
||||||
return self.path('{}_auth.json'.format(self.mode))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def gactions_config(self):
|
|
||||||
"""Return the Google Assistant config."""
|
|
||||||
if self._gactions_config is None:
|
|
||||||
conf = self.google_actions_user_conf
|
|
||||||
|
|
||||||
def should_expose(entity):
|
|
||||||
"""If an entity should be exposed."""
|
|
||||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return conf['filter'](entity.entity_id)
|
|
||||||
|
|
||||||
self._gactions_config = ga_h.Config(
|
|
||||||
should_expose=should_expose,
|
|
||||||
allow_unlock=self.prefs.google_allow_unlock,
|
|
||||||
agent_user_id=self.claims['cognito:username'],
|
|
||||||
entity_config=conf.get(CONF_ENTITY_CONFIG),
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._gactions_config
|
|
||||||
|
|
||||||
def path(self, *parts):
|
|
||||||
"""Get config path inside cloud dir.
|
|
||||||
|
|
||||||
Async friendly.
|
|
||||||
"""
|
|
||||||
return self.hass.config.path(CONFIG_DIR, *parts)
|
|
||||||
|
|
||||||
async def fetch_subscription_info(self):
|
|
||||||
"""Fetch subscription info."""
|
|
||||||
await self.hass.async_add_executor_job(auth_api.check_token, self)
|
|
||||||
websession = self.hass.helpers.aiohttp_client.async_get_clientsession()
|
|
||||||
return await websession.get(
|
|
||||||
self.subscription_info_url, headers={
|
|
||||||
'authorization': self.id_token
|
|
||||||
})
|
|
||||||
|
|
||||||
async def logout(self):
|
|
||||||
"""Close connection and remove all credentials."""
|
|
||||||
await self.iot.disconnect()
|
|
||||||
|
|
||||||
self.id_token = None
|
|
||||||
self.access_token = None
|
|
||||||
self.refresh_token = None
|
|
||||||
self._gactions_config = None
|
|
||||||
|
|
||||||
await self.hass.async_add_job(
|
|
||||||
lambda: os.remove(self.user_info_path))
|
|
||||||
|
|
||||||
def write_user_info(self):
|
|
||||||
"""Write user info to a file."""
|
|
||||||
with open(self.user_info_path, 'wt') as file:
|
|
||||||
file.write(json.dumps({
|
|
||||||
'id_token': self.id_token,
|
|
||||||
'access_token': self.access_token,
|
|
||||||
'refresh_token': self.refresh_token,
|
|
||||||
}, indent=4))
|
|
||||||
|
|
||||||
async def async_start(self, _):
|
|
||||||
"""Start the cloud component."""
|
|
||||||
def load_config():
|
|
||||||
"""Load config."""
|
|
||||||
# Ensure config dir exists
|
|
||||||
path = self.hass.config.path(CONFIG_DIR)
|
|
||||||
if not os.path.isdir(path):
|
|
||||||
os.mkdir(path)
|
|
||||||
|
|
||||||
user_info = self.user_info_path
|
|
||||||
if not os.path.isfile(user_info):
|
|
||||||
return None
|
|
||||||
|
|
||||||
with open(user_info, 'rt') as file:
|
|
||||||
return json.loads(file.read())
|
|
||||||
|
|
||||||
info = await self.hass.async_add_job(load_config)
|
|
||||||
await self.prefs.async_initialize()
|
|
||||||
|
|
||||||
if info is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.id_token = info['id_token']
|
|
||||||
self.access_token = info['access_token']
|
|
||||||
self.refresh_token = info['refresh_token']
|
|
||||||
|
|
||||||
self.hass.async_create_task(self.iot.connect())
|
|
||||||
|
|
||||||
def _decode_claims(self, token): # pylint: disable=no-self-use
|
|
||||||
"""Decode the claims in a token."""
|
|
||||||
from jose import jwt
|
|
||||||
return jwt.get_unverified_claims(token)
|
|
||||||
|
@@ -1,232 +0,0 @@
|
|||||||
"""Package to communicate with the authentication API."""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CloudError(Exception):
|
|
||||||
"""Base class for cloud related errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class Unauthenticated(CloudError):
|
|
||||||
"""Raised when authentication failed."""
|
|
||||||
|
|
||||||
|
|
||||||
class UserNotFound(CloudError):
|
|
||||||
"""Raised when a user is not found."""
|
|
||||||
|
|
||||||
|
|
||||||
class UserNotConfirmed(CloudError):
|
|
||||||
"""Raised when a user has not confirmed email yet."""
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordChangeRequired(CloudError):
|
|
||||||
"""Raised when a password change is required."""
|
|
||||||
|
|
||||||
# https://github.com/PyCQA/pylint/issues/1085
|
|
||||||
# pylint: disable=useless-super-delegation
|
|
||||||
def __init__(self, message='Password change required.'):
|
|
||||||
"""Initialize a password change required error."""
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownError(CloudError):
|
|
||||||
"""Raised when an unknown error occurs."""
|
|
||||||
|
|
||||||
|
|
||||||
AWS_EXCEPTIONS = {
|
|
||||||
'UserNotFoundException': UserNotFound,
|
|
||||||
'NotAuthorizedException': Unauthenticated,
|
|
||||||
'UserNotConfirmedException': UserNotConfirmed,
|
|
||||||
'PasswordResetRequiredException': PasswordChangeRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass, cloud):
|
|
||||||
"""Configure the auth api."""
|
|
||||||
refresh_task = None
|
|
||||||
|
|
||||||
async def handle_token_refresh():
|
|
||||||
"""Handle Cloud access token refresh."""
|
|
||||||
sleep_time = 5
|
|
||||||
sleep_time = random.randint(2400, 3600)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(sleep_time)
|
|
||||||
await hass.async_add_executor_job(renew_access_token, cloud)
|
|
||||||
except CloudError as err:
|
|
||||||
_LOGGER.error("Can't refresh cloud token: %s", err)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Task is canceled, stop it.
|
|
||||||
break
|
|
||||||
|
|
||||||
sleep_time = random.randint(3100, 3600)
|
|
||||||
|
|
||||||
async def on_connect():
|
|
||||||
"""When the instance is connected."""
|
|
||||||
nonlocal refresh_task
|
|
||||||
refresh_task = hass.async_create_task(handle_token_refresh())
|
|
||||||
|
|
||||||
async def on_disconnect():
|
|
||||||
"""When the instance is disconnected."""
|
|
||||||
nonlocal refresh_task
|
|
||||||
refresh_task.cancel()
|
|
||||||
|
|
||||||
cloud.iot.register_on_connect(on_connect)
|
|
||||||
cloud.iot.register_on_disconnect(on_disconnect)
|
|
||||||
|
|
||||||
|
|
||||||
def _map_aws_exception(err):
|
|
||||||
"""Map AWS exception to our exceptions."""
|
|
||||||
ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError)
|
|
||||||
return ex(err.response['Error']['Message'])
|
|
||||||
|
|
||||||
|
|
||||||
def register(cloud, email, password):
|
|
||||||
"""Register a new account."""
|
|
||||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
||||||
|
|
||||||
cognito = _cognito(cloud)
|
|
||||||
# Workaround for bug in Warrant. PR with fix:
|
|
||||||
# https://github.com/capless/warrant/pull/82
|
|
||||||
cognito.add_base_attributes()
|
|
||||||
try:
|
|
||||||
cognito.register(email, password)
|
|
||||||
|
|
||||||
except ClientError as err:
|
|
||||||
raise _map_aws_exception(err)
|
|
||||||
except EndpointConnectionError:
|
|
||||||
raise UnknownError()
|
|
||||||
|
|
||||||
|
|
||||||
def resend_email_confirm(cloud, email):
|
|
||||||
"""Resend email confirmation."""
|
|
||||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
||||||
|
|
||||||
cognito = _cognito(cloud, username=email)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cognito.client.resend_confirmation_code(
|
|
||||||
Username=email,
|
|
||||||
ClientId=cognito.client_id
|
|
||||||
)
|
|
||||||
except ClientError as err:
|
|
||||||
raise _map_aws_exception(err)
|
|
||||||
except EndpointConnectionError:
|
|
||||||
raise UnknownError()
|
|
||||||
|
|
||||||
|
|
||||||
def forgot_password(cloud, email):
|
|
||||||
"""Initialize forgotten password flow."""
|
|
||||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
||||||
|
|
||||||
cognito = _cognito(cloud, username=email)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cognito.initiate_forgot_password()
|
|
||||||
|
|
||||||
except ClientError as err:
|
|
||||||
raise _map_aws_exception(err)
|
|
||||||
except EndpointConnectionError:
|
|
||||||
raise UnknownError()
|
|
||||||
|
|
||||||
|
|
||||||
def login(cloud, email, password):
|
|
||||||
"""Log user in and fetch certificate."""
|
|
||||||
cognito = _authenticate(cloud, email, password)
|
|
||||||
cloud.id_token = cognito.id_token
|
|
||||||
cloud.access_token = cognito.access_token
|
|
||||||
cloud.refresh_token = cognito.refresh_token
|
|
||||||
cloud.write_user_info()
|
|
||||||
|
|
||||||
|
|
||||||
def check_token(cloud):
|
|
||||||
"""Check that the token is valid and verify if needed."""
|
|
||||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
||||||
|
|
||||||
cognito = _cognito(
|
|
||||||
cloud,
|
|
||||||
access_token=cloud.access_token,
|
|
||||||
refresh_token=cloud.refresh_token)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if cognito.check_token():
|
|
||||||
cloud.id_token = cognito.id_token
|
|
||||||
cloud.access_token = cognito.access_token
|
|
||||||
cloud.write_user_info()
|
|
||||||
|
|
||||||
except ClientError as err:
|
|
||||||
raise _map_aws_exception(err)
|
|
||||||
|
|
||||||
except EndpointConnectionError:
|
|
||||||
raise UnknownError()
|
|
||||||
|
|
||||||
|
|
||||||
def renew_access_token(cloud):
|
|
||||||
"""Renew access token."""
|
|
||||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
||||||
|
|
||||||
cognito = _cognito(
|
|
||||||
cloud,
|
|
||||||
access_token=cloud.access_token,
|
|
||||||
refresh_token=cloud.refresh_token)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cognito.renew_access_token()
|
|
||||||
cloud.id_token = cognito.id_token
|
|
||||||
cloud.access_token = cognito.access_token
|
|
||||||
cloud.write_user_info()
|
|
||||||
|
|
||||||
except ClientError as err:
|
|
||||||
raise _map_aws_exception(err)
|
|
||||||
|
|
||||||
except EndpointConnectionError:
|
|
||||||
raise UnknownError()
|
|
||||||
|
|
||||||
|
|
||||||
def _authenticate(cloud, email, password):
|
|
||||||
"""Log in and return an authenticated Cognito instance."""
|
|
||||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
||||||
from warrant.exceptions import ForceChangePasswordException
|
|
||||||
|
|
||||||
assert not cloud.is_logged_in, 'Cannot login if already logged in.'
|
|
||||||
|
|
||||||
cognito = _cognito(cloud, username=email)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cognito.authenticate(password=password)
|
|
||||||
return cognito
|
|
||||||
|
|
||||||
except ForceChangePasswordException:
|
|
||||||
raise PasswordChangeRequired()
|
|
||||||
|
|
||||||
except ClientError as err:
|
|
||||||
raise _map_aws_exception(err)
|
|
||||||
|
|
||||||
except EndpointConnectionError:
|
|
||||||
raise UnknownError()
|
|
||||||
|
|
||||||
|
|
||||||
def _cognito(cloud, **kwargs):
|
|
||||||
"""Get the client credentials."""
|
|
||||||
import botocore
|
|
||||||
import boto3
|
|
||||||
from warrant import Cognito
|
|
||||||
|
|
||||||
cognito = Cognito(
|
|
||||||
user_pool_id=cloud.user_pool_id,
|
|
||||||
client_id=cloud.cognito_client_id,
|
|
||||||
user_pool_region=cloud.region,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
cognito.client = boto3.client(
|
|
||||||
'cognito-idp',
|
|
||||||
region_name=cloud.region,
|
|
||||||
config=botocore.config.Config(
|
|
||||||
signature_version=botocore.UNSIGNED
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return cognito
|
|
73
homeassistant/components/cloud/binary_sensor.py
Normal file
73
homeassistant/components/cloud/binary_sensor.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Support for Home Assistant Cloud binary sensors."""
|
||||||
|
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
|
||||||
|
from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN
|
||||||
|
|
||||||
|
DEPENDENCIES = ['cloud']
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_platform(
|
||||||
|
hass, config, async_add_entities, discovery_info=None):
|
||||||
|
"""Set up the cloud binary sensors."""
|
||||||
|
if discovery_info is None:
|
||||||
|
return
|
||||||
|
cloud = hass.data[DOMAIN]
|
||||||
|
|
||||||
|
async_add_entities([CloudRemoteBinary(cloud)])
|
||||||
|
|
||||||
|
|
||||||
|
class CloudRemoteBinary(BinarySensorDevice):
|
||||||
|
"""Representation of an Cloud Remote UI Connection binary sensor."""
|
||||||
|
|
||||||
|
def __init__(self, cloud):
|
||||||
|
"""Initialize the binary sensor."""
|
||||||
|
self.cloud = cloud
|
||||||
|
self._unsub_dispatcher = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name of the binary sensor, if any."""
|
||||||
|
return "Remote UI"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
"""Return a unique ID."""
|
||||||
|
return "cloud-remote-ui-connectivity"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return true if the binary sensor is on."""
|
||||||
|
return self.cloud.remote.is_connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self) -> str:
|
||||||
|
"""Return the class of this device, from component DEVICE_CLASSES."""
|
||||||
|
return 'connectivity'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return self.cloud.remote.certificate is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self) -> bool:
|
||||||
|
"""Return True if entity has to be polled for state."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""Register update dispatcher."""
|
||||||
|
@callback
|
||||||
|
def async_state_update(data):
|
||||||
|
"""Update callback."""
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
self._unsub_dispatcher = async_dispatcher_connect(
|
||||||
|
self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self):
|
||||||
|
"""Register update dispatcher."""
|
||||||
|
if self._unsub_dispatcher is not None:
|
||||||
|
self._unsub_dispatcher()
|
||||||
|
self._unsub_dispatcher = None
|
198
homeassistant/components/cloud/client.py
Normal file
198
homeassistant/components/cloud/client.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Interface implementation for cloud client."""
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from hass_nabucasa.client import CloudClient as Interface
|
||||||
|
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||||
|
from homeassistant.components.google_assistant import (
|
||||||
|
helpers as ga_h, smart_home as ga)
|
||||||
|
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||||
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
from homeassistant.util.aiohttp import MockRequest
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
from .const import (
|
||||||
|
CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE)
|
||||||
|
from .prefs import CloudPreferences
|
||||||
|
|
||||||
|
|
||||||
|
class CloudClient(Interface):
|
||||||
|
"""Interface class for Home Assistant Cloud."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences,
|
||||||
|
websession: aiohttp.ClientSession,
|
||||||
|
alexa_config: Dict[str, Any], google_config: Dict[str, Any]):
|
||||||
|
"""Initialize client interface to Cloud."""
|
||||||
|
self._hass = hass
|
||||||
|
self._prefs = prefs
|
||||||
|
self._websession = websession
|
||||||
|
self._alexa_user_config = alexa_config
|
||||||
|
self._google_user_config = google_config
|
||||||
|
|
||||||
|
self._alexa_config = None
|
||||||
|
self._google_config = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_path(self) -> Path:
|
||||||
|
"""Return path to base dir."""
|
||||||
|
return Path(self._hass.config.config_dir)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prefs(self) -> CloudPreferences:
|
||||||
|
"""Return Cloud preferences."""
|
||||||
|
return self._prefs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loop(self) -> asyncio.BaseEventLoop:
|
||||||
|
"""Return client loop."""
|
||||||
|
return self._hass.loop
|
||||||
|
|
||||||
|
@property
|
||||||
|
def websession(self) -> aiohttp.ClientSession:
|
||||||
|
"""Return client session for aiohttp."""
|
||||||
|
return self._websession
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aiohttp_runner(self) -> aiohttp.web.AppRunner:
|
||||||
|
"""Return client webinterface aiohttp application."""
|
||||||
|
return self._hass.http.runner
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cloudhooks(self) -> Dict[str, Dict[str, str]]:
|
||||||
|
"""Return list of cloudhooks."""
|
||||||
|
return self._prefs.cloudhooks
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remote_autostart(self) -> bool:
|
||||||
|
"""Return true if we want start a remote connection."""
|
||||||
|
return self._prefs.remote_enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alexa_config(self) -> alexa_sh.Config:
|
||||||
|
"""Return Alexa config."""
|
||||||
|
if not self._alexa_config:
|
||||||
|
alexa_conf = self._alexa_user_config
|
||||||
|
|
||||||
|
self._alexa_config = alexa_sh.Config(
|
||||||
|
endpoint=None,
|
||||||
|
async_get_access_token=None,
|
||||||
|
should_expose=alexa_conf[CONF_FILTER],
|
||||||
|
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._alexa_config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def google_config(self) -> ga_h.Config:
|
||||||
|
"""Return Google config."""
|
||||||
|
if not self._google_config:
|
||||||
|
google_conf = self._google_user_config
|
||||||
|
|
||||||
|
def should_expose(entity):
|
||||||
|
"""If an entity should be exposed."""
|
||||||
|
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return google_conf['filter'](entity.entity_id)
|
||||||
|
|
||||||
|
self._google_config = ga_h.Config(
|
||||||
|
should_expose=should_expose,
|
||||||
|
allow_unlock=self._prefs.google_allow_unlock,
|
||||||
|
entity_config=google_conf.get(CONF_ENTITY_CONFIG),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._google_config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def google_user_config(self) -> Dict[str, Any]:
|
||||||
|
"""Return google action user config."""
|
||||||
|
return self._google_user_config
|
||||||
|
|
||||||
|
async def cleanups(self) -> None:
|
||||||
|
"""Cleanup some stuff after logout."""
|
||||||
|
self._alexa_config = None
|
||||||
|
self._google_config = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def user_message(self, identifier: str, title: str, message: str) -> None:
|
||||||
|
"""Create a message for user to UI."""
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
message, title, identifier
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def dispatcher_message(self, identifier: str, data: Any = None) -> None:
|
||||||
|
"""Match cloud notification to dispatcher."""
|
||||||
|
if identifier.startswith("remote_"):
|
||||||
|
async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data)
|
||||||
|
|
||||||
|
async def async_alexa_message(
|
||||||
|
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||||
|
"""Process cloud alexa message to client."""
|
||||||
|
return await alexa_sh.async_handle_message(
|
||||||
|
self._hass, self.alexa_config, payload,
|
||||||
|
enabled=self._prefs.alexa_enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_google_message(
|
||||||
|
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||||
|
"""Process cloud google message to client."""
|
||||||
|
if not self._prefs.google_enabled:
|
||||||
|
return ga.turned_off_response(payload)
|
||||||
|
|
||||||
|
answer = await ga.async_handle_message(
|
||||||
|
self._hass, self.google_config, self.prefs.cloud_user, payload
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix AgentUserId
|
||||||
|
cloud = self._hass.data[DOMAIN]
|
||||||
|
answer['payload']['agentUserId'] = cloud.claims['cognito:username']
|
||||||
|
|
||||||
|
return answer
|
||||||
|
|
||||||
|
async def async_webhook_message(
|
||||||
|
self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||||
|
"""Process cloud webhook message to client."""
|
||||||
|
cloudhook_id = payload['cloudhook_id']
|
||||||
|
|
||||||
|
found = None
|
||||||
|
for cloudhook in self._prefs.cloudhooks.values():
|
||||||
|
if cloudhook['cloudhook_id'] == cloudhook_id:
|
||||||
|
found = cloudhook
|
||||||
|
break
|
||||||
|
|
||||||
|
if found is None:
|
||||||
|
return {
|
||||||
|
'status': 200
|
||||||
|
}
|
||||||
|
|
||||||
|
request = MockRequest(
|
||||||
|
content=payload['body'].encode('utf-8'),
|
||||||
|
headers=payload['headers'],
|
||||||
|
method=payload['method'],
|
||||||
|
query_string=payload['query'],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self._hass.components.webhook.async_handle_webhook(
|
||||||
|
found['webhook_id'], request)
|
||||||
|
|
||||||
|
response_dict = utils.aiohttp_serialize_response(response)
|
||||||
|
body = response_dict.get('body')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'body': body,
|
||||||
|
'status': response_dict['status'],
|
||||||
|
'headers': {
|
||||||
|
'Content-Type': response.content_type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_cloudhooks_update(
|
||||||
|
self, data: Dict[str, Dict[str, str]]) -> None:
|
||||||
|
"""Update local list of cloudhooks."""
|
||||||
|
await self._prefs.async_update(cloudhooks=data)
|
@@ -1,42 +0,0 @@
|
|||||||
"""Cloud APIs."""
|
|
||||||
from functools import wraps
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from . import auth_api
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_token(func):
|
|
||||||
"""Decorate a function to verify valid token."""
|
|
||||||
@wraps(func)
|
|
||||||
async def check_token(cloud, *args):
|
|
||||||
"""Validate token, then call func."""
|
|
||||||
await cloud.hass.async_add_executor_job(auth_api.check_token, cloud)
|
|
||||||
return await func(cloud, *args)
|
|
||||||
|
|
||||||
return check_token
|
|
||||||
|
|
||||||
|
|
||||||
def _log_response(func):
|
|
||||||
"""Decorate a function to log bad responses."""
|
|
||||||
@wraps(func)
|
|
||||||
async def log_response(*args):
|
|
||||||
"""Log response if it's bad."""
|
|
||||||
resp = await func(*args)
|
|
||||||
meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning
|
|
||||||
meth('Fetched %s (%s)', resp.url, resp.status)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
return log_response
|
|
||||||
|
|
||||||
|
|
||||||
@_check_token
|
|
||||||
@_log_response
|
|
||||||
async def async_create_cloudhook(cloud):
|
|
||||||
"""Create a cloudhook."""
|
|
||||||
websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession()
|
|
||||||
return await websession.post(
|
|
||||||
cloud.cloudhook_create_url, headers={
|
|
||||||
'authorization': cloud.id_token
|
|
||||||
})
|
|
@@ -1,66 +0,0 @@
|
|||||||
"""Manage cloud cloudhooks."""
|
|
||||||
import async_timeout
|
|
||||||
|
|
||||||
from . import cloud_api
|
|
||||||
|
|
||||||
|
|
||||||
class Cloudhooks:
|
|
||||||
"""Class to help manage cloudhooks."""
|
|
||||||
|
|
||||||
def __init__(self, cloud):
|
|
||||||
"""Initialize cloudhooks."""
|
|
||||||
self.cloud = cloud
|
|
||||||
self.cloud.iot.register_on_connect(self.async_publish_cloudhooks)
|
|
||||||
|
|
||||||
async def async_publish_cloudhooks(self):
|
|
||||||
"""Inform the Relayer of the cloudhooks that we support."""
|
|
||||||
cloudhooks = self.cloud.prefs.cloudhooks
|
|
||||||
await self.cloud.iot.async_send_message('webhook-register', {
|
|
||||||
'cloudhook_ids': [info['cloudhook_id'] for info
|
|
||||||
in cloudhooks.values()]
|
|
||||||
}, expect_answer=False)
|
|
||||||
|
|
||||||
async def async_create(self, webhook_id):
|
|
||||||
"""Create a cloud webhook."""
|
|
||||||
cloudhooks = self.cloud.prefs.cloudhooks
|
|
||||||
|
|
||||||
if webhook_id in cloudhooks:
|
|
||||||
raise ValueError('Hook is already enabled for the cloud.')
|
|
||||||
|
|
||||||
if not self.cloud.iot.connected:
|
|
||||||
raise ValueError("Cloud is not connected")
|
|
||||||
|
|
||||||
# Create cloud hook
|
|
||||||
with async_timeout.timeout(10):
|
|
||||||
resp = await cloud_api.async_create_cloudhook(self.cloud)
|
|
||||||
|
|
||||||
data = await resp.json()
|
|
||||||
cloudhook_id = data['cloudhook_id']
|
|
||||||
cloudhook_url = data['url']
|
|
||||||
|
|
||||||
# Store hook
|
|
||||||
cloudhooks = dict(cloudhooks)
|
|
||||||
hook = cloudhooks[webhook_id] = {
|
|
||||||
'webhook_id': webhook_id,
|
|
||||||
'cloudhook_id': cloudhook_id,
|
|
||||||
'cloudhook_url': cloudhook_url
|
|
||||||
}
|
|
||||||
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
|
|
||||||
|
|
||||||
await self.async_publish_cloudhooks()
|
|
||||||
|
|
||||||
return hook
|
|
||||||
|
|
||||||
async def async_delete(self, webhook_id):
|
|
||||||
"""Delete a cloud webhook."""
|
|
||||||
cloudhooks = self.cloud.prefs.cloudhooks
|
|
||||||
|
|
||||||
if webhook_id not in cloudhooks:
|
|
||||||
raise ValueError('Hook is not enabled for the cloud.')
|
|
||||||
|
|
||||||
# Remove hook
|
|
||||||
cloudhooks = dict(cloudhooks)
|
|
||||||
cloudhooks.pop(webhook_id)
|
|
||||||
await self.cloud.prefs.async_update(cloudhooks=cloudhooks)
|
|
||||||
|
|
||||||
await self.async_publish_cloudhooks()
|
|
@@ -1,34 +1,33 @@
|
|||||||
"""Constants for the cloud component."""
|
"""Constants for the cloud component."""
|
||||||
DOMAIN = 'cloud'
|
DOMAIN = 'cloud'
|
||||||
CONFIG_DIR = '.cloud'
|
|
||||||
REQUEST_TIMEOUT = 10
|
REQUEST_TIMEOUT = 10
|
||||||
|
|
||||||
PREF_ENABLE_ALEXA = 'alexa_enabled'
|
PREF_ENABLE_ALEXA = 'alexa_enabled'
|
||||||
PREF_ENABLE_GOOGLE = 'google_enabled'
|
PREF_ENABLE_GOOGLE = 'google_enabled'
|
||||||
|
PREF_ENABLE_REMOTE = 'remote_enabled'
|
||||||
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
|
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
|
||||||
PREF_CLOUDHOOKS = 'cloudhooks'
|
PREF_CLOUDHOOKS = 'cloudhooks'
|
||||||
|
PREF_CLOUD_USER = 'cloud_user'
|
||||||
|
|
||||||
SERVERS = {
|
CONF_ALEXA = 'alexa'
|
||||||
'production': {
|
CONF_ALIASES = 'aliases'
|
||||||
'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u',
|
CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
|
||||||
'user_pool_id': 'us-east-1_87ll5WOP8',
|
CONF_ENTITY_CONFIG = 'entity_config'
|
||||||
'region': 'us-east-1',
|
CONF_FILTER = 'filter'
|
||||||
'relayer': 'wss://cloud.hass.io:8000/websocket',
|
CONF_GOOGLE_ACTIONS = 'google_actions'
|
||||||
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
|
CONF_RELAYER = 'relayer'
|
||||||
'amazonaws.com/prod/smart_home_sync'),
|
CONF_USER_POOL_ID = 'user_pool_id'
|
||||||
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
|
CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
|
||||||
'subscription_info'),
|
CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
||||||
'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate'
|
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
||||||
}
|
CONF_REMOTE_API_URL = 'remote_api_url'
|
||||||
}
|
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
|
||||||
|
|
||||||
MESSAGE_EXPIRATION = """
|
MODE_DEV = "development"
|
||||||
It looks like your Home Assistant Cloud subscription has expired. Please check
|
MODE_PROD = "production"
|
||||||
your [account page](/config/cloud/account) to continue using the service.
|
|
||||||
"""
|
|
||||||
|
|
||||||
MESSAGE_AUTH_FAIL = """
|
DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update'
|
||||||
You have been logged out of Home Assistant Cloud because we have been unable
|
|
||||||
to verify your credentials. Please [log in](/config/cloud) again to continue
|
|
||||||
using the service.
|
class InvalidTrustedNetworks(Exception):
|
||||||
"""
|
"""Raised when invalid trusted networks config."""
|
||||||
|
@@ -3,6 +3,7 @@ import asyncio
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import attr
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import async_timeout
|
import async_timeout
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -15,11 +16,9 @@ from homeassistant.components import websocket_api
|
|||||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||||
from homeassistant.components.google_assistant import smart_home as google_sh
|
from homeassistant.components.google_assistant import smart_home as google_sh
|
||||||
|
|
||||||
from . import auth_api
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||||
PREF_GOOGLE_ALLOW_UNLOCK)
|
PREF_GOOGLE_ALLOW_UNLOCK, InvalidTrustedNetworks)
|
||||||
from .iot import STATE_DISCONNECTED, STATE_CONNECTED
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -59,6 +58,13 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
_CLOUD_ERRORS = {
|
||||||
|
InvalidTrustedNetworks:
|
||||||
|
(500, 'Remote UI not compatible with 127.0.0.1/::1'
|
||||||
|
' as a trusted network.')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass):
|
async def async_setup(hass):
|
||||||
"""Initialize the HTTP API."""
|
"""Initialize the HTTP API."""
|
||||||
hass.components.websocket_api.async_register_command(
|
hass.components.websocket_api.async_register_command(
|
||||||
@@ -81,6 +87,10 @@ async def async_setup(hass):
|
|||||||
WS_TYPE_HOOK_DELETE, websocket_hook_delete,
|
WS_TYPE_HOOK_DELETE, websocket_hook_delete,
|
||||||
SCHEMA_WS_HOOK_DELETE
|
SCHEMA_WS_HOOK_DELETE
|
||||||
)
|
)
|
||||||
|
hass.components.websocket_api.async_register_command(
|
||||||
|
websocket_remote_connect)
|
||||||
|
hass.components.websocket_api.async_register_command(
|
||||||
|
websocket_remote_disconnect)
|
||||||
hass.http.register_view(GoogleActionsSyncView)
|
hass.http.register_view(GoogleActionsSyncView)
|
||||||
hass.http.register_view(CloudLoginView)
|
hass.http.register_view(CloudLoginView)
|
||||||
hass.http.register_view(CloudLogoutView)
|
hass.http.register_view(CloudLogoutView)
|
||||||
@@ -88,14 +98,22 @@ async def async_setup(hass):
|
|||||||
hass.http.register_view(CloudResendConfirmView)
|
hass.http.register_view(CloudResendConfirmView)
|
||||||
hass.http.register_view(CloudForgotPasswordView)
|
hass.http.register_view(CloudForgotPasswordView)
|
||||||
|
|
||||||
|
from hass_nabucasa import auth
|
||||||
|
|
||||||
_CLOUD_ERRORS = {
|
_CLOUD_ERRORS.update({
|
||||||
auth_api.UserNotFound: (400, "User does not exist."),
|
auth.UserNotFound:
|
||||||
auth_api.UserNotConfirmed: (400, 'Email not confirmed.'),
|
(400, "User does not exist."),
|
||||||
auth_api.Unauthenticated: (401, 'Authentication failed.'),
|
auth.UserNotConfirmed:
|
||||||
auth_api.PasswordChangeRequired: (400, 'Password change required.'),
|
(400, 'Email not confirmed.'),
|
||||||
asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.')
|
auth.Unauthenticated:
|
||||||
}
|
(401, 'Authentication failed.'),
|
||||||
|
auth.PasswordChangeRequired:
|
||||||
|
(400, 'Password change required.'),
|
||||||
|
asyncio.TimeoutError:
|
||||||
|
(502, 'Unable to reach the Home Assistant cloud.'),
|
||||||
|
aiohttp.ClientError:
|
||||||
|
(500, 'Error making internal request'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _handle_cloud_errors(handler):
|
def _handle_cloud_errors(handler):
|
||||||
@@ -108,12 +126,7 @@ def _handle_cloud_errors(handler):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
except Exception as err: # pylint: disable=broad-except
|
||||||
err_info = _CLOUD_ERRORS.get(err.__class__)
|
status, msg = _process_cloud_exception(err, request.path)
|
||||||
if err_info is None:
|
|
||||||
_LOGGER.exception(
|
|
||||||
"Unexpected error processing request for %s", request.path)
|
|
||||||
err_info = (502, 'Unexpected error: {}'.format(err))
|
|
||||||
status, msg = err_info
|
|
||||||
return view.json_message(
|
return view.json_message(
|
||||||
msg, status_code=status,
|
msg, status_code=status,
|
||||||
message_code=err.__class__.__name__.lower())
|
message_code=err.__class__.__name__.lower())
|
||||||
@@ -121,6 +134,31 @@ def _handle_cloud_errors(handler):
|
|||||||
return error_handler
|
return error_handler
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_handle_cloud_errors(handler):
|
||||||
|
"""Websocket decorator to handle auth errors."""
|
||||||
|
@wraps(handler)
|
||||||
|
async def error_handler(hass, connection, msg):
|
||||||
|
"""Handle exceptions that raise from the wrapped handler."""
|
||||||
|
try:
|
||||||
|
return await handler(hass, connection, msg)
|
||||||
|
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
err_status, err_msg = _process_cloud_exception(err, msg['type'])
|
||||||
|
connection.send_error(msg['id'], err_status, err_msg)
|
||||||
|
|
||||||
|
return error_handler
|
||||||
|
|
||||||
|
|
||||||
|
def _process_cloud_exception(exc, where):
|
||||||
|
"""Process a cloud exception."""
|
||||||
|
err_info = _CLOUD_ERRORS.get(exc.__class__)
|
||||||
|
if err_info is None:
|
||||||
|
_LOGGER.exception(
|
||||||
|
"Unexpected error processing request for %s", where)
|
||||||
|
err_info = (502, 'Unexpected error: {}'.format(exc))
|
||||||
|
return err_info
|
||||||
|
|
||||||
|
|
||||||
class GoogleActionsSyncView(HomeAssistantView):
|
class GoogleActionsSyncView(HomeAssistantView):
|
||||||
"""Trigger a Google Actions Smart Home Sync."""
|
"""Trigger a Google Actions Smart Home Sync."""
|
||||||
|
|
||||||
@@ -135,7 +173,7 @@ class GoogleActionsSyncView(HomeAssistantView):
|
|||||||
websession = hass.helpers.aiohttp_client.async_get_clientsession()
|
websession = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||||
|
|
||||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
await hass.async_add_job(auth_api.check_token, cloud)
|
await hass.async_add_job(cloud.auth.check_token)
|
||||||
|
|
||||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
req = await websession.post(
|
req = await websession.post(
|
||||||
@@ -163,7 +201,7 @@ class CloudLoginView(HomeAssistantView):
|
|||||||
cloud = hass.data[DOMAIN]
|
cloud = hass.data[DOMAIN]
|
||||||
|
|
||||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
await hass.async_add_job(auth_api.login, cloud, data['email'],
|
await hass.async_add_job(cloud.auth.login, data['email'],
|
||||||
data['password'])
|
data['password'])
|
||||||
|
|
||||||
hass.async_add_job(cloud.iot.connect)
|
hass.async_add_job(cloud.iot.connect)
|
||||||
@@ -206,7 +244,7 @@ class CloudRegisterView(HomeAssistantView):
|
|||||||
|
|
||||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
await hass.async_add_job(
|
await hass.async_add_job(
|
||||||
auth_api.register, cloud, data['email'], data['password'])
|
cloud.auth.register, data['email'], data['password'])
|
||||||
|
|
||||||
return self.json_message('ok')
|
return self.json_message('ok')
|
||||||
|
|
||||||
@@ -228,7 +266,7 @@ class CloudResendConfirmView(HomeAssistantView):
|
|||||||
|
|
||||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
await hass.async_add_job(
|
await hass.async_add_job(
|
||||||
auth_api.resend_email_confirm, cloud, data['email'])
|
cloud.auth.resend_email_confirm, data['email'])
|
||||||
|
|
||||||
return self.json_message('ok')
|
return self.json_message('ok')
|
||||||
|
|
||||||
@@ -250,7 +288,7 @@ class CloudForgotPasswordView(HomeAssistantView):
|
|||||||
|
|
||||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
await hass.async_add_job(
|
await hass.async_add_job(
|
||||||
auth_api.forgot_password, cloud, data['email'])
|
cloud.auth.forgot_password, data['email'])
|
||||||
|
|
||||||
return self.json_message('ok')
|
return self.json_message('ok')
|
||||||
|
|
||||||
@@ -283,30 +321,11 @@ def _require_cloud_login(handler):
|
|||||||
return with_cloud_auth
|
return with_cloud_auth
|
||||||
|
|
||||||
|
|
||||||
def _handle_aiohttp_errors(handler):
|
|
||||||
"""Websocket decorator that handlers aiohttp errors.
|
|
||||||
|
|
||||||
Can only wrap async handlers.
|
|
||||||
"""
|
|
||||||
@wraps(handler)
|
|
||||||
async def with_error_handling(hass, connection, msg):
|
|
||||||
"""Handle aiohttp errors."""
|
|
||||||
try:
|
|
||||||
await handler(hass, connection, msg)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
connection.send_message(websocket_api.error_message(
|
|
||||||
msg['id'], 'timeout', 'Command timed out.'))
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
connection.send_message(websocket_api.error_message(
|
|
||||||
msg['id'], 'unknown', 'Error making request.'))
|
|
||||||
|
|
||||||
return with_error_handling
|
|
||||||
|
|
||||||
|
|
||||||
@_require_cloud_login
|
@_require_cloud_login
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
async def websocket_subscription(hass, connection, msg):
|
async def websocket_subscription(hass, connection, msg):
|
||||||
"""Handle request for account info."""
|
"""Handle request for account info."""
|
||||||
|
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||||
cloud = hass.data[DOMAIN]
|
cloud = hass.data[DOMAIN]
|
||||||
|
|
||||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||||
@@ -320,11 +339,10 @@ async def websocket_subscription(hass, connection, msg):
|
|||||||
|
|
||||||
# Check if a user is subscribed but local info is outdated
|
# Check if a user is subscribed but local info is outdated
|
||||||
# In that case, let's refresh and reconnect
|
# In that case, let's refresh and reconnect
|
||||||
if data.get('provider') and cloud.iot.state != STATE_CONNECTED:
|
if data.get('provider') and not cloud.is_connected:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Found disconnected account with valid subscriotion, connecting")
|
"Found disconnected account with valid subscriotion, connecting")
|
||||||
await hass.async_add_executor_job(
|
await hass.async_add_executor_job(cloud.auth.renew_access_token)
|
||||||
auth_api.renew_access_token, cloud)
|
|
||||||
|
|
||||||
# Cancel reconnect in progress
|
# Cancel reconnect in progress
|
||||||
if cloud.iot.state != STATE_DISCONNECTED:
|
if cloud.iot.state != STATE_DISCONNECTED:
|
||||||
@@ -344,23 +362,24 @@ async def websocket_update_prefs(hass, connection, msg):
|
|||||||
changes = dict(msg)
|
changes = dict(msg)
|
||||||
changes.pop('id')
|
changes.pop('id')
|
||||||
changes.pop('type')
|
changes.pop('type')
|
||||||
await cloud.prefs.async_update(**changes)
|
await cloud.client.prefs.async_update(**changes)
|
||||||
|
|
||||||
connection.send_message(websocket_api.result_message(msg['id']))
|
connection.send_message(websocket_api.result_message(msg['id']))
|
||||||
|
|
||||||
|
|
||||||
@_require_cloud_login
|
@_require_cloud_login
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
@_handle_aiohttp_errors
|
@_ws_handle_cloud_errors
|
||||||
async def websocket_hook_create(hass, connection, msg):
|
async def websocket_hook_create(hass, connection, msg):
|
||||||
"""Handle request for account info."""
|
"""Handle request for account info."""
|
||||||
cloud = hass.data[DOMAIN]
|
cloud = hass.data[DOMAIN]
|
||||||
hook = await cloud.cloudhooks.async_create(msg['webhook_id'])
|
hook = await cloud.cloudhooks.async_create(msg['webhook_id'], False)
|
||||||
connection.send_message(websocket_api.result_message(msg['id'], hook))
|
connection.send_message(websocket_api.result_message(msg['id'], hook))
|
||||||
|
|
||||||
|
|
||||||
@_require_cloud_login
|
@_require_cloud_login
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
|
@_ws_handle_cloud_errors
|
||||||
async def websocket_hook_delete(hass, connection, msg):
|
async def websocket_hook_delete(hass, connection, msg):
|
||||||
"""Handle request for account info."""
|
"""Handle request for account info."""
|
||||||
cloud = hass.data[DOMAIN]
|
cloud = hass.data[DOMAIN]
|
||||||
@@ -370,6 +389,8 @@ async def websocket_hook_delete(hass, connection, msg):
|
|||||||
|
|
||||||
def _account_data(cloud):
|
def _account_data(cloud):
|
||||||
"""Generate the auth data JSON response."""
|
"""Generate the auth data JSON response."""
|
||||||
|
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||||
|
|
||||||
if not cloud.is_logged_in:
|
if not cloud.is_logged_in:
|
||||||
return {
|
return {
|
||||||
'logged_in': False,
|
'logged_in': False,
|
||||||
@@ -377,14 +398,53 @@ def _account_data(cloud):
|
|||||||
}
|
}
|
||||||
|
|
||||||
claims = cloud.claims
|
claims = cloud.claims
|
||||||
|
client = cloud.client
|
||||||
|
remote = cloud.remote
|
||||||
|
|
||||||
|
# Load remote certificate
|
||||||
|
if remote.certificate:
|
||||||
|
certificate = attr.asdict(remote.certificate)
|
||||||
|
else:
|
||||||
|
certificate = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'logged_in': True,
|
'logged_in': True,
|
||||||
'email': claims['email'],
|
'email': claims['email'],
|
||||||
'cloud': cloud.iot.state,
|
'cloud': cloud.iot.state,
|
||||||
'prefs': cloud.prefs.as_dict(),
|
'prefs': client.prefs.as_dict(),
|
||||||
'google_entities': cloud.google_actions_user_conf['filter'].config,
|
'google_entities': client.google_user_config['filter'].config,
|
||||||
'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES),
|
'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES),
|
||||||
'alexa_entities': cloud.alexa_config.should_expose.config,
|
'alexa_entities': client.alexa_config.should_expose.config,
|
||||||
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
|
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
|
||||||
|
'remote_domain': remote.instance_domain,
|
||||||
|
'remote_connected': remote.is_connected,
|
||||||
|
'remote_certificate': certificate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@_require_cloud_login
|
||||||
|
@websocket_api.async_response
|
||||||
|
@_ws_handle_cloud_errors
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
'type': 'cloud/remote/connect'
|
||||||
|
})
|
||||||
|
async def websocket_remote_connect(hass, connection, msg):
|
||||||
|
"""Handle request for connect remote."""
|
||||||
|
cloud = hass.data[DOMAIN]
|
||||||
|
await cloud.client.prefs.async_update(remote_enabled=True)
|
||||||
|
await cloud.remote.connect()
|
||||||
|
connection.send_result(msg['id'], _account_data(cloud))
|
||||||
|
|
||||||
|
|
||||||
|
@_require_cloud_login
|
||||||
|
@websocket_api.async_response
|
||||||
|
@_ws_handle_cloud_errors
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
'type': 'cloud/remote/disconnect'
|
||||||
|
})
|
||||||
|
async def websocket_remote_disconnect(hass, connection, msg):
|
||||||
|
"""Handle request for disconnect remote."""
|
||||||
|
cloud = hass.data[DOMAIN]
|
||||||
|
await cloud.client.prefs.async_update(remote_enabled=False)
|
||||||
|
await cloud.remote.disconnect()
|
||||||
|
connection.send_result(msg['id'], _account_data(cloud))
|
||||||
|
@@ -1,391 +0,0 @@
|
|||||||
"""Module to handle messages from Home Assistant cloud."""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import pprint
|
|
||||||
import random
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from aiohttp import hdrs, client_exceptions, WSMsgType
|
|
||||||
|
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
|
||||||
from homeassistant.components.alexa import smart_home as alexa
|
|
||||||
from homeassistant.components.google_assistant import smart_home as ga
|
|
||||||
from homeassistant.core import callback
|
|
||||||
from homeassistant.util.decorator import Registry
|
|
||||||
from homeassistant.util.aiohttp import MockRequest
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from . import auth_api
|
|
||||||
from . import utils
|
|
||||||
from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL
|
|
||||||
|
|
||||||
HANDLERS = Registry()
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATE_CONNECTING = 'connecting'
|
|
||||||
STATE_CONNECTED = 'connected'
|
|
||||||
STATE_DISCONNECTED = 'disconnected'
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownHandler(Exception):
|
|
||||||
"""Exception raised when trying to handle unknown handler."""
|
|
||||||
|
|
||||||
|
|
||||||
class NotConnected(Exception):
|
|
||||||
"""Exception raised when trying to handle unknown handler."""
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorMessage(Exception):
|
|
||||||
"""Exception raised when there was error handling message in the cloud."""
|
|
||||||
|
|
||||||
def __init__(self, error):
|
|
||||||
"""Initialize Error Message."""
|
|
||||||
super().__init__(self, "Error in Cloud")
|
|
||||||
self.error = error
|
|
||||||
|
|
||||||
|
|
||||||
class CloudIoT:
|
|
||||||
"""Class to manage the IoT connection."""
|
|
||||||
|
|
||||||
def __init__(self, cloud):
|
|
||||||
"""Initialize the CloudIoT class."""
|
|
||||||
self.cloud = cloud
|
|
||||||
# The WebSocket client
|
|
||||||
self.client = None
|
|
||||||
# Scheduled sleep task till next connection retry
|
|
||||||
self.retry_task = None
|
|
||||||
# Boolean to indicate if we wanted the connection to close
|
|
||||||
self.close_requested = False
|
|
||||||
# The current number of attempts to connect, impacts wait time
|
|
||||||
self.tries = 0
|
|
||||||
# Current state of the connection
|
|
||||||
self.state = STATE_DISCONNECTED
|
|
||||||
# Local code waiting for a response
|
|
||||||
self._response_handler = {}
|
|
||||||
self._on_connect = []
|
|
||||||
self._on_disconnect = []
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def register_on_connect(self, on_connect_cb):
|
|
||||||
"""Register an async on_connect callback."""
|
|
||||||
self._on_connect.append(on_connect_cb)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def register_on_disconnect(self, on_disconnect_cb):
|
|
||||||
"""Register an async on_disconnect callback."""
|
|
||||||
self._on_disconnect.append(on_disconnect_cb)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def connected(self):
|
|
||||||
"""Return if we're currently connected."""
|
|
||||||
return self.state == STATE_CONNECTED
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def connect(self):
|
|
||||||
"""Connect to the IoT broker."""
|
|
||||||
if self.state != STATE_DISCONNECTED:
|
|
||||||
raise RuntimeError('Connect called while not disconnected')
|
|
||||||
|
|
||||||
hass = self.cloud.hass
|
|
||||||
self.close_requested = False
|
|
||||||
self.state = STATE_CONNECTING
|
|
||||||
self.tries = 0
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def _handle_hass_stop(event):
|
|
||||||
"""Handle Home Assistant shutting down."""
|
|
||||||
nonlocal remove_hass_stop_listener
|
|
||||||
remove_hass_stop_listener = None
|
|
||||||
yield from self.disconnect()
|
|
||||||
|
|
||||||
remove_hass_stop_listener = hass.bus.async_listen_once(
|
|
||||||
EVENT_HOMEASSISTANT_STOP, _handle_hass_stop)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
yield from self._handle_connection()
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
# Safety net. This should never hit.
|
|
||||||
# Still adding it here to make sure we can always reconnect
|
|
||||||
_LOGGER.exception("Unexpected error")
|
|
||||||
|
|
||||||
if self.state == STATE_CONNECTED and self._on_disconnect:
|
|
||||||
try:
|
|
||||||
yield from asyncio.wait([
|
|
||||||
cb() for cb in self._on_disconnect
|
|
||||||
])
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
# Safety net. This should never hit.
|
|
||||||
# Still adding it here to make sure we don't break the flow
|
|
||||||
_LOGGER.exception(
|
|
||||||
"Unexpected error in on_disconnect callbacks")
|
|
||||||
|
|
||||||
if self.close_requested:
|
|
||||||
break
|
|
||||||
|
|
||||||
self.state = STATE_CONNECTING
|
|
||||||
self.tries += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Sleep 2^tries + 0…tries*3 seconds between retries
|
|
||||||
self.retry_task = hass.async_create_task(
|
|
||||||
asyncio.sleep(2**min(9, self.tries) +
|
|
||||||
random.randint(0, self.tries * 3),
|
|
||||||
loop=hass.loop))
|
|
||||||
yield from self.retry_task
|
|
||||||
self.retry_task = None
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Happens if disconnect called
|
|
||||||
break
|
|
||||||
|
|
||||||
self.state = STATE_DISCONNECTED
|
|
||||||
if remove_hass_stop_listener is not None:
|
|
||||||
remove_hass_stop_listener()
|
|
||||||
|
|
||||||
async def async_send_message(self, handler, payload,
|
|
||||||
expect_answer=True):
|
|
||||||
"""Send a message."""
|
|
||||||
if self.state != STATE_CONNECTED:
|
|
||||||
raise NotConnected
|
|
||||||
|
|
||||||
msgid = uuid.uuid4().hex
|
|
||||||
|
|
||||||
if expect_answer:
|
|
||||||
fut = self._response_handler[msgid] = asyncio.Future()
|
|
||||||
|
|
||||||
message = {
|
|
||||||
'msgid': msgid,
|
|
||||||
'handler': handler,
|
|
||||||
'payload': payload,
|
|
||||||
}
|
|
||||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
||||||
_LOGGER.debug("Publishing message:\n%s\n",
|
|
||||||
pprint.pformat(message))
|
|
||||||
await self.client.send_json(message)
|
|
||||||
|
|
||||||
if expect_answer:
|
|
||||||
return await fut
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def _handle_connection(self):
|
|
||||||
"""Connect to the IoT broker."""
|
|
||||||
hass = self.cloud.hass
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield from hass.async_add_job(auth_api.check_token, self.cloud)
|
|
||||||
except auth_api.Unauthenticated as err:
|
|
||||||
_LOGGER.error('Unable to refresh token: %s', err)
|
|
||||||
|
|
||||||
hass.components.persistent_notification.async_create(
|
|
||||||
MESSAGE_AUTH_FAIL, 'Home Assistant Cloud',
|
|
||||||
'cloud_subscription_expired')
|
|
||||||
|
|
||||||
# Don't await it because it will cancel this task
|
|
||||||
hass.async_create_task(self.cloud.logout())
|
|
||||||
return
|
|
||||||
except auth_api.CloudError as err:
|
|
||||||
_LOGGER.warning("Unable to refresh token: %s", err)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.cloud.subscription_expired:
|
|
||||||
hass.components.persistent_notification.async_create(
|
|
||||||
MESSAGE_EXPIRATION, 'Home Assistant Cloud',
|
|
||||||
'cloud_subscription_expired')
|
|
||||||
self.close_requested = True
|
|
||||||
return
|
|
||||||
|
|
||||||
session = async_get_clientsession(self.cloud.hass)
|
|
||||||
client = None
|
|
||||||
disconnect_warn = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.client = client = yield from session.ws_connect(
|
|
||||||
self.cloud.relayer, heartbeat=55, headers={
|
|
||||||
hdrs.AUTHORIZATION:
|
|
||||||
'Bearer {}'.format(self.cloud.id_token)
|
|
||||||
})
|
|
||||||
self.tries = 0
|
|
||||||
|
|
||||||
_LOGGER.info("Connected")
|
|
||||||
self.state = STATE_CONNECTED
|
|
||||||
|
|
||||||
if self._on_connect:
|
|
||||||
try:
|
|
||||||
yield from asyncio.wait([cb() for cb in self._on_connect])
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
# Safety net. This should never hit.
|
|
||||||
# Still adding it here to make sure we don't break the flow
|
|
||||||
_LOGGER.exception(
|
|
||||||
"Unexpected error in on_connect callbacks")
|
|
||||||
|
|
||||||
while not client.closed:
|
|
||||||
msg = yield from client.receive()
|
|
||||||
|
|
||||||
if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING):
|
|
||||||
break
|
|
||||||
|
|
||||||
elif msg.type == WSMsgType.ERROR:
|
|
||||||
disconnect_warn = 'Connection error'
|
|
||||||
break
|
|
||||||
|
|
||||||
elif msg.type != WSMsgType.TEXT:
|
|
||||||
disconnect_warn = 'Received non-Text message: {}'.format(
|
|
||||||
msg.type)
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = msg.json()
|
|
||||||
except ValueError:
|
|
||||||
disconnect_warn = 'Received invalid JSON.'
|
|
||||||
break
|
|
||||||
|
|
||||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
||||||
_LOGGER.debug("Received message:\n%s\n",
|
|
||||||
pprint.pformat(msg))
|
|
||||||
|
|
||||||
response_handler = self._response_handler.pop(msg['msgid'],
|
|
||||||
None)
|
|
||||||
|
|
||||||
if response_handler is not None:
|
|
||||||
if 'payload' in msg:
|
|
||||||
response_handler.set_result(msg["payload"])
|
|
||||||
else:
|
|
||||||
response_handler.set_exception(
|
|
||||||
ErrorMessage(msg['error']))
|
|
||||||
continue
|
|
||||||
|
|
||||||
response = {
|
|
||||||
'msgid': msg['msgid'],
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
result = yield from async_handle_message(
|
|
||||||
hass, self.cloud, msg['handler'], msg['payload'])
|
|
||||||
|
|
||||||
# No response from handler
|
|
||||||
if result is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
response['payload'] = result
|
|
||||||
|
|
||||||
except UnknownHandler:
|
|
||||||
response['error'] = 'unknown-handler'
|
|
||||||
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
_LOGGER.exception("Error handling message")
|
|
||||||
response['error'] = 'exception'
|
|
||||||
|
|
||||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
||||||
_LOGGER.debug("Publishing message:\n%s\n",
|
|
||||||
pprint.pformat(response))
|
|
||||||
yield from client.send_json(response)
|
|
||||||
|
|
||||||
except client_exceptions.WSServerHandshakeError as err:
|
|
||||||
if err.status == 401:
|
|
||||||
disconnect_warn = 'Invalid auth.'
|
|
||||||
self.close_requested = True
|
|
||||||
# Should we notify user?
|
|
||||||
else:
|
|
||||||
_LOGGER.warning("Unable to connect: %s", err)
|
|
||||||
|
|
||||||
except client_exceptions.ClientError as err:
|
|
||||||
_LOGGER.warning("Unable to connect: %s", err)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if disconnect_warn is None:
|
|
||||||
_LOGGER.info("Connection closed")
|
|
||||||
else:
|
|
||||||
_LOGGER.warning("Connection closed: %s", disconnect_warn)
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def disconnect(self):
|
|
||||||
"""Disconnect the client."""
|
|
||||||
self.close_requested = True
|
|
||||||
|
|
||||||
if self.client is not None:
|
|
||||||
yield from self.client.close()
|
|
||||||
elif self.retry_task is not None:
|
|
||||||
self.retry_task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
|
||||||
def async_handle_message(hass, cloud, handler_name, payload):
|
|
||||||
"""Handle incoming IoT message."""
|
|
||||||
handler = HANDLERS.get(handler_name)
|
|
||||||
|
|
||||||
if handler is None:
|
|
||||||
raise UnknownHandler()
|
|
||||||
|
|
||||||
return (yield from handler(hass, cloud, payload))
|
|
||||||
|
|
||||||
|
|
||||||
@HANDLERS.register('alexa')
|
|
||||||
@asyncio.coroutine
|
|
||||||
def async_handle_alexa(hass, cloud, payload):
|
|
||||||
"""Handle an incoming IoT message for Alexa."""
|
|
||||||
result = yield from alexa.async_handle_message(
|
|
||||||
hass, cloud.alexa_config, payload,
|
|
||||||
enabled=cloud.prefs.alexa_enabled)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@HANDLERS.register('google_actions')
|
|
||||||
@asyncio.coroutine
|
|
||||||
def async_handle_google_actions(hass, cloud, payload):
|
|
||||||
"""Handle an incoming IoT message for Google Actions."""
|
|
||||||
if not cloud.prefs.google_enabled:
|
|
||||||
return ga.turned_off_response(payload)
|
|
||||||
|
|
||||||
result = yield from ga.async_handle_message(
|
|
||||||
hass, cloud.gactions_config, payload)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@HANDLERS.register('cloud')
|
|
||||||
async def async_handle_cloud(hass, cloud, payload):
|
|
||||||
"""Handle an incoming IoT message for cloud component."""
|
|
||||||
action = payload['action']
|
|
||||||
|
|
||||||
if action == 'logout':
|
|
||||||
# Log out of Home Assistant Cloud
|
|
||||||
await cloud.logout()
|
|
||||||
_LOGGER.error("You have been logged out from Home Assistant cloud: %s",
|
|
||||||
payload['reason'])
|
|
||||||
else:
|
|
||||||
_LOGGER.warning("Received unknown cloud action: %s", action)
|
|
||||||
|
|
||||||
|
|
||||||
@HANDLERS.register('webhook')
|
|
||||||
async def async_handle_webhook(hass, cloud, payload):
|
|
||||||
"""Handle an incoming IoT message for cloud webhooks."""
|
|
||||||
cloudhook_id = payload['cloudhook_id']
|
|
||||||
|
|
||||||
found = None
|
|
||||||
for cloudhook in cloud.prefs.cloudhooks.values():
|
|
||||||
if cloudhook['cloudhook_id'] == cloudhook_id:
|
|
||||||
found = cloudhook
|
|
||||||
break
|
|
||||||
|
|
||||||
if found is None:
|
|
||||||
return {
|
|
||||||
'status': 200
|
|
||||||
}
|
|
||||||
|
|
||||||
request = MockRequest(
|
|
||||||
content=payload['body'].encode('utf-8'),
|
|
||||||
headers=payload['headers'],
|
|
||||||
method=payload['method'],
|
|
||||||
query_string=payload['query'],
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await hass.components.webhook.async_handle_webhook(
|
|
||||||
found['webhook_id'], request)
|
|
||||||
|
|
||||||
response_dict = utils.aiohttp_serialize_response(response)
|
|
||||||
body = response_dict.get('body')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'body': body,
|
|
||||||
'status': response_dict['status'],
|
|
||||||
'headers': {
|
|
||||||
'Content-Type': response.content_type
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,7 +1,10 @@
|
|||||||
"""Preference management for cloud."""
|
"""Preference management for cloud."""
|
||||||
|
from ipaddress import ip_address
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
|
||||||
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS)
|
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER,
|
||||||
|
InvalidTrustedNetworks)
|
||||||
|
|
||||||
STORAGE_KEY = DOMAIN
|
STORAGE_KEY = DOMAIN
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
@@ -13,6 +16,7 @@ class CloudPreferences:
|
|||||||
|
|
||||||
def __init__(self, hass):
|
def __init__(self, hass):
|
||||||
"""Initialize cloud prefs."""
|
"""Initialize cloud prefs."""
|
||||||
|
self._hass = hass
|
||||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||||
self._prefs = None
|
self._prefs = None
|
||||||
|
|
||||||
@@ -24,31 +28,52 @@ class CloudPreferences:
|
|||||||
prefs = {
|
prefs = {
|
||||||
PREF_ENABLE_ALEXA: True,
|
PREF_ENABLE_ALEXA: True,
|
||||||
PREF_ENABLE_GOOGLE: True,
|
PREF_ENABLE_GOOGLE: True,
|
||||||
|
PREF_ENABLE_REMOTE: False,
|
||||||
PREF_GOOGLE_ALLOW_UNLOCK: False,
|
PREF_GOOGLE_ALLOW_UNLOCK: False,
|
||||||
PREF_CLOUDHOOKS: {}
|
PREF_CLOUDHOOKS: {},
|
||||||
|
PREF_CLOUD_USER: None,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._prefs = prefs
|
self._prefs = prefs
|
||||||
|
|
||||||
async def async_update(self, *, google_enabled=_UNDEF,
|
async def async_update(self, *, google_enabled=_UNDEF,
|
||||||
alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF,
|
alexa_enabled=_UNDEF, remote_enabled=_UNDEF,
|
||||||
cloudhooks=_UNDEF):
|
google_allow_unlock=_UNDEF, cloudhooks=_UNDEF,
|
||||||
|
cloud_user=_UNDEF):
|
||||||
"""Update user preferences."""
|
"""Update user preferences."""
|
||||||
for key, value in (
|
for key, value in (
|
||||||
(PREF_ENABLE_GOOGLE, google_enabled),
|
(PREF_ENABLE_GOOGLE, google_enabled),
|
||||||
(PREF_ENABLE_ALEXA, alexa_enabled),
|
(PREF_ENABLE_ALEXA, alexa_enabled),
|
||||||
|
(PREF_ENABLE_REMOTE, remote_enabled),
|
||||||
(PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock),
|
(PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock),
|
||||||
(PREF_CLOUDHOOKS, cloudhooks),
|
(PREF_CLOUDHOOKS, cloudhooks),
|
||||||
|
(PREF_CLOUD_USER, cloud_user),
|
||||||
):
|
):
|
||||||
if value is not _UNDEF:
|
if value is not _UNDEF:
|
||||||
self._prefs[key] = value
|
self._prefs[key] = value
|
||||||
|
|
||||||
|
if remote_enabled is True and self._has_local_trusted_network:
|
||||||
|
raise InvalidTrustedNetworks
|
||||||
|
|
||||||
await self._store.async_save(self._prefs)
|
await self._store.async_save(self._prefs)
|
||||||
|
|
||||||
def as_dict(self):
|
def as_dict(self):
|
||||||
"""Return dictionary version."""
|
"""Return dictionary version."""
|
||||||
return self._prefs
|
return self._prefs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remote_enabled(self):
|
||||||
|
"""Return if remote is enabled on start."""
|
||||||
|
enabled = self._prefs.get(PREF_ENABLE_REMOTE, False)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._has_local_trusted_network:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alexa_enabled(self):
|
def alexa_enabled(self):
|
||||||
"""Return if Alexa is enabled."""
|
"""Return if Alexa is enabled."""
|
||||||
@@ -68,3 +93,24 @@ class CloudPreferences:
|
|||||||
def cloudhooks(self):
|
def cloudhooks(self):
|
||||||
"""Return the published cloud webhooks."""
|
"""Return the published cloud webhooks."""
|
||||||
return self._prefs.get(PREF_CLOUDHOOKS, {})
|
return self._prefs.get(PREF_CLOUDHOOKS, {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cloud_user(self) -> str:
|
||||||
|
"""Return ID from Home Assistant Cloud system user."""
|
||||||
|
return self._prefs.get(PREF_CLOUD_USER)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _has_local_trusted_network(self) -> bool:
|
||||||
|
"""Return if we allow localhost to bypass auth."""
|
||||||
|
local4 = ip_address('127.0.0.1')
|
||||||
|
local6 = ip_address('::1')
|
||||||
|
|
||||||
|
for prv in self._hass.auth.auth_providers:
|
||||||
|
if prv.type != 'trusted_networks':
|
||||||
|
continue
|
||||||
|
|
||||||
|
for network in prv.trusted_networks:
|
||||||
|
if local4 in network or local6 in network:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
7
homeassistant/components/cloud/services.yaml
Normal file
7
homeassistant/components/cloud/services.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Describes the format for available light services
|
||||||
|
|
||||||
|
remote_connect:
|
||||||
|
description: Make instance UI available outside over NabuCasa cloud.
|
||||||
|
|
||||||
|
remote_disconnect:
|
||||||
|
description: Disconnect UI from NabuCasa cloud.
|
@@ -1,13 +1,13 @@
|
|||||||
"""Component to configure Home Assistant via an API."""
|
"""Component to configure Home Assistant via an API."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import importlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID
|
from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID
|
||||||
from homeassistant.setup import (
|
from homeassistant.setup import ATTR_COMPONENT
|
||||||
async_prepare_setup_platform, ATTR_COMPONENT)
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http import HomeAssistantView
|
||||||
from homeassistant.util.yaml import load_yaml, dump
|
from homeassistant.util.yaml import load_yaml, dump
|
||||||
|
|
||||||
@@ -24,7 +24,6 @@ SECTIONS = (
|
|||||||
'device_registry',
|
'device_registry',
|
||||||
'entity_registry',
|
'entity_registry',
|
||||||
'group',
|
'group',
|
||||||
'hassbian',
|
|
||||||
'script',
|
'script',
|
||||||
)
|
)
|
||||||
ON_DEMAND = ('zwave',)
|
ON_DEMAND = ('zwave',)
|
||||||
@@ -37,8 +36,7 @@ async def async_setup(hass, config):
|
|||||||
|
|
||||||
async def setup_panel(panel_name):
|
async def setup_panel(panel_name):
|
||||||
"""Set up a panel."""
|
"""Set up a panel."""
|
||||||
panel = await async_prepare_setup_platform(
|
panel = importlib.import_module('.{}'.format(panel_name), __name__)
|
||||||
hass, config, DOMAIN, panel_name)
|
|
||||||
|
|
||||||
if not panel:
|
if not panel:
|
||||||
return
|
return
|
||||||
|
@@ -8,8 +8,6 @@ from homeassistant.core import callback
|
|||||||
from homeassistant.helpers.area_registry import async_get_registry
|
from homeassistant.helpers.area_registry import async_get_registry
|
||||||
|
|
||||||
|
|
||||||
DEPENDENCIES = ['websocket_api']
|
|
||||||
|
|
||||||
WS_TYPE_LIST = 'config/area_registry/list'
|
WS_TYPE_LIST = 'config/area_registry/list'
|
||||||
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||||
vol.Required('type'): WS_TYPE_LIST,
|
vol.Required('type'): WS_TYPE_LIST,
|
||||||
|
@@ -36,6 +36,7 @@ async def async_setup(hass):
|
|||||||
WS_TYPE_CREATE, websocket_create,
|
WS_TYPE_CREATE, websocket_create,
|
||||||
SCHEMA_WS_CREATE
|
SCHEMA_WS_CREATE
|
||||||
)
|
)
|
||||||
|
hass.components.websocket_api.async_register_command(websocket_update)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +85,40 @@ async def websocket_create(hass, connection, msg):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.async_response
|
||||||
|
@websocket_api.websocket_command({
|
||||||
|
vol.Required('type'): 'config/auth/update',
|
||||||
|
vol.Required('user_id'): str,
|
||||||
|
vol.Optional('name'): str,
|
||||||
|
vol.Optional('group_ids'): [str]
|
||||||
|
})
|
||||||
|
async def websocket_update(hass, connection, msg):
|
||||||
|
"""Update a user."""
|
||||||
|
user = await hass.auth.async_get_user(msg.pop('user_id'))
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
connection.send_message(websocket_api.error_message(
|
||||||
|
msg['id'], websocket_api.const.ERR_NOT_FOUND, 'User not found'))
|
||||||
|
return
|
||||||
|
|
||||||
|
if user.system_generated:
|
||||||
|
connection.send_message(websocket_api.error_message(
|
||||||
|
msg['id'], 'cannot_modify_system_generated',
|
||||||
|
'Unable to update system generated users.'))
|
||||||
|
return
|
||||||
|
|
||||||
|
msg.pop('type')
|
||||||
|
msg_id = msg.pop('id')
|
||||||
|
|
||||||
|
await hass.auth.async_update_user(user, **msg)
|
||||||
|
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.result_message(msg_id, {
|
||||||
|
'user': _user_info(user),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
def _user_info(user):
|
def _user_info(user):
|
||||||
"""Format a user."""
|
"""Format a user."""
|
||||||
return {
|
return {
|
||||||
|
@@ -122,7 +122,6 @@ async def websocket_delete(hass, connection, msg):
|
|||||||
websocket_api.result_message(msg['id']))
|
websocket_api.result_message(msg['id']))
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.require_admin
|
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
async def websocket_change_password(hass, connection, msg):
|
async def websocket_change_password(hass, connection, msg):
|
||||||
"""Change user password."""
|
"""Change user password."""
|
||||||
|
@@ -118,6 +118,16 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView):
|
|||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
return await super().post(request)
|
return await super().post(request)
|
||||||
|
|
||||||
|
def _prepare_result_json(self, result):
|
||||||
|
"""Convert result to JSON."""
|
||||||
|
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||||
|
return super()._prepare_result_json(result)
|
||||||
|
|
||||||
|
data = result.copy()
|
||||||
|
data['result'] = data['result'].entry_id
|
||||||
|
data.pop('data')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class ConfigManagerFlowResourceView(FlowManagerResourceView):
|
class ConfigManagerFlowResourceView(FlowManagerResourceView):
|
||||||
"""View to interact with the flow manager."""
|
"""View to interact with the flow manager."""
|
||||||
@@ -143,6 +153,16 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView):
|
|||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
return await super().post(request, flow_id)
|
return await super().post(request, flow_id)
|
||||||
|
|
||||||
|
def _prepare_result_json(self, result):
|
||||||
|
"""Convert result to JSON."""
|
||||||
|
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||||
|
return super()._prepare_result_json(result)
|
||||||
|
|
||||||
|
data = result.copy()
|
||||||
|
data['result'] = data['result'].entry_id
|
||||||
|
data.pop('data')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class ConfigManagerAvailableFlowView(HomeAssistantView):
|
class ConfigManagerAvailableFlowView(HomeAssistantView):
|
||||||
"""View to query available flows."""
|
"""View to query available flows."""
|
||||||
@@ -175,7 +195,7 @@ class OptionManagerFlowIndexView(FlowManagerIndexView):
|
|||||||
return await super().post(request)
|
return await super().post(request)
|
||||||
|
|
||||||
|
|
||||||
class OptionManagerFlowResourceView(ConfigManagerFlowResourceView):
|
class OptionManagerFlowResourceView(FlowManagerResourceView):
|
||||||
"""View to interact with the option flow manager."""
|
"""View to interact with the option flow manager."""
|
||||||
|
|
||||||
url = '/api/config/config_entries/options/flow/{flow_id}'
|
url = '/api/config/config_entries/options/flow/{flow_id}'
|
||||||
|
@@ -7,8 +7,6 @@ from homeassistant.components.websocket_api.decorators import (
|
|||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers.device_registry import async_get_registry
|
from homeassistant.helpers.device_registry import async_get_registry
|
||||||
|
|
||||||
DEPENDENCIES = ['websocket_api']
|
|
||||||
|
|
||||||
WS_TYPE_LIST = 'config/device_registry/list'
|
WS_TYPE_LIST = 'config/device_registry/list'
|
||||||
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||||
vol.Required('type'): WS_TYPE_LIST,
|
vol.Required('type'): WS_TYPE_LIST,
|
||||||
|
@@ -9,8 +9,6 @@ from homeassistant.components.websocket_api.decorators import (
|
|||||||
async_response, require_admin)
|
async_response, require_admin)
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
DEPENDENCIES = ['websocket_api']
|
|
||||||
|
|
||||||
WS_TYPE_LIST = 'config/entity_registry/list'
|
WS_TYPE_LIST = 'config/entity_registry/list'
|
||||||
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||||
vol.Required('type'): WS_TYPE_LIST,
|
vol.Required('type'): WS_TYPE_LIST,
|
||||||
|
@@ -1,86 +0,0 @@
|
|||||||
"""Component to interact with Hassbian tools."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
|
||||||
|
|
||||||
|
|
||||||
_TEST_OUTPUT = """
|
|
||||||
{
|
|
||||||
"suites":{
|
|
||||||
"libcec":{
|
|
||||||
"state":"Uninstalled",
|
|
||||||
"description":"Installs the libcec package for controlling CEC devices from this Pi"
|
|
||||||
},
|
|
||||||
"mosquitto":{
|
|
||||||
"state":"failed",
|
|
||||||
"description":"Installs the Mosquitto package for setting up a local MQTT server"
|
|
||||||
},
|
|
||||||
"openzwave":{
|
|
||||||
"state":"Uninstalled",
|
|
||||||
"description":"Installs the Open Z-wave package for setting up your zwave network"
|
|
||||||
},
|
|
||||||
"samba":{
|
|
||||||
"state":"installing",
|
|
||||||
"description":"Installs the samba package for sharing the hassbian configuration files over the Pi's network."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""" # noqa
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass):
|
|
||||||
"""Set up the Hassbian config."""
|
|
||||||
# Test if is Hassbian
|
|
||||||
test_mode = 'FORCE_HASSBIAN' in os.environ
|
|
||||||
is_hassbian = test_mode
|
|
||||||
|
|
||||||
if not is_hassbian:
|
|
||||||
return False
|
|
||||||
|
|
||||||
hass.http.register_view(HassbianSuitesView(test_mode))
|
|
||||||
hass.http.register_view(HassbianSuiteInstallView(test_mode))
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def hassbian_status(hass, test_mode=False):
|
|
||||||
"""Query for the Hassbian status."""
|
|
||||||
# Fetch real output when not in test mode
|
|
||||||
if test_mode:
|
|
||||||
return json.loads(_TEST_OUTPUT)
|
|
||||||
|
|
||||||
raise Exception('Real mode not implemented yet.')
|
|
||||||
|
|
||||||
|
|
||||||
class HassbianSuitesView(HomeAssistantView):
|
|
||||||
"""Hassbian packages endpoint."""
|
|
||||||
|
|
||||||
url = '/api/config/hassbian/suites'
|
|
||||||
name = 'api:config:hassbian:suites'
|
|
||||||
|
|
||||||
def __init__(self, test_mode):
|
|
||||||
"""Initialize suites view."""
|
|
||||||
self._test_mode = test_mode
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
"""Request suite status."""
|
|
||||||
inp = await hassbian_status(request.app['hass'], self._test_mode)
|
|
||||||
|
|
||||||
return self.json(inp['suites'])
|
|
||||||
|
|
||||||
|
|
||||||
class HassbianSuiteInstallView(HomeAssistantView):
|
|
||||||
"""Hassbian packages endpoint."""
|
|
||||||
|
|
||||||
url = '/api/config/hassbian/suites/{suite}/install'
|
|
||||||
name = 'api:config:hassbian:suite'
|
|
||||||
|
|
||||||
def __init__(self, test_mode):
|
|
||||||
"""Initialize suite view."""
|
|
||||||
self._test_mode = test_mode
|
|
||||||
|
|
||||||
async def post(self, request, suite):
|
|
||||||
"""Request suite status."""
|
|
||||||
# do real install if not in test mode
|
|
||||||
return self.json({"status": "ok"})
|
|
1
homeassistant/components/cppm_tracker/__init__.py
Normal file
1
homeassistant/components/cppm_tracker/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Add support for ClearPass Policy Manager."""
|
85
homeassistant/components/cppm_tracker/device_tracker.py
Executable file
85
homeassistant/components/cppm_tracker/device_tracker.py
Executable file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Support for ClearPass Policy Manager.
|
||||||
|
|
||||||
|
Allows tracking devices with CPPM.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.components.device_tracker import (
|
||||||
|
PLATFORM_SCHEMA, DeviceScanner, DOMAIN
|
||||||
|
)
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_HOST, CONF_API_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIREMENTS = ['clearpasspy==1.0.2']
|
||||||
|
|
||||||
|
SCAN_INTERVAL = timedelta(seconds=120)
|
||||||
|
|
||||||
|
CLIENT_ID = 'client_id'
|
||||||
|
|
||||||
|
GRANT_TYPE = 'client_credentials'
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
|
vol.Required(CONF_HOST): cv.string,
|
||||||
|
vol.Required(CLIENT_ID): cv.string,
|
||||||
|
vol.Required(CONF_API_KEY): cv.string,
|
||||||
|
})
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_scanner(hass, config):
|
||||||
|
"""Initialize Scanner."""
|
||||||
|
from clearpasspy import ClearPass
|
||||||
|
data = {
|
||||||
|
'server': config[DOMAIN][CONF_HOST],
|
||||||
|
'grant_type': GRANT_TYPE,
|
||||||
|
'secret': config[DOMAIN][CONF_API_KEY],
|
||||||
|
'client': config[DOMAIN][CLIENT_ID]
|
||||||
|
}
|
||||||
|
cppm = ClearPass(data)
|
||||||
|
if cppm.access_token is None:
|
||||||
|
return None
|
||||||
|
_LOGGER.debug("Successfully received Access Token")
|
||||||
|
return CPPMDeviceScanner(cppm)
|
||||||
|
|
||||||
|
|
||||||
|
class CPPMDeviceScanner(DeviceScanner):
|
||||||
|
"""Initialize class."""
|
||||||
|
|
||||||
|
def __init__(self, cppm):
|
||||||
|
"""Initialize class."""
|
||||||
|
self._cppm = cppm
|
||||||
|
self.results = None
|
||||||
|
|
||||||
|
def scan_devices(self):
|
||||||
|
"""Initialize scanner."""
|
||||||
|
self.get_cppm_data()
|
||||||
|
return [device['mac'] for device in self.results]
|
||||||
|
|
||||||
|
def get_device_name(self, device):
|
||||||
|
"""Retrieve device name."""
|
||||||
|
name = next((
|
||||||
|
result['name'] for result in self.results
|
||||||
|
if result['mac'] == device), None)
|
||||||
|
return name
|
||||||
|
|
||||||
|
def get_cppm_data(self):
|
||||||
|
"""Retrieve data from Aruba Clearpass and return parsed result."""
|
||||||
|
endpoints = self._cppm.get_endpoints(100)['_embedded']['items']
|
||||||
|
devices = []
|
||||||
|
for item in endpoints:
|
||||||
|
if self._cppm.online_status(item['mac_address']):
|
||||||
|
device = {
|
||||||
|
'mac': item['mac_address'],
|
||||||
|
'name': item['mac_address']
|
||||||
|
}
|
||||||
|
devices.append(device)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
_LOGGER.debug("Devices: %s", devices)
|
||||||
|
self.results = devices
|
19
homeassistant/components/daikin/.translations/fr.json
Normal file
19
homeassistant/components/daikin/.translations/fr.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
|
||||||
|
"device_fail": "Erreur inattendue lors de la cr\u00e9ation du p\u00e9riph\u00e9rique.",
|
||||||
|
"device_timeout": "D\u00e9lai de connexion au p\u00e9riph\u00e9rique expir\u00e9."
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "H\u00f4te"
|
||||||
|
},
|
||||||
|
"description": "Entrez l'adresse IP de votre Daikin AC.",
|
||||||
|
"title": "Configurer Daikin AC"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Daikin AC"
|
||||||
|
}
|
||||||
|
}
|
12
homeassistant/components/daikin/.translations/th.json
Normal file
12
homeassistant/components/daikin/.translations/th.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Daikin AC"
|
||||||
|
}
|
||||||
|
}
|
@@ -10,7 +10,7 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"host": "\u4e3b\u673a"
|
"host": "\u4e3b\u673a"
|
||||||
},
|
},
|
||||||
"description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684IP\u5730\u5740\u3002",
|
"description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684 IP \u5730\u5740\u3002",
|
||||||
"title": "\u914d\u7f6e Daikin \u7a7a\u8c03"
|
"title": "\u914d\u7f6e Daikin \u7a7a\u8c03"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@@ -17,7 +17,7 @@
|
|||||||
"title": "Definiere das deCONZ-Gateway"
|
"title": "Definiere das deCONZ-Gateway"
|
||||||
},
|
},
|
||||||
"link": {
|
"link": {
|
||||||
"description": "Entsperre dein deCONZ-Gateway, um dich bei Home Assistant zu registrieren. \n\n 1. Gehe zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"",
|
"description": "Entsperre dein deCONZ-Gateway, um es bei Home Assistant zu registrieren. \n\n 1. Gehe in die deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"",
|
||||||
"title": "Mit deCONZ verbinden"
|
"title": "Mit deCONZ verbinden"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
@@ -12,12 +12,12 @@
|
|||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Poort (standaard: '80')"
|
"port": "Poort"
|
||||||
},
|
},
|
||||||
"title": "Definieer deCONZ gateway"
|
"title": "Definieer deCONZ gateway"
|
||||||
},
|
},
|
||||||
"link": {
|
"link": {
|
||||||
"description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen\n2. Druk op de knop \"Gateway ontgrendelen\"",
|
"description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen (Instellingen -> Gateway -> Geavanceerd)\n2. Druk op de knop \"Gateway ontgrendelen\"",
|
||||||
"title": "Koppel met deCONZ"
|
"title": "Koppel met deCONZ"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
@@ -6,7 +6,6 @@ DEPENDENCIES = (
|
|||||||
'cloud',
|
'cloud',
|
||||||
'config',
|
'config',
|
||||||
'conversation',
|
'conversation',
|
||||||
'discovery',
|
|
||||||
'frontend',
|
'frontend',
|
||||||
'history',
|
'history',
|
||||||
'logbook',
|
'logbook',
|
||||||
@@ -17,6 +16,7 @@ DEPENDENCIES = (
|
|||||||
'sun',
|
'sun',
|
||||||
'system_health',
|
'system_health',
|
||||||
'updater',
|
'updater',
|
||||||
|
'zeroconf',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -41,17 +41,17 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None):
|
|||||||
|
|
||||||
for dev_id, topic in devices.items():
|
for dev_id, topic in devices.items():
|
||||||
@callback
|
@callback
|
||||||
def async_message_received(topic, payload, qos, dev_id=dev_id):
|
def async_message_received(msg, dev_id=dev_id):
|
||||||
"""Handle received MQTT message."""
|
"""Handle received MQTT message."""
|
||||||
try:
|
try:
|
||||||
data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload))
|
data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(msg.payload))
|
||||||
except vol.MultipleInvalid:
|
except vol.MultipleInvalid:
|
||||||
_LOGGER.error("Skipping update for following data "
|
_LOGGER.error("Skipping update for following data "
|
||||||
"because of missing or malformatted data: %s",
|
"because of missing or malformatted data: %s",
|
||||||
payload)
|
msg.payload)
|
||||||
return
|
return
|
||||||
except ValueError:
|
except ValueError:
|
||||||
_LOGGER.error("Error parsing JSON payload: %s", payload)
|
_LOGGER.error("Error parsing JSON payload: %s", msg.payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
kwargs = _parse_see_args(dev_id, data)
|
kwargs = _parse_see_args(dev_id, data)
|
||||||
|
@@ -11,10 +11,10 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA,
|
from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA,
|
||||||
DeviceScanner)
|
DeviceScanner)
|
||||||
from homeassistant.const import (CONF_HOST, CONF_PASSWORD)
|
from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_SSL)
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
REQUIREMENTS = ['quantum-gateway==0.0.3']
|
REQUIREMENTS = ['quantum-gateway==0.0.5']
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ DEFAULT_HOST = 'myfiosgateway.com'
|
|||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||||
|
vol.Optional(CONF_SSL, default=True): cv.boolean,
|
||||||
vol.Required(CONF_PASSWORD): cv.string
|
vol.Required(CONF_PASSWORD): cv.string
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,10 +43,12 @@ class QuantumGatewayDeviceScanner(DeviceScanner):
|
|||||||
|
|
||||||
self.host = config[CONF_HOST]
|
self.host = config[CONF_HOST]
|
||||||
self.password = config[CONF_PASSWORD]
|
self.password = config[CONF_PASSWORD]
|
||||||
|
self.use_https = config[CONF_SSL]
|
||||||
_LOGGER.debug('Initializing')
|
_LOGGER.debug('Initializing')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.quantum = QuantumGatewayScanner(self.host, self.password)
|
self.quantum = QuantumGatewayScanner(self.host, self.password,
|
||||||
|
self.use_https)
|
||||||
self.success_init = self.quantum.success_init
|
self.success_init = self.quantum.success_init
|
||||||
except RequestException:
|
except RequestException:
|
||||||
self.success_init = False
|
self.success_init = False
|
||||||
|
@@ -18,7 +18,7 @@ from homeassistant.util import slugify
|
|||||||
from homeassistant.util.json import load_json, save_json
|
from homeassistant.util.json import load_json, save_json
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
REQUIREMENTS = ['pytile==2.0.5']
|
REQUIREMENTS = ['pytile==2.0.6']
|
||||||
|
|
||||||
CLIENT_UUID_CONFIG_FILE = '.tile.conf'
|
CLIENT_UUID_CONFIG_FILE = '.tile.conf'
|
||||||
DEVICE_TYPES = ['PHONE', 'TILE']
|
DEVICE_TYPES = ['PHONE', 'TILE']
|
||||||
|
@@ -43,6 +43,8 @@ AVAILABLE_ATTRS = [
|
|||||||
'uptime', 'user_id', 'usergroup_id', 'vlan'
|
'uptime', 'user_id', 'usergroup_id', 'vlan'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TIMESTAMP_ATTRS = ['first_seen', 'last_seen', 'latest_assoc_time']
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||||
vol.Optional(CONF_SITE_ID, default='default'): cv.string,
|
vol.Optional(CONF_SITE_ID, default='default'): cv.string,
|
||||||
@@ -149,7 +151,12 @@ class UnifiScanner(DeviceScanner):
|
|||||||
attributes = {}
|
attributes = {}
|
||||||
for variable in self._monitored_conditions:
|
for variable in self._monitored_conditions:
|
||||||
if variable in client:
|
if variable in client:
|
||||||
attributes[variable] = client[variable]
|
if variable in TIMESTAMP_ATTRS:
|
||||||
|
attributes[variable] = dt_util.utc_from_timestamp(
|
||||||
|
float(client[variable])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
attributes[variable] = client[variable]
|
||||||
|
|
||||||
_LOGGER.debug("Device mac %s attributes %s", device, attributes)
|
_LOGGER.debug("Device mac %s attributes %s", device, attributes)
|
||||||
return attributes
|
return attributes
|
||||||
|
58
homeassistant/components/device_tracker/xfinity.py
Normal file
58
homeassistant/components/device_tracker/xfinity.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Support for device tracking via Xfinity Gateways."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.components.device_tracker import (
|
||||||
|
DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
|
||||||
|
REQUIREMENTS = ['xfinity-gateway==0.0.4']
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_HOST = '10.0.0.1'
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
|
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_scanner(hass, config):
|
||||||
|
"""Validate the configuration and return an Xfinity Gateway scanner."""
|
||||||
|
from xfinity_gateway import XfinityGateway
|
||||||
|
|
||||||
|
gateway = XfinityGateway(config[DOMAIN][CONF_HOST])
|
||||||
|
scanner = None
|
||||||
|
try:
|
||||||
|
gateway.scan_devices()
|
||||||
|
scanner = XfinityDeviceScanner(gateway)
|
||||||
|
except (RequestException, ValueError):
|
||||||
|
_LOGGER.error("Error communicating with Xfinity Gateway. "
|
||||||
|
"Check host: %s", gateway.host)
|
||||||
|
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
|
||||||
|
class XfinityDeviceScanner(DeviceScanner):
|
||||||
|
"""This class queries an Xfinity Gateway."""
|
||||||
|
|
||||||
|
def __init__(self, gateway):
|
||||||
|
"""Initialize the scanner."""
|
||||||
|
self.gateway = gateway
|
||||||
|
|
||||||
|
def scan_devices(self):
|
||||||
|
"""Scan for new devices and return a list of found MACs."""
|
||||||
|
connected_devices = []
|
||||||
|
try:
|
||||||
|
connected_devices = self.gateway.scan_devices()
|
||||||
|
except (RequestException, ValueError):
|
||||||
|
_LOGGER.error("Unable to scan devices. "
|
||||||
|
"Check connection to gateway")
|
||||||
|
return connected_devices
|
||||||
|
|
||||||
|
def get_device_name(self, device):
|
||||||
|
"""Return the name of the given device or None if we don't know."""
|
||||||
|
return self.gateway.get_device_name(device)
|
@@ -9,7 +9,6 @@ loaded before the EVENT_PLATFORM_DISCOVERED is fired.
|
|||||||
import json
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time
|
|||||||
from homeassistant.helpers.discovery import async_load_platform, async_discover
|
from homeassistant.helpers.discovery import async_load_platform, async_discover
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
REQUIREMENTS = ['netdisco==2.3.0']
|
REQUIREMENTS = ['netdisco==2.5.0']
|
||||||
|
|
||||||
DOMAIN = 'discovery'
|
DOMAIN = 'discovery'
|
||||||
|
|
||||||
@@ -31,6 +30,7 @@ SERVICE_AXIS = 'axis'
|
|||||||
SERVICE_DAIKIN = 'daikin'
|
SERVICE_DAIKIN = 'daikin'
|
||||||
SERVICE_DECONZ = 'deconz'
|
SERVICE_DECONZ = 'deconz'
|
||||||
SERVICE_DLNA_DMR = 'dlna_dmr'
|
SERVICE_DLNA_DMR = 'dlna_dmr'
|
||||||
|
SERVICE_ENIGMA2 = 'enigma2'
|
||||||
SERVICE_FREEBOX = 'freebox'
|
SERVICE_FREEBOX = 'freebox'
|
||||||
SERVICE_HASS_IOS_APP = 'hass_ios'
|
SERVICE_HASS_IOS_APP = 'hass_ios'
|
||||||
SERVICE_HASSIO = 'hassio'
|
SERVICE_HASSIO = 'hassio'
|
||||||
@@ -39,6 +39,7 @@ SERVICE_HUE = 'philips_hue'
|
|||||||
SERVICE_IGD = 'igd'
|
SERVICE_IGD = 'igd'
|
||||||
SERVICE_IKEA_TRADFRI = 'ikea_tradfri'
|
SERVICE_IKEA_TRADFRI = 'ikea_tradfri'
|
||||||
SERVICE_KONNECTED = 'konnected'
|
SERVICE_KONNECTED = 'konnected'
|
||||||
|
SERVICE_MOBILE_APP = 'hass_mobile_app'
|
||||||
SERVICE_NETGEAR = 'netgear_router'
|
SERVICE_NETGEAR = 'netgear_router'
|
||||||
SERVICE_OCTOPRINT = 'octoprint'
|
SERVICE_OCTOPRINT = 'octoprint'
|
||||||
SERVICE_ROKU = 'roku'
|
SERVICE_ROKU = 'roku'
|
||||||
@@ -62,12 +63,14 @@ CONFIG_ENTRY_HANDLERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SERVICE_HANDLERS = {
|
SERVICE_HANDLERS = {
|
||||||
|
SERVICE_MOBILE_APP: ('mobile_app', None),
|
||||||
SERVICE_HASS_IOS_APP: ('ios', None),
|
SERVICE_HASS_IOS_APP: ('ios', None),
|
||||||
SERVICE_NETGEAR: ('device_tracker', None),
|
SERVICE_NETGEAR: ('device_tracker', None),
|
||||||
SERVICE_WEMO: ('wemo', None),
|
SERVICE_WEMO: ('wemo', None),
|
||||||
SERVICE_HASSIO: ('hassio', None),
|
SERVICE_HASSIO: ('hassio', None),
|
||||||
SERVICE_AXIS: ('axis', None),
|
SERVICE_AXIS: ('axis', None),
|
||||||
SERVICE_APPLE_TV: ('apple_tv', None),
|
SERVICE_APPLE_TV: ('apple_tv', None),
|
||||||
|
SERVICE_ENIGMA2: ('media_player', 'enigma2'),
|
||||||
SERVICE_ROKU: ('roku', None),
|
SERVICE_ROKU: ('roku', None),
|
||||||
SERVICE_WINK: ('wink', None),
|
SERVICE_WINK: ('wink', None),
|
||||||
SERVICE_XIAOMI_GW: ('xiaomi_aqara', None),
|
SERVICE_XIAOMI_GW: ('xiaomi_aqara', None),
|
||||||
@@ -93,7 +96,7 @@ SERVICE_HANDLERS = {
|
|||||||
'kodi': ('media_player', 'kodi'),
|
'kodi': ('media_player', 'kodi'),
|
||||||
'volumio': ('media_player', 'volumio'),
|
'volumio': ('media_player', 'volumio'),
|
||||||
'lg_smart_device': ('media_player', 'lg_soundbar'),
|
'lg_smart_device': ('media_player', 'lg_soundbar'),
|
||||||
'nanoleaf_aurora': ('light', 'nanoleaf_aurora'),
|
'nanoleaf_aurora': ('light', 'nanoleaf'),
|
||||||
}
|
}
|
||||||
|
|
||||||
OPTIONAL_SERVICE_HANDLERS = {
|
OPTIONAL_SERVICE_HANDLERS = {
|
||||||
@@ -195,10 +198,6 @@ async def async_setup(hass, config):
|
|||||||
"""Schedule the first discovery when Home Assistant starts up."""
|
"""Schedule the first discovery when Home Assistant starts up."""
|
||||||
async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow())
|
async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow())
|
||||||
|
|
||||||
# Discovery for local services
|
|
||||||
if 'HASSIO' in os.environ:
|
|
||||||
hass.async_create_task(new_service_found(SERVICE_HASSIO, {}))
|
|
||||||
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
6
homeassistant/components/ebusd/.translations/nl.json
Normal file
6
homeassistant/components/ebusd/.translations/nl.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"state": {
|
||||||
|
"day": "Dag",
|
||||||
|
"night": "Nacht"
|
||||||
|
}
|
||||||
|
}
|
5
homeassistant/components/ebusd/.translations/th.json
Normal file
5
homeassistant/components/ebusd/.translations/th.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"state": {
|
||||||
|
"night": "\u0e01\u0e25\u0e32\u0e07\u0e04\u0e37\u0e19"
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,6 @@
|
|||||||
"""Constants for ebus component."""
|
"""Constants for ebus component."""
|
||||||
|
from homeassistant.const import ENERGY_KILO_WATT_HOUR
|
||||||
|
|
||||||
DOMAIN = 'ebusd'
|
DOMAIN = 'ebusd'
|
||||||
|
|
||||||
# SensorTypes:
|
# SensorTypes:
|
||||||
@@ -67,9 +69,9 @@ SENSOR_TYPES = {
|
|||||||
'ContinuosHeating':
|
'ContinuosHeating':
|
||||||
['ContinuosHeating', '°C', 'mdi:weather-snowy', 0],
|
['ContinuosHeating', '°C', 'mdi:weather-snowy', 0],
|
||||||
'PowerEnergyConsumptionLastMonth':
|
'PowerEnergyConsumptionLastMonth':
|
||||||
['PrEnergySumHcLastMonth', 'kWh', 'mdi:flash', 0],
|
['PrEnergySumHcLastMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
|
||||||
'PowerEnergyConsumptionThisMonth':
|
'PowerEnergyConsumptionThisMonth':
|
||||||
['PrEnergySumHcThisMonth', 'kWh', 'mdi:flash', 0]
|
['PrEnergySumHcThisMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0]
|
||||||
},
|
},
|
||||||
'ehp': {
|
'ehp': {
|
||||||
'HWTemperature':
|
'HWTemperature':
|
||||||
@@ -89,12 +91,12 @@ SENSOR_TYPES = {
|
|||||||
'Flame':
|
'Flame':
|
||||||
['Flame', None, 'mdi:toggle-switch', 2],
|
['Flame', None, 'mdi:toggle-switch', 2],
|
||||||
'PowerEnergyConsumptionHeatingCircuit':
|
'PowerEnergyConsumptionHeatingCircuit':
|
||||||
['PrEnergySumHc1', 'kWh', 'mdi:flash', 0],
|
['PrEnergySumHc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
|
||||||
'PowerEnergyConsumptionHotWaterCircuit':
|
'PowerEnergyConsumptionHotWaterCircuit':
|
||||||
['PrEnergySumHwc1', 'kWh', 'mdi:flash', 0],
|
['PrEnergySumHwc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
|
||||||
'RoomThermostat':
|
'RoomThermostat':
|
||||||
['DCRoomthermostat', None, 'mdi:toggle-switch', 2],
|
['DCRoomthermostat', None, 'mdi:toggle-switch', 2],
|
||||||
'HeatingPartLoad':
|
'HeatingPartLoad':
|
||||||
['PartloadHcKW', 'kWh', 'mdi:flash', 0]
|
['PartloadHcKW', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.const import POWER_WATT
|
||||||
|
|
||||||
from homeassistant.components.edp_redy import EdpRedyDevice, EDP_REDY
|
from homeassistant.components.edp_redy import EdpRedyDevice, EDP_REDY
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ async def async_setup_platform(
|
|||||||
|
|
||||||
# Create a sensor for global active power
|
# Create a sensor for global active power
|
||||||
devices.append(EdpRedySensor(session, ACTIVE_POWER_ID, "Power Home",
|
devices.append(EdpRedySensor(session, ACTIVE_POWER_ID, "Power Home",
|
||||||
'mdi:flash', 'W'))
|
'mdi:flash', POWER_WATT))
|
||||||
|
|
||||||
async_add_entities(devices, True)
|
async_add_entities(devices, True)
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ class EdpRedyModuleSensor(EdpRedyDevice, Entity):
|
|||||||
@property
|
@property
|
||||||
def unit_of_measurement(self):
|
def unit_of_measurement(self):
|
||||||
"""Return the unit of measurement of this sensor."""
|
"""Return the unit of measurement of this sensor."""
|
||||||
return 'W'
|
return POWER_WATT
|
||||||
|
|
||||||
async def async_update(self):
|
async def async_update(self):
|
||||||
"""Parse the data for this sensor."""
|
"""Parse the data for this sensor."""
|
||||||
|
@@ -5,13 +5,16 @@ from aiohttp import web
|
|||||||
|
|
||||||
from homeassistant import core
|
from homeassistant import core
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET,
|
ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||||
SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON, STATE_OFF,
|
SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON,
|
||||||
HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES,
|
STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES
|
||||||
)
|
)
|
||||||
from homeassistant.components.light import (
|
from homeassistant.components.light import (
|
||||||
ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS
|
ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.climate.const import (
|
||||||
|
SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE
|
||||||
|
)
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET,
|
ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET,
|
||||||
)
|
)
|
||||||
@@ -26,7 +29,7 @@ from homeassistant.components.cover import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.components import (
|
from homeassistant.components import (
|
||||||
cover, fan, media_player, light, script, scene
|
climate, cover, fan, media_player, light, script, scene
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http import HomeAssistantView
|
||||||
@@ -262,6 +265,18 @@ class HueOneLightChangeView(HomeAssistantView):
|
|||||||
if brightness is not None:
|
if brightness is not None:
|
||||||
data['variables']['requested_level'] = brightness
|
data['variables']['requested_level'] = brightness
|
||||||
|
|
||||||
|
# If the requested entity is a climate, set the temperature
|
||||||
|
elif entity.domain == climate.DOMAIN:
|
||||||
|
# We don't support turning climate devices on or off,
|
||||||
|
# only setting the temperature
|
||||||
|
service = None
|
||||||
|
|
||||||
|
if entity_features & SUPPORT_TARGET_TEMPERATURE:
|
||||||
|
if brightness is not None:
|
||||||
|
domain = entity.domain
|
||||||
|
service = SERVICE_SET_TEMPERATURE
|
||||||
|
data[ATTR_TEMPERATURE] = brightness
|
||||||
|
|
||||||
# If the requested entity is a media player, convert to volume
|
# If the requested entity is a media player, convert to volume
|
||||||
elif entity.domain == media_player.DOMAIN:
|
elif entity.domain == media_player.DOMAIN:
|
||||||
if entity_features & SUPPORT_VOLUME_SET:
|
if entity_features & SUPPORT_VOLUME_SET:
|
||||||
@@ -318,8 +333,9 @@ class HueOneLightChangeView(HomeAssistantView):
|
|||||||
core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id},
|
core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id},
|
||||||
blocking=True))
|
blocking=True))
|
||||||
|
|
||||||
hass.async_create_task(hass.services.async_call(
|
if service is not None:
|
||||||
domain, service, data, blocking=True))
|
hass.async_create_task(hass.services.async_call(
|
||||||
|
domain, service, data, blocking=True))
|
||||||
|
|
||||||
json_response = \
|
json_response = \
|
||||||
[create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
|
[create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
|
||||||
@@ -371,7 +387,7 @@ def parse_hue_api_put_light_body(request_json, entity):
|
|||||||
|
|
||||||
elif entity.domain in [
|
elif entity.domain in [
|
||||||
script.DOMAIN, media_player.DOMAIN,
|
script.DOMAIN, media_player.DOMAIN,
|
||||||
fan.DOMAIN, cover.DOMAIN]:
|
fan.DOMAIN, cover.DOMAIN, climate.DOMAIN]:
|
||||||
# Convert 0-255 to 0-100
|
# Convert 0-255 to 0-100
|
||||||
level = brightness / 255 * 100
|
level = brightness / 255 * 100
|
||||||
brightness = round(level)
|
brightness = round(level)
|
||||||
@@ -397,6 +413,10 @@ def get_entity_state(config, entity):
|
|||||||
if entity_features & SUPPORT_BRIGHTNESS:
|
if entity_features & SUPPORT_BRIGHTNESS:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
elif entity.domain == climate.DOMAIN:
|
||||||
|
temperature = entity.attributes.get(ATTR_TEMPERATURE, 0)
|
||||||
|
# Convert 0-100 to 0-255
|
||||||
|
final_brightness = round(temperature * 255 / 100)
|
||||||
elif entity.domain == media_player.DOMAIN:
|
elif entity.domain == media_player.DOMAIN:
|
||||||
level = entity.attributes.get(
|
level = entity.attributes.get(
|
||||||
ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0)
|
ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0)
|
||||||
|
12
homeassistant/components/emulated_roku/.translations/th.json
Normal file
12
homeassistant/components/emulated_roku/.translations/th.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host_ip": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c IP",
|
||||||
|
"name": "\u0e0a\u0e37\u0e48\u0e2d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -6,7 +6,9 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host_ip": "\u4e3b\u673aIP",
|
"advertise_ip": "\u5e7f\u64ad IP",
|
||||||
|
"advertise_port": "\u5e7f\u64ad\u7aef\u53e3",
|
||||||
|
"host_ip": "\u4e3b\u673a IP",
|
||||||
"listen_port": "\u76d1\u542c\u7aef\u53e3",
|
"listen_port": "\u76d1\u542c\u7aef\u53e3",
|
||||||
"name": "\u59d3\u540d"
|
"name": "\u59d3\u540d"
|
||||||
},
|
},
|
||||||
|
1
homeassistant/components/enigma2/__init__.py
Normal file
1
homeassistant/components/enigma2/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Support for Enigma2 devices."""
|
225
homeassistant/components/enigma2/media_player.py
Normal file
225
homeassistant/components/enigma2/media_player.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Support for Enigma2 media players."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.media_player import MediaPlayerDevice
|
||||||
|
from homeassistant.helpers.config_validation import (PLATFORM_SCHEMA)
|
||||||
|
from homeassistant.components.media_player.const import (
|
||||||
|
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON,
|
||||||
|
SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP,
|
||||||
|
SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP, MEDIA_TYPE_TVSHOW)
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_SSL,
|
||||||
|
STATE_OFF, STATE_ON, STATE_PLAYING, CONF_PORT)
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
|
REQUIREMENTS = ['openwebifpy==1.2.7']
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ATTR_MEDIA_CURRENTLY_RECORDING = 'media_currently_recording'
|
||||||
|
ATTR_MEDIA_DESCRIPTION = 'media_description'
|
||||||
|
ATTR_MEDIA_END_TIME = 'media_end_time'
|
||||||
|
ATTR_MEDIA_START_TIME = 'media_start_time'
|
||||||
|
|
||||||
|
CONF_USE_CHANNEL_ICON = "use_channel_icon"
|
||||||
|
|
||||||
|
DEFAULT_NAME = 'Enigma2 Media Player'
|
||||||
|
DEFAULT_PORT = 80
|
||||||
|
DEFAULT_SSL = False
|
||||||
|
DEFAULT_USE_CHANNEL_ICON = False
|
||||||
|
DEFAULT_USERNAME = 'root'
|
||||||
|
DEFAULT_PASSWORD = 'dreambox'
|
||||||
|
|
||||||
|
SUPPORTED_ENIGMA2 = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||||
|
SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | SUPPORT_STOP | \
|
||||||
|
SUPPORT_PREVIOUS_TRACK | SUPPORT_VOLUME_STEP | \
|
||||||
|
SUPPORT_TURN_ON | SUPPORT_PAUSE | SUPPORT_SELECT_SOURCE
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
|
vol.Required(CONF_HOST): cv.string,
|
||||||
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||||
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||||
|
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
|
||||||
|
vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
|
||||||
|
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
||||||
|
vol.Optional(CONF_USE_CHANNEL_ICON,
|
||||||
|
default=DEFAULT_USE_CHANNEL_ICON): cv.boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||||
|
"""Set up of an enigma2 media player."""
|
||||||
|
if discovery_info:
|
||||||
|
# Discovery gives us the streaming service port (8001)
|
||||||
|
# which is not useful as OpenWebif never runs on that port.
|
||||||
|
# So use the default port instead.
|
||||||
|
config[CONF_PORT] = DEFAULT_PORT
|
||||||
|
config[CONF_NAME] = discovery_info['hostname']
|
||||||
|
config[CONF_HOST] = discovery_info['host']
|
||||||
|
config[CONF_USERNAME] = DEFAULT_USERNAME
|
||||||
|
config[CONF_PASSWORD] = DEFAULT_PASSWORD
|
||||||
|
config[CONF_SSL] = DEFAULT_SSL
|
||||||
|
config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON
|
||||||
|
|
||||||
|
from openwebif.api import CreateDevice
|
||||||
|
device = \
|
||||||
|
CreateDevice(host=config[CONF_HOST],
|
||||||
|
port=config.get(CONF_PORT),
|
||||||
|
username=config.get(CONF_USERNAME),
|
||||||
|
password=config.get(CONF_PASSWORD),
|
||||||
|
is_https=config.get(CONF_SSL),
|
||||||
|
prefer_picon=config.get(CONF_USE_CHANNEL_ICON))
|
||||||
|
|
||||||
|
add_devices([Enigma2Device(config[CONF_NAME], device)], True)
|
||||||
|
|
||||||
|
|
||||||
|
class Enigma2Device(MediaPlayerDevice):
|
||||||
|
"""Representation of an Enigma2 box."""
|
||||||
|
|
||||||
|
def __init__(self, name, device):
|
||||||
|
"""Initialize the Enigma2 device."""
|
||||||
|
self._name = name
|
||||||
|
self.e2_box = device
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the name of the device."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of the device."""
|
||||||
|
if self.e2_box.is_recording_playback:
|
||||||
|
return STATE_PLAYING
|
||||||
|
return STATE_OFF if self.e2_box.in_standby else STATE_ON
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self):
|
||||||
|
"""Flag of media commands that are supported."""
|
||||||
|
return SUPPORTED_ENIGMA2
|
||||||
|
|
||||||
|
def turn_off(self):
|
||||||
|
"""Turn off media player."""
|
||||||
|
self.e2_box.turn_off()
|
||||||
|
|
||||||
|
def turn_on(self):
|
||||||
|
"""Turn the media player on."""
|
||||||
|
self.e2_box.turn_on()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_title(self):
|
||||||
|
"""Title of current playing media."""
|
||||||
|
return self.e2_box.current_service_channel_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_series_title(self):
|
||||||
|
"""Return the title of current episode of TV show."""
|
||||||
|
return self.e2_box.current_programme_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_channel(self):
|
||||||
|
"""Channel of current playing media."""
|
||||||
|
return self.e2_box.current_service_channel_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_id(self):
|
||||||
|
"""Service Ref of current playing media."""
|
||||||
|
return self.e2_box.current_service_ref
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_type(self):
|
||||||
|
"""Type of video currently playing."""
|
||||||
|
return MEDIA_TYPE_TVSHOW
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_volume_muted(self):
|
||||||
|
"""Boolean if volume is currently muted."""
|
||||||
|
return self.e2_box.muted
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_image_url(self):
|
||||||
|
"""Picon url for the channel."""
|
||||||
|
return self.e2_box.picon_url
|
||||||
|
|
||||||
|
def set_volume_level(self, volume):
|
||||||
|
"""Set volume level, range 0..1."""
|
||||||
|
self.e2_box.set_volume(int(volume * 100))
|
||||||
|
|
||||||
|
def volume_up(self):
|
||||||
|
"""Volume up the media player."""
|
||||||
|
self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5)
|
||||||
|
|
||||||
|
def volume_down(self):
|
||||||
|
"""Volume down media player."""
|
||||||
|
self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_level(self):
|
||||||
|
"""Volume level of the media player (0..1)."""
|
||||||
|
return self.e2_box.volume
|
||||||
|
|
||||||
|
def media_stop(self):
|
||||||
|
"""Send stop command."""
|
||||||
|
self.e2_box.set_stop()
|
||||||
|
|
||||||
|
def media_play(self):
|
||||||
|
"""Play media."""
|
||||||
|
self.e2_box.toggle_play_pause()
|
||||||
|
|
||||||
|
def media_pause(self):
|
||||||
|
"""Pause the media player."""
|
||||||
|
self.e2_box.toggle_play_pause()
|
||||||
|
|
||||||
|
def media_next_track(self):
|
||||||
|
"""Send next track command."""
|
||||||
|
self.e2_box.set_channel_up()
|
||||||
|
|
||||||
|
def media_previous_track(self):
|
||||||
|
"""Send next track command."""
|
||||||
|
self.e2_box.set_channel_down()
|
||||||
|
|
||||||
|
def mute_volume(self, mute):
|
||||||
|
"""Mute or unmute."""
|
||||||
|
self.e2_box.mute_volume()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self):
|
||||||
|
"""Return the current input source."""
|
||||||
|
return self.e2_box.current_service_channel_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_list(self):
|
||||||
|
"""List of available input sources."""
|
||||||
|
return self.e2_box.source_list
|
||||||
|
|
||||||
|
def select_source(self, source):
|
||||||
|
"""Select input source."""
|
||||||
|
self.e2_box.select_source(self.e2_box.sources[source])
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""Update state of the media_player."""
|
||||||
|
self.e2_box.update()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_state_attributes(self):
|
||||||
|
"""Return device specific state attributes.
|
||||||
|
|
||||||
|
isRecording: Is the box currently recording.
|
||||||
|
currservice_fulldescription: Full program description.
|
||||||
|
currservice_begin: is in the format '21:00'.
|
||||||
|
currservice_end: is in the format '21:00'.
|
||||||
|
"""
|
||||||
|
attributes = {}
|
||||||
|
if not self.e2_box.in_standby:
|
||||||
|
attributes[ATTR_MEDIA_CURRENTLY_RECORDING] = \
|
||||||
|
self.e2_box.status_info['isRecording']
|
||||||
|
attributes[ATTR_MEDIA_DESCRIPTION] = \
|
||||||
|
self.e2_box.status_info['currservice_fulldescription']
|
||||||
|
attributes[ATTR_MEDIA_START_TIME] = \
|
||||||
|
self.e2_box.status_info['currservice_begin']
|
||||||
|
attributes[ATTR_MEDIA_END_TIME] = \
|
||||||
|
self.e2_box.status_info['currservice_end']
|
||||||
|
|
||||||
|
return attributes
|
@@ -4,7 +4,7 @@ import logging
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||||
from homeassistant.const import (CONF_NAME, CONF_ID)
|
from homeassistant.const import (CONF_NAME, CONF_ID, POWER_WATT)
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.components import enocean
|
from homeassistant.components import enocean
|
||||||
@@ -59,4 +59,4 @@ class EnOceanSensor(enocean.EnOceanDevice, Entity):
|
|||||||
@property
|
@property
|
||||||
def unit_of_measurement(self):
|
def unit_of_measurement(self):
|
||||||
"""Return the unit of measurement."""
|
"""Return the unit of measurement."""
|
||||||
return 'W'
|
return POWER_WATT
|
||||||
|
@@ -13,9 +13,13 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"password": "Adgangskode"
|
"password": "Adgangskode"
|
||||||
},
|
},
|
||||||
"description": "Indtast venligst den adgangskode, du har angivet i din konfiguration.",
|
"description": "Indtast venligst den adgangskode du har angivet i din konfiguration for {name}.",
|
||||||
"title": "Indtast adgangskode"
|
"title": "Indtast adgangskode"
|
||||||
},
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Vil du tilf\u00f8je ESPHome node `{name}` til Home Assistant?",
|
||||||
|
"title": "Fandt ESPHome node"
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "V\u00e6rt",
|
"host": "V\u00e6rt",
|
||||||
|
@@ -13,9 +13,13 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"password": "Passwort"
|
"password": "Passwort"
|
||||||
},
|
},
|
||||||
"description": "Bitte geben Sie das Passwort der ESPhome-Konfiguration ein:",
|
"description": "Bitte geben Sie das Passwort der ESPHome-Konfiguration f\u00fcr {name} ein:",
|
||||||
"title": "Passwort eingeben"
|
"title": "Passwort eingeben"
|
||||||
},
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?",
|
||||||
|
"title": "Gefundener ESPHome-Knoten"
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_password": "Mot de passe invalide !"
|
"connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.",
|
||||||
|
"invalid_password": "Mot de passe invalide !",
|
||||||
|
"resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"authenticate": {
|
"authenticate": {
|
||||||
@@ -11,6 +16,10 @@
|
|||||||
"description": "Veuillez saisir le mot de passe que vous avez d\u00e9fini dans votre configuration.",
|
"description": "Veuillez saisir le mot de passe que vous avez d\u00e9fini dans votre configuration.",
|
||||||
"title": "Entrer votre mot de passe"
|
"title": "Entrer votre mot de passe"
|
||||||
},
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Voulez-vous ajouter le n\u0153ud ESPHome ` {name} ` \u00e0 Home Assistant?",
|
||||||
|
"title": "N\u0153ud ESPHome d\u00e9couvert"
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "H\u00f4te",
|
"host": "H\u00f4te",
|
||||||
|
@@ -13,7 +13,7 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"password": "Password"
|
"password": "Password"
|
||||||
},
|
},
|
||||||
"description": "Inserisci la password che hai impostato nella tua configurazione.",
|
"description": "Inserisci la password per {name} che hai impostato nella tua configurazione.",
|
||||||
"title": "Inserisci la password"
|
"title": "Inserisci la password"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
|
@@ -13,9 +13,13 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"password": "Wachtwoord"
|
"password": "Wachtwoord"
|
||||||
},
|
},
|
||||||
"description": "Voer het wachtwoord in dat u in uw configuratie hebt ingesteld.",
|
"description": "Voer het wachtwoord in dat u in uw configuratie heeft ingesteld voor {name}.",
|
||||||
"title": "Voer wachtwoord in"
|
"title": "Voer wachtwoord in"
|
||||||
},
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?",
|
||||||
|
"title": "ESPHome node ontdekt"
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
@@ -16,6 +16,10 @@
|
|||||||
"description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon.",
|
"description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon.",
|
||||||
"title": "Skriv Inn Passord"
|
"title": "Skriv Inn Passord"
|
||||||
},
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?",
|
||||||
|
"title": "Oppdaget ESPHome node"
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Vert",
|
"host": "Vert",
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user