mirror of
https://github.com/home-assistant/core.git
synced 2025-08-15 18:41:44 +02:00
@@ -27,6 +27,7 @@ omit =
|
||||
homeassistant/components/ambient_station/*
|
||||
homeassistant/components/amcrest/*
|
||||
homeassistant/components/android_ip_webcam/*
|
||||
homeassistant/components/androidtv/*
|
||||
homeassistant/components/apcupsd/*
|
||||
homeassistant/components/apiai/*
|
||||
homeassistant/components/apple_tv/*
|
||||
@@ -68,6 +69,7 @@ omit =
|
||||
homeassistant/components/camera/xiaomi.py
|
||||
homeassistant/components/camera/yi.py
|
||||
homeassistant/components/cast/*
|
||||
homeassistant/components/cisco_mobility_express/device_tracker.py
|
||||
homeassistant/components/climate/coolmaster.py
|
||||
homeassistant/components/climate/ephember.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_smarthub.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/fritz.py
|
||||
homeassistant/components/device_tracker/google_maps.py
|
||||
@@ -138,6 +141,7 @@ omit =
|
||||
homeassistant/components/device_tracker/trackr.py
|
||||
homeassistant/components/device_tracker/ubee.py
|
||||
homeassistant/components/device_tracker/ubus.py
|
||||
homeassistant/components/device_tracker/xfinity.py
|
||||
homeassistant/components/digital_ocean/*
|
||||
homeassistant/components/dominos/*
|
||||
homeassistant/components/doorbird/*
|
||||
@@ -154,6 +158,7 @@ omit =
|
||||
homeassistant/components/elkm1/*
|
||||
homeassistant/components/emoncms_history/*
|
||||
homeassistant/components/emulated_hue/upnp.py
|
||||
homeassistant/components/enigma2/media_player.py
|
||||
homeassistant/components/enocean/*
|
||||
homeassistant/components/envisalink/*
|
||||
homeassistant/components/esphome/__init__.py
|
||||
@@ -233,7 +238,7 @@ omit =
|
||||
homeassistant/components/light/limitlessled.py
|
||||
homeassistant/components/light/lw12wifi.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/opple.py
|
||||
homeassistant/components/light/osramlightify.py
|
||||
@@ -280,7 +285,6 @@ omit =
|
||||
homeassistant/components/media_player/dunehd.py
|
||||
homeassistant/components/media_player/emby.py
|
||||
homeassistant/components/media_player/epson.py
|
||||
homeassistant/components/media_player/firetv.py
|
||||
homeassistant/components/media_player/frontier_silicon.py
|
||||
homeassistant/components/media_player/gpmdp.py
|
||||
homeassistant/components/media_player/gstreamer.py
|
||||
@@ -632,6 +636,7 @@ omit =
|
||||
homeassistant/components/thingspeak/*
|
||||
homeassistant/components/thinkingcleaner/*
|
||||
homeassistant/components/tibber/*
|
||||
homeassistant/components/tof/sensor.py
|
||||
homeassistant/components/toon/*
|
||||
homeassistant/components/tplink_lte/*
|
||||
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:
|
||||
|
||||
|
||||
|
13
.travis.yml
13
.travis.yml
@@ -1,8 +1,18 @@
|
||||
sudo: false
|
||||
dist: xenial
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- sourceline: "ppa:jonathonf/ffmpeg-4"
|
||||
packages:
|
||||
- libudev-dev
|
||||
- libavformat-dev
|
||||
- libavcodec-dev
|
||||
- libavdevice-dev
|
||||
- libavutil-dev
|
||||
- libswscale-dev
|
||||
- libswresample-dev
|
||||
- libavfilter-dev
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
@@ -19,15 +29,12 @@ matrix:
|
||||
env: TOXENV=py36
|
||||
- python: "3.7"
|
||||
env: TOXENV=py37
|
||||
dist: xenial
|
||||
- python: "3.8-dev"
|
||||
env: TOXENV=py38
|
||||
dist: xenial
|
||||
if: branch = dev AND type = push
|
||||
allow_failures:
|
||||
- python: "3.8-dev"
|
||||
env: TOXENV=py38
|
||||
dist: xenial
|
||||
|
||||
cache:
|
||||
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/threshold.py @fabaff
|
||||
homeassistant/components/binary_sensor/uptimerobot.py @ludeeus
|
||||
homeassistant/components/camera/push.py @dgomes
|
||||
homeassistant/components/camera/yi.py @bachya
|
||||
homeassistant/components/climate/coolmaster.py @OnFreund
|
||||
homeassistant/components/climate/ephember.py @ttroy50
|
||||
@@ -62,12 +63,13 @@ homeassistant/components/cover/group.py @cdce8p
|
||||
homeassistant/components/cover/template.py @PhracturedBlue
|
||||
homeassistant/components/device_tracker/asuswrt.py @kennedyshead
|
||||
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/quantum_gateway.py @cisasteelersfan
|
||||
homeassistant/components/device_tracker/tile.py @bachya
|
||||
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/xfinity.py @cisasteelersfan
|
||||
homeassistant/components/light/lifx_legacy.py @amelchio
|
||||
homeassistant/components/light/yeelight.py @rytilahti
|
||||
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/cups.py @fabaff
|
||||
homeassistant/components/sensor/darksky.py @fabaff
|
||||
homeassistant/components/sensor/discogs.py @thibmaek
|
||||
homeassistant/components/sensor/file.py @fabaff
|
||||
homeassistant/components/sensor/filter.py @dgomes
|
||||
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/moon.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/pi_hole.py @fabaff
|
||||
homeassistant/components/sensor/pollen.py @bachya
|
||||
|
@@ -100,9 +100,21 @@ class AuthManager:
|
||||
"""Return a list of available auth modules."""
|
||||
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) \
|
||||
-> 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)
|
||||
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
@@ -113,6 +125,11 @@ class AuthManager:
|
||||
"""Retrieve a user."""
|
||||
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]:
|
||||
"""Retrieve all groups."""
|
||||
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 . 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.types import PolicyType # noqa: F401
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = 'auth'
|
||||
GROUP_NAME_ADMIN = 'Administrators'
|
||||
GROUP_NAME_USER = "Users"
|
||||
GROUP_NAME_READ_ONLY = 'Read Only'
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ class AuthStore:
|
||||
self._perm_lookup = None # type: Optional[PermissionLookup]
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY,
|
||||
private=True)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def async_get_groups(self) -> List[models.Group]:
|
||||
"""Retrieve all users."""
|
||||
@@ -272,8 +274,16 @@ class AuthStore:
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""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.device_registry.async_get_registry(),
|
||||
self._store.async_load(),
|
||||
)
|
||||
|
||||
@@ -282,7 +292,9 @@ class AuthStore:
|
||||
if self._users is not None:
|
||||
return
|
||||
|
||||
self._perm_lookup = perm_lookup = PermissionLookup(ent_reg)
|
||||
self._perm_lookup = perm_lookup = PermissionLookup(
|
||||
ent_reg, dev_reg
|
||||
)
|
||||
|
||||
if data is None:
|
||||
self._set_defaults()
|
||||
@@ -297,6 +309,7 @@ class AuthStore:
|
||||
# 1. Data from a recent version which has a single group without policy
|
||||
# 2. Data from old version which has no groups
|
||||
has_admin_group = False
|
||||
has_user_group = False
|
||||
has_read_only_group = False
|
||||
group_without_policy = None
|
||||
|
||||
@@ -314,6 +327,13 @@ class AuthStore:
|
||||
policy = system_policies.ADMIN_POLICY
|
||||
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:
|
||||
has_read_only_group = True
|
||||
|
||||
@@ -361,6 +381,10 @@ class AuthStore:
|
||||
read_only_group = _system_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']:
|
||||
# Collect the users group.
|
||||
user_groups = []
|
||||
@@ -475,7 +499,7 @@ class AuthStore:
|
||||
'name': group.name
|
||||
} # 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
|
||||
|
||||
groups.append(g_dict)
|
||||
@@ -528,6 +552,8 @@ class AuthStore:
|
||||
groups = OrderedDict() # type: Dict[str, models.Group]
|
||||
admin_group = _system_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()
|
||||
groups[read_only_group.id] = read_only_group
|
||||
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:
|
||||
"""Create read only group."""
|
||||
return models.Group(
|
||||
|
@@ -5,4 +5,5 @@ ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
||||
MFA_SESSION_EXPIRATION = timedelta(minutes=5)
|
||||
|
||||
GROUP_ID_ADMIN = 'system-admin'
|
||||
GROUP_ID_USER = 'system-users'
|
||||
GROUP_ID_READ_ONLY = 'system-read-only'
|
||||
|
@@ -1,12 +1,14 @@
|
||||
"""Entity permissions."""
|
||||
from functools import wraps
|
||||
from typing import Callable, List, Union # noqa: F401
|
||||
from collections import OrderedDict
|
||||
from typing import Callable, Optional # noqa: F401
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT
|
||||
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({
|
||||
vol.Optional(POLICY_READ): True,
|
||||
@@ -15,6 +17,7 @@ SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({
|
||||
}))
|
||||
|
||||
ENTITY_DOMAINS = 'domains'
|
||||
ENTITY_AREAS = 'area_ids'
|
||||
ENTITY_DEVICE_IDS = 'device_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({
|
||||
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_DOMAINS): ENTITY_VALUES_SCHEMA,
|
||||
vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA,
|
||||
}))
|
||||
|
||||
|
||||
def _entity_allowed(schema: ValueType, key: str) \
|
||||
-> Union[bool, None]:
|
||||
"""Test if an entity is allowed based on the keys."""
|
||||
if schema is None or isinstance(schema, bool):
|
||||
return schema
|
||||
assert isinstance(schema, dict)
|
||||
return schema.get(key)
|
||||
def _lookup_domain(perm_lookup: PermissionLookup,
|
||||
domains_dict: SubCategoryDict,
|
||||
entity_id: str) -> Optional[ValueType]:
|
||||
"""Look up entity permissions by domain."""
|
||||
return domains_dict.get(entity_id.split(".", 1)[0])
|
||||
|
||||
|
||||
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) \
|
||||
-> Callable[[str, str], bool]:
|
||||
"""Compile policy into a function that tests policy."""
|
||||
# None, Empty Dict, False
|
||||
if not policy:
|
||||
def apply_policy_deny_all(entity_id: str, key: str) -> bool:
|
||||
"""Decline all."""
|
||||
return False
|
||||
subcategories = OrderedDict() # type: SubCatLookupType
|
||||
subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id
|
||||
subcategories[ENTITY_DEVICE_IDS] = _lookup_device
|
||||
subcategories[ENTITY_AREAS] = _lookup_area
|
||||
subcategories[ENTITY_DOMAINS] = _lookup_domain
|
||||
subcategories[SUBCAT_ALL] = lookup_all
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
return compile_policy(policy, subcategories, perm_lookup)
|
||||
|
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
|
||||
from homeassistant.helpers import ( # noqa
|
||||
entity_registry as ent_reg,
|
||||
)
|
||||
from homeassistant.helpers import ( # noqa
|
||||
device_registry as dev_reg,
|
||||
)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
@@ -15,3 +18,4 @@ class PermissionLookup:
|
||||
"""Class to hold data for permission lookups."""
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
USER_POLICY = {
|
||||
CAT_ENTITIES: True,
|
||||
}
|
||||
|
||||
READ_ONLY_POLICY = {
|
||||
CAT_ENTITIES: {
|
||||
SUBCAT_ALL: {
|
||||
|
@@ -10,9 +10,11 @@ ValueType = Union[
|
||||
None
|
||||
]
|
||||
|
||||
# Example: entities.domains = { light: … }
|
||||
SubCategoryDict = Mapping[str, ValueType]
|
||||
|
||||
SubCategoryType = Union[
|
||||
# Example: entities.domains = { light: … }
|
||||
Mapping[str, ValueType],
|
||||
SubCategoryDict,
|
||||
bool,
|
||||
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
|
||||
"""
|
||||
import hmac
|
||||
from typing import Any, Dict, Optional, cast, TYPE_CHECKING
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from .. import AuthManager
|
||||
from ..models import Credentials, UserMeta, User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
vol.Required('username'): str,
|
||||
})
|
||||
|
||||
AUTH_PROVIDER_TYPE = 'legacy_api_password'
|
||||
CONF_API_PASSWORD = 'api_password'
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
vol.Required(CONF_API_PASSWORD): cv.string,
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
LEGACY_USER_NAME = 'Legacy API password user'
|
||||
@@ -34,40 +30,45 @@ class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
async def async_get_user(hass: HomeAssistant) -> User:
|
||||
"""Return the legacy API password user."""
|
||||
async def async_validate_password(hass: HomeAssistant, password: str)\
|
||||
-> Optional[User]:
|
||||
"""Return a user if password is valid. None if not."""
|
||||
auth = cast(AuthManager, hass.auth) # type: ignore
|
||||
found = None
|
||||
|
||||
for prv in auth.auth_providers:
|
||||
if prv.type == 'legacy_api_password':
|
||||
found = prv
|
||||
break
|
||||
|
||||
if found is None:
|
||||
providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE)
|
||||
if not providers:
|
||||
raise ValueError('Legacy API password provider not found')
|
||||
|
||||
return await auth.async_get_or_create_user(
|
||||
await found.async_get_or_create_credentials({})
|
||||
)
|
||||
try:
|
||||
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):
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
"""An auth provider support 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:
|
||||
"""Return a flow to login."""
|
||||
return LegacyLoginFlow(self)
|
||||
|
||||
@callback
|
||||
def async_validate_login(self, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
||||
"""Validate password."""
|
||||
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')):
|
||||
raise InvalidAuthError
|
||||
|
||||
@@ -99,12 +100,6 @@ class LegacyLoginFlow(LoginFlow):
|
||||
"""Handle the step of the form."""
|
||||
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:
|
||||
try:
|
||||
cast(LegacyApiPasswordAuthProvider, self._auth_provider)\
|
||||
|
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from time import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Optional, Dict
|
||||
from typing import Any, Optional, Dict, Set
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -28,8 +28,16 @@ ERROR_LOG_FILENAME = 'home-assistant.log'
|
||||
# hass.data key for logging information.
|
||||
DATA_LOGGING = 'logging'
|
||||
|
||||
FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream',
|
||||
'logger', 'introduction', 'frontend', 'history'}
|
||||
LOGGING_COMPONENT = {'logger', 'system_log'}
|
||||
|
||||
FIRST_INIT_COMPONENT = {
|
||||
'recorder',
|
||||
'mqtt',
|
||||
'mqtt_eventstream',
|
||||
'introduction',
|
||||
'frontend',
|
||||
'history',
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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')
|
||||
|
||||
try:
|
||||
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:
|
||||
conf_util.async_log_exception(
|
||||
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_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 = set(key.split(' ')[0] for key in config.keys()
|
||||
if key != core.DOMAIN)
|
||||
components.update(hass.config_entries.async_domains())
|
||||
components = _get_components(hass, config)
|
||||
|
||||
# Resolve all dependencies of all 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")
|
||||
|
||||
# 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
|
||||
for component in components:
|
||||
if component not in FIRST_INIT_COMPONENT:
|
||||
continue
|
||||
hass.async_create_task(async_setup_component(hass, component, config))
|
||||
if component in FIRST_INIT_COMPONENT:
|
||||
hass.async_create_task(
|
||||
async_setup_component(hass, component, config))
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# stage 2
|
||||
for component in components:
|
||||
if component in FIRST_INIT_COMPONENT:
|
||||
if component in FIRST_INIT_COMPONENT or component in LOGGING_COMPONENT:
|
||||
continue
|
||||
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:
|
||||
sys.path.insert(0, lib_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.config as conf_util
|
||||
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.const import (
|
||||
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."""
|
||||
async def async_handle_turn_service(service):
|
||||
"""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
|
||||
if not entity_ids:
|
||||
@@ -166,6 +166,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]:
|
||||
_LOGGER.error(err)
|
||||
return
|
||||
|
||||
# auth only processed during startup
|
||||
await conf_util.async_process_ha_core_config(
|
||||
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
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['opensensemap-api==0.1.4']
|
||||
REQUIREMENTS = ['opensensemap-api==0.1.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -342,18 +342,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
)
|
||||
|
||||
@callback
|
||||
def message_received(topic, payload, qos):
|
||||
def message_received(msg):
|
||||
"""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)
|
||||
elif payload == self._payload_arm_home:
|
||||
elif msg.payload == self._payload_arm_home:
|
||||
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)
|
||||
elif payload == self._payload_arm_night:
|
||||
elif msg.payload == self._payload_arm_night:
|
||||
self.async_alarm_arm_night(self._code)
|
||||
else:
|
||||
_LOGGER.warning("Received unexpected payload: %s", payload)
|
||||
_LOGGER.warning("Received unexpected payload: %s", msg.payload)
|
||||
return
|
||||
|
||||
await mqtt.async_subscribe(
|
||||
|
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
|
||||
|
||||
REQUIREMENTS = ['total_connect_client==0.22']
|
||||
REQUIREMENTS = ['total_connect_client==0.24']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -131,6 +131,11 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel):
|
||||
if 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):
|
||||
"""Send toggle chime command."""
|
||||
if code:
|
||||
|
@@ -89,7 +89,7 @@ async def async_setup(hass, config):
|
||||
|
||||
async def async_handle_alert_service(service_call):
|
||||
"""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 in entities:
|
||||
|
@@ -1,21 +1,21 @@
|
||||
"""Support for alexa Smart Home Skill API."""
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.components import (
|
||||
alert, automation, binary_sensor, cover, fan, group, http,
|
||||
input_boolean, light, lock, media_player, scene, script, sensor, switch)
|
||||
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 (
|
||||
ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
|
||||
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_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE,
|
||||
STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL)
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.util.decorator import Registry
|
||||
from homeassistant.util.temperature import convert as convert_temperature
|
||||
|
||||
from .auth import Auth
|
||||
from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \
|
||||
CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT
|
||||
from .auth import Auth
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -1115,12 +1115,15 @@ class SmartHomeView(http.HomeAssistantView):
|
||||
the response.
|
||||
"""
|
||||
hass = request.app['hass']
|
||||
user = request[http.KEY_HASS_USER]
|
||||
message = await request.json()
|
||||
|
||||
_LOGGER.debug("Received Alexa Smart Home request: %s", 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)
|
||||
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):
|
||||
"""Initialize."""
|
||||
self._config_entry = config_entry
|
||||
self._entry_setup_complete = False
|
||||
self._hass = hass
|
||||
self._watchdog_listener = None
|
||||
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
|
||||
@@ -362,12 +363,18 @@ class AmbientStation:
|
||||
'name', station['macAddress']),
|
||||
}
|
||||
|
||||
for component in ('binary_sensor', 'sensor'):
|
||||
self._hass.async_create_task(
|
||||
self._hass.config_entries.async_forward_entry_setup(
|
||||
self._config_entry, component))
|
||||
# If the websocket disconnects and reconnects, the on_subscribed
|
||||
# handler will get called again; in that case, we don't want to
|
||||
# attempt forward setup of the config entry (because it will have
|
||||
# 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_data(on_data)
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['amcrest==1.2.3']
|
||||
REQUIREMENTS = ['amcrest==1.2.5']
|
||||
DEPENDENCIES = ['ffmpeg']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@@ -1,6 +1,10 @@
|
||||
"""Support for Amcrest IP cameras."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from requests import RequestException
|
||||
from urllib3.exceptions import ReadTimeoutError
|
||||
|
||||
from homeassistant.components.amcrest import (
|
||||
DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT)
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -43,12 +47,20 @@ class AmcrestCam(Camera):
|
||||
self._stream_source = amcrest.stream_source
|
||||
self._resolution = amcrest.resolution
|
||||
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."""
|
||||
# Send the request to snap a picture and return raw jpg data
|
||||
response = self._camera.snapshot(channel=self._resolution)
|
||||
return response.data
|
||||
async with self._snapshot_lock:
|
||||
try:
|
||||
# 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):
|
||||
"""Return an MJPEG stream."""
|
||||
@@ -85,3 +97,8 @@ class AmcrestCam(Camera):
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
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
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -57,7 +57,7 @@ SENSOR_TYPES = {
|
||||
'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'],
|
||||
'nominv': ['Nominal Input 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'],
|
||||
'numxfers': ['Transfer Count', '', 'mdi:counter'],
|
||||
'outcurnt': ['Output Current', 'A', 'mdi:flash'],
|
||||
@@ -93,7 +93,7 @@ INFERRED_UNITS = {
|
||||
' Volts': 'V',
|
||||
' Ampere': 'A',
|
||||
' Volt-Ampere': 'VA',
|
||||
' Watts': 'W',
|
||||
' Watts': POWER_WATT,
|
||||
' Hz': 'Hz',
|
||||
' C': TEMP_CELSIUS,
|
||||
' Percent Load Capacity': '%',
|
||||
|
@@ -168,11 +168,11 @@ class APIDiscoveryView(HomeAssistantView):
|
||||
def get(self, request):
|
||||
"""Get discovery information."""
|
||||
hass = request.app['hass']
|
||||
needs_auth = hass.config.api.api_password is not None
|
||||
return self.json({
|
||||
ATTR_BASE_URL: hass.config.api.base_url,
|
||||
ATTR_LOCATION_NAME: hass.config.location_name,
|
||||
ATTR_REQUIRES_API_PASSWORD: needs_auth,
|
||||
# always needs authentication
|
||||
ATTR_REQUIRES_API_PASSWORD: True,
|
||||
ATTR_VERSION: __version__,
|
||||
})
|
||||
|
||||
|
@@ -9,7 +9,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
|
||||
REQUIREMENTS = ['aioasuswrt==1.1.20']
|
||||
REQUIREMENTS = ['aioasuswrt==1.1.21']
|
||||
|
||||
_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, \
|
||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_REAL_IP
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
@@ -184,10 +185,18 @@ RESULT_TYPE_USER = 'user'
|
||||
_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):
|
||||
"""Component to allow users to login."""
|
||||
store_result, retrieve_result = _create_auth_code_store()
|
||||
|
||||
hass.data[DOMAIN] = store_result
|
||||
|
||||
hass.http.register_view(TokenView(retrieve_result))
|
||||
hass.http.register_view(LinkUserView(retrieve_result))
|
||||
|
||||
@@ -450,6 +459,7 @@ async def websocket_current_user(
|
||||
'id': user.id,
|
||||
'name': user.name,
|
||||
'is_owner': user.is_owner,
|
||||
'is_admin': user.is_admin,
|
||||
'credentials': [{'auth_provider_type': c.auth_provider_type,
|
||||
'auth_provider_id': c.auth_provider_id}
|
||||
for c in user.credentials],
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Helpers to resolve client ID/secret."""
|
||||
import logging
|
||||
import asyncio
|
||||
from ipaddress import ip_address
|
||||
from html.parser import HTMLParser
|
||||
@@ -9,6 +10,8 @@ from aiohttp.client_exceptions import ClientError
|
||||
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def verify_redirect_uri(hass, client_id, redirect_uri):
|
||||
"""Verify that the client and redirect uri match."""
|
||||
@@ -78,7 +81,8 @@ async def fetch_redirect_uris(hass, url):
|
||||
if chunks == 10:
|
||||
break
|
||||
|
||||
except (asyncio.TimeoutError, ClientError):
|
||||
except (asyncio.TimeoutError, ClientError) as ex:
|
||||
_LOGGER.error("Error while looking up redirect_uri %s: %s", url, ex)
|
||||
pass
|
||||
|
||||
# Authorization endpoints verifying that a redirect_uri is allowed for use
|
||||
|
@@ -7,7 +7,7 @@ import logging
|
||||
import voluptuous as vol
|
||||
|
||||
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.const import (
|
||||
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):
|
||||
"""Handle automation triggers."""
|
||||
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(
|
||||
service_call.data.get(ATTR_VARIABLES),
|
||||
skip_condition=True,
|
||||
@@ -133,7 +133,7 @@ async def async_setup(hass, config):
|
||||
"""Handle automation turn on/off service calls."""
|
||||
tasks = []
|
||||
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)())
|
||||
|
||||
if tasks:
|
||||
@@ -142,7 +142,7 @@ async def async_setup(hass, config):
|
||||
async def toggle_service_handler(service_call):
|
||||
"""Handle automation toggle service calls."""
|
||||
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:
|
||||
tasks.append(entity.async_turn_off())
|
||||
else:
|
||||
@@ -280,15 +280,21 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
|
||||
|
||||
This method is a coroutine.
|
||||
"""
|
||||
if skip_condition or self._cond_func(variables):
|
||||
self.async_set_context(context)
|
||||
self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, {
|
||||
ATTR_NAME: self._name,
|
||||
ATTR_ENTITY_ID: self.entity_id,
|
||||
}, context=context)
|
||||
await self._async_action(self.entity_id, variables, context)
|
||||
self._last_triggered = utcnow()
|
||||
await self.async_update_ha_state()
|
||||
if not skip_condition and not self._cond_func(variables):
|
||||
return
|
||||
|
||||
# Create a new context referring to the old context.
|
||||
parent_id = None if context is None else context.id
|
||||
trigger_context = Context(parent_id=parent_id)
|
||||
|
||||
self.async_set_context(trigger_context)
|
||||
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):
|
||||
"""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
|
||||
|
||||
@callback
|
||||
def mqtt_automation_listener(msg_topic, msg_payload, qos):
|
||||
def mqtt_automation_listener(mqttmsg):
|
||||
"""Listen for MQTT messages."""
|
||||
if payload is None or payload == msg_payload:
|
||||
if payload is None or payload == mqttmsg.payload:
|
||||
data = {
|
||||
'platform': 'mqtt',
|
||||
'topic': msg_topic,
|
||||
'payload': msg_payload,
|
||||
'qos': qos,
|
||||
'topic': mqttmsg.topic,
|
||||
'payload': mqttmsg.payload,
|
||||
'qos': mqttmsg.qos,
|
||||
}
|
||||
|
||||
try:
|
||||
data['payload_json'] = json.loads(msg_payload)
|
||||
data['payload_json'] = json.loads(mqttmsg.payload)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
@@ -17,7 +17,7 @@ from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
REQUIREMENTS = ['numpy==1.16.1']
|
||||
REQUIREMENTS = ['numpy==1.16.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -42,6 +42,7 @@ CONF_PROVINCE = 'province'
|
||||
CONF_WORKDAYS = 'workdays'
|
||||
CONF_EXCLUDES = 'excludes'
|
||||
CONF_OFFSET = 'days_offset'
|
||||
CONF_ADD_HOLIDAYS = 'add_holidays'
|
||||
|
||||
# By default, Monday - Friday are workdays
|
||||
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_WORKDAYS, default=DEFAULT_WORKDAYS):
|
||||
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)
|
||||
excludes = config.get(CONF_EXCLUDES)
|
||||
days_offset = config.get(CONF_OFFSET)
|
||||
add_holidays = config.get(CONF_ADD_HOLIDAYS)
|
||||
|
||||
year = (get_date(datetime.today()) + timedelta(days=days_offset)).year
|
||||
obj_holidays = getattr(holidays, country)(years=year)
|
||||
@@ -92,6 +95,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
province, country)
|
||||
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:")
|
||||
for date, name in sorted(obj_holidays.items()):
|
||||
_LOGGER.debug("%s %s", date, name)
|
||||
|
@@ -28,6 +28,12 @@ from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.config_validation import ( # noqa
|
||||
PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
|
||||
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
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
@@ -39,11 +45,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SERVICE_ENABLE_MOTION = 'enable_motion_detection'
|
||||
SERVICE_DISABLE_MOTION = 'disable_motion_detection'
|
||||
SERVICE_SNAPSHOT = 'snapshot'
|
||||
SERVICE_PLAY_STREAM = 'play_stream'
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
ATTR_FILENAME = 'filename'
|
||||
ATTR_MEDIA_PLAYER = 'media_player'
|
||||
ATTR_FORMAT = 'format'
|
||||
|
||||
STATE_RECORDING = 'recording'
|
||||
STATE_STREAMING = 'streaming'
|
||||
@@ -69,6 +78,11 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
|
||||
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'
|
||||
SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL,
|
||||
@@ -176,6 +190,7 @@ async def async_setup(hass, config):
|
||||
WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail,
|
||||
SCHEMA_WS_CAMERA_THUMBNAIL
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(ws_camera_stream)
|
||||
|
||||
await component.async_setup(config)
|
||||
|
||||
@@ -209,6 +224,10 @@ async def async_setup(hass, config):
|
||||
SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT,
|
||||
async_handle_snapshot_service
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_PLAY_STREAM, CAMERA_SERVICE_PLAY_STREAM,
|
||||
async_handle_play_stream_service
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -273,6 +292,11 @@ class Camera(Entity):
|
||||
"""Return the interval between frames of the mjpeg stream."""
|
||||
return 0.5
|
||||
|
||||
@property
|
||||
def stream_source(self):
|
||||
"""Return the source of the stream."""
|
||||
return None
|
||||
|
||||
def camera_image(self):
|
||||
"""Return bytes of camera image."""
|
||||
raise NotImplementedError()
|
||||
@@ -473,6 +497,33 @@ async def websocket_camera_thumbnail(hass, connection, msg):
|
||||
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):
|
||||
"""Handle snapshot services calls."""
|
||||
hass = camera.hass
|
||||
@@ -500,3 +551,25 @@ async def async_handle_snapshot_service(camera, service):
|
||||
_write_image, snapshot_file, image)
|
||||
except OSError as 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_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change'
|
||||
CONF_STILL_IMAGE_URL = 'still_image_url'
|
||||
CONF_STREAM_SOURCE = 'stream_source'
|
||||
CONF_FRAMERATE = 'framerate'
|
||||
|
||||
DEFAULT_NAME = 'Generic Camera'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
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.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
|
||||
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._name = device_info.get(CONF_NAME)
|
||||
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._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
||||
self._frame_interval = 1 / device_info[CONF_FRAMERATE]
|
||||
@@ -128,7 +131,7 @@ class GenericCamera(Camera):
|
||||
url, auth=self._auth)
|
||||
self._last_image = await response.read()
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Timeout getting camera image")
|
||||
_LOGGER.error("Timeout getting image from: %s", self._name)
|
||||
return self._last_image
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Error getting new camera image: %s", err)
|
||||
@@ -141,3 +144,8 @@ class GenericCamera(Camera):
|
||||
def name(self):
|
||||
"""Return the name of this device."""
|
||||
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
|
||||
|
||||
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \
|
||||
HTTP_HEADER_HA_AUTH
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util.async_ import run_coroutine_threadsafe
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.components.camera import async_get_still_stream
|
||||
|
||||
REQUIREMENTS = ['pillow==5.4.1']
|
||||
|
||||
@@ -209,9 +207,6 @@ class ProxyCamera(Camera):
|
||||
or config.get(CONF_CACHE_IMAGES))
|
||||
self._last_image_time = dt_util.utc_from_timestamp(0)
|
||||
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)
|
||||
|
||||
def camera_image(self):
|
||||
@@ -252,7 +247,7 @@ class ProxyCamera(Camera):
|
||||
return await self.hass.components.camera.async_get_mjpeg_stream(
|
||||
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,
|
||||
self.content_type, self.frame_interval)
|
||||
|
||||
|
@@ -38,6 +38,19 @@ snapshot:
|
||||
description: Template of a Filename. Variable is 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:
|
||||
description: Update the file_path for a local_file camera.
|
||||
fields:
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyxeoma==1.4.0']
|
||||
REQUIREMENTS = ['pyxeoma==1.4.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.helpers import config_entry_flow
|
||||
|
||||
REQUIREMENTS = ['pychromecast==2.5.2']
|
||||
REQUIREMENTS = ['pychromecast==3.0.0']
|
||||
|
||||
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)
|
||||
for cfg in config])
|
||||
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
|
||||
|
||||
|
||||
@@ -289,7 +292,7 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
if cast_device is not None:
|
||||
async_add_entities([cast_device])
|
||||
|
||||
remove_handler = async_dispatcher_connect(
|
||||
async_dispatcher_connect(
|
||||
hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered)
|
||||
# Re-play the callback for all past chromecasts, store the objects in
|
||||
# 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:
|
||||
_LOGGER.debug("Cannot retrieve detail information for chromecast"
|
||||
" %s, the device may not be online", info)
|
||||
remove_handler()
|
||||
raise PlatformNotReady
|
||||
|
||||
hass.async_add_job(_discover_chromecast, hass, info)
|
||||
|
||||
@@ -477,16 +478,10 @@ class CastDevice(MediaPlayerDevice):
|
||||
))
|
||||
self._chromecast = chromecast
|
||||
self._status_listener = CastStatusListener(self, chromecast)
|
||||
# Initialise connection status as connected because we can only
|
||||
# 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._available = False
|
||||
self.cast_status = chromecast.status
|
||||
self.media_status = chromecast.media_controller.status
|
||||
_LOGGER.debug("[%s %s (%s:%s)] Connection successful!",
|
||||
self.entity_id, self._cast_info.friendly_name,
|
||||
self._cast_info.host, self._cast_info.port)
|
||||
self._chromecast.start()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
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._cast_info.host, self._cast_info.port,
|
||||
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.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_AWAY_MODE = 'away_mode'
|
||||
|
@@ -117,7 +117,8 @@ class EphEmberThermostat(ClimateDevice):
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""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)
|
||||
|
||||
@property
|
||||
|
@@ -184,7 +184,8 @@ class EQ3BTSmartThermostat(ClimateDevice):
|
||||
|
||||
def update(self):
|
||||
"""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:
|
||||
self._thermostat.update()
|
||||
except BTLEException as ex:
|
||||
|
@@ -273,6 +273,11 @@ class HoneywellUSThermostat(ClimateDevice):
|
||||
"""Return the current temperature."""
|
||||
return self._device.current_temperature
|
||||
|
||||
@property
|
||||
def current_humidity(self):
|
||||
"""Return the current humidity."""
|
||||
return self._device.current_humidity
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
|
@@ -1,43 +1,39 @@
|
||||
"""Component to integrate the Home Assistant cloud."""
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
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.auth.const import GROUP_ID_ADMIN
|
||||
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.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 .const import CONFIG_DIR, DOMAIN, SERVERS
|
||||
from . import http_api
|
||||
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__)
|
||||
|
||||
CONF_ALEXA = 'alexa'
|
||||
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 = MODE_PROD
|
||||
|
||||
DEFAULT_MODE = 'production'
|
||||
DEPENDENCIES = ['http']
|
||||
SERVICE_REMOTE_CONNECT = 'remote_connect'
|
||||
SERVICE_REMOTE_DISCONNECT = 'remote_disconnect'
|
||||
|
||||
MODE_DEV = 'development'
|
||||
|
||||
ALEXA_ENTITY_SCHEMA = vol.Schema({
|
||||
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
|
||||
@@ -52,7 +48,7 @@ GOOGLE_ENTITY_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({
|
||||
@@ -63,205 +59,140 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
|
||||
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
|
||||
})
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
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
|
||||
vol.Optional(CONF_COGNITO_CLIENT_ID): str,
|
||||
vol.Optional(CONF_USER_POOL_ID): str,
|
||||
vol.Optional(CONF_REGION): str,
|
||||
vol.Optional(CONF_RELAYER): str,
|
||||
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str,
|
||||
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str,
|
||||
vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str,
|
||||
vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(),
|
||||
vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(),
|
||||
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_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
||||
}),
|
||||
}, 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):
|
||||
"""Initialize the Home Assistant cloud."""
|
||||
from hass_nabucasa import Cloud
|
||||
from .client import CloudClient
|
||||
|
||||
# Process configs
|
||||
if DOMAIN in config:
|
||||
kwargs = dict(config[DOMAIN])
|
||||
else:
|
||||
kwargs = {CONF_MODE: DEFAULT_MODE}
|
||||
|
||||
# Alexa/Google custom config
|
||||
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:
|
||||
kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({})
|
||||
# Cloud settings
|
||||
prefs = CloudPreferences(hass)
|
||||
await prefs.async_initialize()
|
||||
|
||||
kwargs[CONF_ALEXA] = alexa_sh.Config(
|
||||
endpoint=None,
|
||||
async_get_access_token=None,
|
||||
should_expose=alexa_conf[CONF_FILTER],
|
||||
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
|
||||
)
|
||||
# Cloud user
|
||||
if not prefs.cloud_user:
|
||||
user = await hass.auth.async_create_system_user(
|
||||
'Home Assistant Cloud', [GROUP_ID_ADMIN])
|
||||
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)
|
||||
hass.async_create_task(hass.helpers.discovery.async_load_platform(
|
||||
'binary_sensor', DOMAIN, {}, config))
|
||||
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."""
|
||||
DOMAIN = 'cloud'
|
||||
CONFIG_DIR = '.cloud'
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
PREF_ENABLE_ALEXA = 'alexa_enabled'
|
||||
PREF_ENABLE_GOOGLE = 'google_enabled'
|
||||
PREF_ENABLE_REMOTE = 'remote_enabled'
|
||||
PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock'
|
||||
PREF_CLOUDHOOKS = 'cloudhooks'
|
||||
PREF_CLOUD_USER = 'cloud_user'
|
||||
|
||||
SERVERS = {
|
||||
'production': {
|
||||
'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u',
|
||||
'user_pool_id': 'us-east-1_87ll5WOP8',
|
||||
'region': 'us-east-1',
|
||||
'relayer': 'wss://cloud.hass.io:8000/websocket',
|
||||
'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.'
|
||||
'amazonaws.com/prod/smart_home_sync'),
|
||||
'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/'
|
||||
'subscription_info'),
|
||||
'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate'
|
||||
}
|
||||
}
|
||||
CONF_ALEXA = 'alexa'
|
||||
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'
|
||||
CONF_REMOTE_API_URL = 'remote_api_url'
|
||||
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
|
||||
|
||||
MESSAGE_EXPIRATION = """
|
||||
It looks like your Home Assistant Cloud subscription has expired. Please check
|
||||
your [account page](/config/cloud/account) to continue using the service.
|
||||
"""
|
||||
MODE_DEV = "development"
|
||||
MODE_PROD = "production"
|
||||
|
||||
MESSAGE_AUTH_FAIL = """
|
||||
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.
|
||||
"""
|
||||
DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update'
|
||||
|
||||
|
||||
class InvalidTrustedNetworks(Exception):
|
||||
"""Raised when invalid trusted networks config."""
|
||||
|
@@ -3,6 +3,7 @@ import asyncio
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
import attr
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
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.google_assistant import smart_home as google_sh
|
||||
|
||||
from . import auth_api
|
||||
from .const import (
|
||||
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK)
|
||||
from .iot import STATE_DISCONNECTED, STATE_CONNECTED
|
||||
PREF_GOOGLE_ALLOW_UNLOCK, InvalidTrustedNetworks)
|
||||
|
||||
_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):
|
||||
"""Initialize the HTTP API."""
|
||||
hass.components.websocket_api.async_register_command(
|
||||
@@ -81,6 +87,10 @@ async def async_setup(hass):
|
||||
WS_TYPE_HOOK_DELETE, websocket_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(CloudLoginView)
|
||||
hass.http.register_view(CloudLogoutView)
|
||||
@@ -88,14 +98,22 @@ async def async_setup(hass):
|
||||
hass.http.register_view(CloudResendConfirmView)
|
||||
hass.http.register_view(CloudForgotPasswordView)
|
||||
|
||||
from hass_nabucasa import auth
|
||||
|
||||
_CLOUD_ERRORS = {
|
||||
auth_api.UserNotFound: (400, "User does not exist."),
|
||||
auth_api.UserNotConfirmed: (400, 'Email not confirmed.'),
|
||||
auth_api.Unauthenticated: (401, 'Authentication failed.'),
|
||||
auth_api.PasswordChangeRequired: (400, 'Password change required.'),
|
||||
asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.')
|
||||
}
|
||||
_CLOUD_ERRORS.update({
|
||||
auth.UserNotFound:
|
||||
(400, "User does not exist."),
|
||||
auth.UserNotConfirmed:
|
||||
(400, 'Email not confirmed.'),
|
||||
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):
|
||||
@@ -108,12 +126,7 @@ def _handle_cloud_errors(handler):
|
||||
return result
|
||||
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
err_info = _CLOUD_ERRORS.get(err.__class__)
|
||||
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
|
||||
status, msg = _process_cloud_exception(err, request.path)
|
||||
return view.json_message(
|
||||
msg, status_code=status,
|
||||
message_code=err.__class__.__name__.lower())
|
||||
@@ -121,6 +134,31 @@ def _handle_cloud_errors(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):
|
||||
"""Trigger a Google Actions Smart Home Sync."""
|
||||
|
||||
@@ -135,7 +173,7 @@ class GoogleActionsSyncView(HomeAssistantView):
|
||||
websession = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||
|
||||
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):
|
||||
req = await websession.post(
|
||||
@@ -163,7 +201,7 @@ class CloudLoginView(HomeAssistantView):
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
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'])
|
||||
|
||||
hass.async_add_job(cloud.iot.connect)
|
||||
@@ -206,7 +244,7 @@ class CloudRegisterView(HomeAssistantView):
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
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')
|
||||
|
||||
@@ -228,7 +266,7 @@ class CloudResendConfirmView(HomeAssistantView):
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
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')
|
||||
|
||||
@@ -250,7 +288,7 @@ class CloudForgotPasswordView(HomeAssistantView):
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
await hass.async_add_job(
|
||||
auth_api.forgot_password, cloud, data['email'])
|
||||
cloud.auth.forgot_password, data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
@@ -283,30 +321,11 @@ def _require_cloud_login(handler):
|
||||
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
|
||||
@websocket_api.async_response
|
||||
async def websocket_subscription(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
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
|
||||
# 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(
|
||||
"Found disconnected account with valid subscriotion, connecting")
|
||||
await hass.async_add_executor_job(
|
||||
auth_api.renew_access_token, cloud)
|
||||
await hass.async_add_executor_job(cloud.auth.renew_access_token)
|
||||
|
||||
# Cancel reconnect in progress
|
||||
if cloud.iot.state != STATE_DISCONNECTED:
|
||||
@@ -344,23 +362,24 @@ async def websocket_update_prefs(hass, connection, msg):
|
||||
changes = dict(msg)
|
||||
changes.pop('id')
|
||||
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']))
|
||||
|
||||
|
||||
@_require_cloud_login
|
||||
@websocket_api.async_response
|
||||
@_handle_aiohttp_errors
|
||||
@_ws_handle_cloud_errors
|
||||
async def websocket_hook_create(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
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))
|
||||
|
||||
|
||||
@_require_cloud_login
|
||||
@websocket_api.async_response
|
||||
@_ws_handle_cloud_errors
|
||||
async def websocket_hook_delete(hass, connection, msg):
|
||||
"""Handle request for account info."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
@@ -370,6 +389,8 @@ async def websocket_hook_delete(hass, connection, msg):
|
||||
|
||||
def _account_data(cloud):
|
||||
"""Generate the auth data JSON response."""
|
||||
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
|
||||
if not cloud.is_logged_in:
|
||||
return {
|
||||
'logged_in': False,
|
||||
@@ -377,14 +398,53 @@ def _account_data(cloud):
|
||||
}
|
||||
|
||||
claims = cloud.claims
|
||||
client = cloud.client
|
||||
remote = cloud.remote
|
||||
|
||||
# Load remote certificate
|
||||
if remote.certificate:
|
||||
certificate = attr.asdict(remote.certificate)
|
||||
else:
|
||||
certificate = None
|
||||
|
||||
return {
|
||||
'logged_in': True,
|
||||
'email': claims['email'],
|
||||
'cloud': cloud.iot.state,
|
||||
'prefs': cloud.prefs.as_dict(),
|
||||
'google_entities': cloud.google_actions_user_conf['filter'].config,
|
||||
'prefs': client.prefs.as_dict(),
|
||||
'google_entities': client.google_user_config['filter'].config,
|
||||
'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),
|
||||
'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."""
|
||||
from ipaddress import ip_address
|
||||
|
||||
from .const import (
|
||||
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS)
|
||||
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER,
|
||||
InvalidTrustedNetworks)
|
||||
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
@@ -13,6 +16,7 @@ class CloudPreferences:
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize cloud prefs."""
|
||||
self._hass = hass
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._prefs = None
|
||||
|
||||
@@ -24,31 +28,52 @@ class CloudPreferences:
|
||||
prefs = {
|
||||
PREF_ENABLE_ALEXA: True,
|
||||
PREF_ENABLE_GOOGLE: True,
|
||||
PREF_ENABLE_REMOTE: False,
|
||||
PREF_GOOGLE_ALLOW_UNLOCK: False,
|
||||
PREF_CLOUDHOOKS: {}
|
||||
PREF_CLOUDHOOKS: {},
|
||||
PREF_CLOUD_USER: None,
|
||||
}
|
||||
|
||||
self._prefs = prefs
|
||||
|
||||
async def async_update(self, *, google_enabled=_UNDEF,
|
||||
alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF,
|
||||
cloudhooks=_UNDEF):
|
||||
alexa_enabled=_UNDEF, remote_enabled=_UNDEF,
|
||||
google_allow_unlock=_UNDEF, cloudhooks=_UNDEF,
|
||||
cloud_user=_UNDEF):
|
||||
"""Update user preferences."""
|
||||
for key, value in (
|
||||
(PREF_ENABLE_GOOGLE, google_enabled),
|
||||
(PREF_ENABLE_ALEXA, alexa_enabled),
|
||||
(PREF_ENABLE_REMOTE, remote_enabled),
|
||||
(PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock),
|
||||
(PREF_CLOUDHOOKS, cloudhooks),
|
||||
(PREF_CLOUD_USER, cloud_user),
|
||||
):
|
||||
if value is not _UNDEF:
|
||||
self._prefs[key] = value
|
||||
|
||||
if remote_enabled is True and self._has_local_trusted_network:
|
||||
raise InvalidTrustedNetworks
|
||||
|
||||
await self._store.async_save(self._prefs)
|
||||
|
||||
def as_dict(self):
|
||||
"""Return dictionary version."""
|
||||
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
|
||||
def alexa_enabled(self):
|
||||
"""Return if Alexa is enabled."""
|
||||
@@ -68,3 +93,24 @@ class CloudPreferences:
|
||||
def cloudhooks(self):
|
||||
"""Return the published cloud webhooks."""
|
||||
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."""
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID
|
||||
from homeassistant.setup import (
|
||||
async_prepare_setup_platform, ATTR_COMPONENT)
|
||||
from homeassistant.setup import ATTR_COMPONENT
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.util.yaml import load_yaml, dump
|
||||
|
||||
@@ -24,7 +24,6 @@ SECTIONS = (
|
||||
'device_registry',
|
||||
'entity_registry',
|
||||
'group',
|
||||
'hassbian',
|
||||
'script',
|
||||
)
|
||||
ON_DEMAND = ('zwave',)
|
||||
@@ -37,8 +36,7 @@ async def async_setup(hass, config):
|
||||
|
||||
async def setup_panel(panel_name):
|
||||
"""Set up a panel."""
|
||||
panel = await async_prepare_setup_platform(
|
||||
hass, config, DOMAIN, panel_name)
|
||||
panel = importlib.import_module('.{}'.format(panel_name), __name__)
|
||||
|
||||
if not panel:
|
||||
return
|
||||
|
@@ -8,8 +8,6 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers.area_registry import async_get_registry
|
||||
|
||||
|
||||
DEPENDENCIES = ['websocket_api']
|
||||
|
||||
WS_TYPE_LIST = 'config/area_registry/list'
|
||||
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_LIST,
|
||||
|
@@ -36,6 +36,7 @@ async def async_setup(hass):
|
||||
WS_TYPE_CREATE, websocket_create,
|
||||
SCHEMA_WS_CREATE
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(websocket_update)
|
||||
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):
|
||||
"""Format a user."""
|
||||
return {
|
||||
|
@@ -122,7 +122,6 @@ async def websocket_delete(hass, connection, msg):
|
||||
websocket_api.result_message(msg['id']))
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_change_password(hass, connection, msg):
|
||||
"""Change user password."""
|
||||
|
@@ -118,6 +118,16 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView):
|
||||
# pylint: disable=no-value-for-parameter
|
||||
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):
|
||||
"""View to interact with the flow manager."""
|
||||
@@ -143,6 +153,16 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView):
|
||||
# pylint: disable=no-value-for-parameter
|
||||
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):
|
||||
"""View to query available flows."""
|
||||
@@ -175,7 +195,7 @@ class OptionManagerFlowIndexView(FlowManagerIndexView):
|
||||
return await super().post(request)
|
||||
|
||||
|
||||
class OptionManagerFlowResourceView(ConfigManagerFlowResourceView):
|
||||
class OptionManagerFlowResourceView(FlowManagerResourceView):
|
||||
"""View to interact with the option flow manager."""
|
||||
|
||||
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.helpers.device_registry import async_get_registry
|
||||
|
||||
DEPENDENCIES = ['websocket_api']
|
||||
|
||||
WS_TYPE_LIST = 'config/device_registry/list'
|
||||
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
vol.Required('type'): WS_TYPE_LIST,
|
||||
|
@@ -9,8 +9,6 @@ from homeassistant.components.websocket_api.decorators import (
|
||||
async_response, require_admin)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
DEPENDENCIES = ['websocket_api']
|
||||
|
||||
WS_TYPE_LIST = 'config/entity_registry/list'
|
||||
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
|
||||
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": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"title": "Definiere das deCONZ-Gateway"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"options": {
|
||||
|
@@ -12,12 +12,12 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Poort (standaard: '80')"
|
||||
"port": "Poort"
|
||||
},
|
||||
"title": "Definieer deCONZ gateway"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"options": {
|
||||
|
@@ -6,7 +6,6 @@ DEPENDENCIES = (
|
||||
'cloud',
|
||||
'config',
|
||||
'conversation',
|
||||
'discovery',
|
||||
'frontend',
|
||||
'history',
|
||||
'logbook',
|
||||
@@ -17,6 +16,7 @@ DEPENDENCIES = (
|
||||
'sun',
|
||||
'system_health',
|
||||
'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():
|
||||
@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."""
|
||||
try:
|
||||
data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload))
|
||||
data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(msg.payload))
|
||||
except vol.MultipleInvalid:
|
||||
_LOGGER.error("Skipping update for following data "
|
||||
"because of missing or malformatted data: %s",
|
||||
payload)
|
||||
msg.payload)
|
||||
return
|
||||
except ValueError:
|
||||
_LOGGER.error("Error parsing JSON payload: %s", payload)
|
||||
_LOGGER.error("Error parsing JSON payload: %s", msg.payload)
|
||||
return
|
||||
|
||||
kwargs = _parse_see_args(dev_id, data)
|
||||
|
@@ -11,10 +11,10 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA,
|
||||
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
|
||||
|
||||
REQUIREMENTS = ['quantum-gateway==0.0.3']
|
||||
REQUIREMENTS = ['quantum-gateway==0.0.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,6 +22,7 @@ DEFAULT_HOST = 'myfiosgateway.com'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Optional(CONF_SSL, default=True): cv.boolean,
|
||||
vol.Required(CONF_PASSWORD): cv.string
|
||||
})
|
||||
|
||||
@@ -42,10 +43,12 @@ class QuantumGatewayDeviceScanner(DeviceScanner):
|
||||
|
||||
self.host = config[CONF_HOST]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
self.use_https = config[CONF_SSL]
|
||||
_LOGGER.debug('Initializing')
|
||||
|
||||
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
|
||||
except RequestException:
|
||||
self.success_init = False
|
||||
|
@@ -18,7 +18,7 @@ from homeassistant.util import slugify
|
||||
from homeassistant.util.json import load_json, save_json
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['pytile==2.0.5']
|
||||
REQUIREMENTS = ['pytile==2.0.6']
|
||||
|
||||
CLIENT_UUID_CONFIG_FILE = '.tile.conf'
|
||||
DEVICE_TYPES = ['PHONE', 'TILE']
|
||||
|
@@ -43,6 +43,8 @@ AVAILABLE_ATTRS = [
|
||||
'uptime', 'user_id', 'usergroup_id', 'vlan'
|
||||
]
|
||||
|
||||
TIMESTAMP_ATTRS = ['first_seen', 'last_seen', 'latest_assoc_time']
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Optional(CONF_SITE_ID, default='default'): cv.string,
|
||||
@@ -149,7 +151,12 @@ class UnifiScanner(DeviceScanner):
|
||||
attributes = {}
|
||||
for variable in self._monitored_conditions:
|
||||
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)
|
||||
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
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import os
|
||||
|
||||
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
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
REQUIREMENTS = ['netdisco==2.3.0']
|
||||
REQUIREMENTS = ['netdisco==2.5.0']
|
||||
|
||||
DOMAIN = 'discovery'
|
||||
|
||||
@@ -31,6 +30,7 @@ SERVICE_AXIS = 'axis'
|
||||
SERVICE_DAIKIN = 'daikin'
|
||||
SERVICE_DECONZ = 'deconz'
|
||||
SERVICE_DLNA_DMR = 'dlna_dmr'
|
||||
SERVICE_ENIGMA2 = 'enigma2'
|
||||
SERVICE_FREEBOX = 'freebox'
|
||||
SERVICE_HASS_IOS_APP = 'hass_ios'
|
||||
SERVICE_HASSIO = 'hassio'
|
||||
@@ -39,6 +39,7 @@ SERVICE_HUE = 'philips_hue'
|
||||
SERVICE_IGD = 'igd'
|
||||
SERVICE_IKEA_TRADFRI = 'ikea_tradfri'
|
||||
SERVICE_KONNECTED = 'konnected'
|
||||
SERVICE_MOBILE_APP = 'hass_mobile_app'
|
||||
SERVICE_NETGEAR = 'netgear_router'
|
||||
SERVICE_OCTOPRINT = 'octoprint'
|
||||
SERVICE_ROKU = 'roku'
|
||||
@@ -62,12 +63,14 @@ CONFIG_ENTRY_HANDLERS = {
|
||||
}
|
||||
|
||||
SERVICE_HANDLERS = {
|
||||
SERVICE_MOBILE_APP: ('mobile_app', None),
|
||||
SERVICE_HASS_IOS_APP: ('ios', None),
|
||||
SERVICE_NETGEAR: ('device_tracker', None),
|
||||
SERVICE_WEMO: ('wemo', None),
|
||||
SERVICE_HASSIO: ('hassio', None),
|
||||
SERVICE_AXIS: ('axis', None),
|
||||
SERVICE_APPLE_TV: ('apple_tv', None),
|
||||
SERVICE_ENIGMA2: ('media_player', 'enigma2'),
|
||||
SERVICE_ROKU: ('roku', None),
|
||||
SERVICE_WINK: ('wink', None),
|
||||
SERVICE_XIAOMI_GW: ('xiaomi_aqara', None),
|
||||
@@ -93,7 +96,7 @@ SERVICE_HANDLERS = {
|
||||
'kodi': ('media_player', 'kodi'),
|
||||
'volumio': ('media_player', 'volumio'),
|
||||
'lg_smart_device': ('media_player', 'lg_soundbar'),
|
||||
'nanoleaf_aurora': ('light', 'nanoleaf_aurora'),
|
||||
'nanoleaf_aurora': ('light', 'nanoleaf'),
|
||||
}
|
||||
|
||||
OPTIONAL_SERVICE_HANDLERS = {
|
||||
@@ -195,10 +198,6 @@ async def async_setup(hass, config):
|
||||
"""Schedule the first discovery when Home Assistant starts up."""
|
||||
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)
|
||||
|
||||
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."""
|
||||
from homeassistant.const import ENERGY_KILO_WATT_HOUR
|
||||
|
||||
DOMAIN = 'ebusd'
|
||||
|
||||
# SensorTypes:
|
||||
@@ -67,9 +69,9 @@ SENSOR_TYPES = {
|
||||
'ContinuosHeating':
|
||||
['ContinuosHeating', '°C', 'mdi:weather-snowy', 0],
|
||||
'PowerEnergyConsumptionLastMonth':
|
||||
['PrEnergySumHcLastMonth', 'kWh', 'mdi:flash', 0],
|
||||
['PrEnergySumHcLastMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
|
||||
'PowerEnergyConsumptionThisMonth':
|
||||
['PrEnergySumHcThisMonth', 'kWh', 'mdi:flash', 0]
|
||||
['PrEnergySumHcThisMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0]
|
||||
},
|
||||
'ehp': {
|
||||
'HWTemperature':
|
||||
@@ -89,12 +91,12 @@ SENSOR_TYPES = {
|
||||
'Flame':
|
||||
['Flame', None, 'mdi:toggle-switch', 2],
|
||||
'PowerEnergyConsumptionHeatingCircuit':
|
||||
['PrEnergySumHc1', 'kWh', 'mdi:flash', 0],
|
||||
['PrEnergySumHc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
|
||||
'PowerEnergyConsumptionHotWaterCircuit':
|
||||
['PrEnergySumHwc1', 'kWh', 'mdi:flash', 0],
|
||||
['PrEnergySumHwc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
|
||||
'RoomThermostat':
|
||||
['DCRoomthermostat', None, 'mdi:toggle-switch', 2],
|
||||
'HeatingPartLoad':
|
||||
['PartloadHcKW', 'kWh', 'mdi:flash', 0]
|
||||
['PartloadHcKW', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0]
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
import logging
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.const import POWER_WATT
|
||||
|
||||
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
|
||||
devices.append(EdpRedySensor(session, ACTIVE_POWER_ID, "Power Home",
|
||||
'mdi:flash', 'W'))
|
||||
'mdi:flash', POWER_WATT))
|
||||
|
||||
async_add_entities(devices, True)
|
||||
|
||||
@@ -89,7 +90,7 @@ class EdpRedyModuleSensor(EdpRedyDevice, Entity):
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement of this sensor."""
|
||||
return 'W'
|
||||
return POWER_WATT
|
||||
|
||||
async def async_update(self):
|
||||
"""Parse the data for this sensor."""
|
||||
|
@@ -5,13 +5,16 @@ from aiohttp import web
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET,
|
||||
SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON, STATE_OFF,
|
||||
HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON,
|
||||
STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS
|
||||
)
|
||||
from homeassistant.components.climate.const import (
|
||||
SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE
|
||||
)
|
||||
from homeassistant.components.media_player.const import (
|
||||
ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET,
|
||||
)
|
||||
@@ -26,7 +29,7 @@ from homeassistant.components.cover 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
|
||||
@@ -262,6 +265,18 @@ class HueOneLightChangeView(HomeAssistantView):
|
||||
if brightness is not None:
|
||||
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
|
||||
elif entity.domain == media_player.DOMAIN:
|
||||
if entity_features & SUPPORT_VOLUME_SET:
|
||||
@@ -318,8 +333,9 @@ class HueOneLightChangeView(HomeAssistantView):
|
||||
core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True))
|
||||
|
||||
hass.async_create_task(hass.services.async_call(
|
||||
domain, service, data, blocking=True))
|
||||
if service is not None:
|
||||
hass.async_create_task(hass.services.async_call(
|
||||
domain, service, data, blocking=True))
|
||||
|
||||
json_response = \
|
||||
[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 [
|
||||
script.DOMAIN, media_player.DOMAIN,
|
||||
fan.DOMAIN, cover.DOMAIN]:
|
||||
fan.DOMAIN, cover.DOMAIN, climate.DOMAIN]:
|
||||
# Convert 0-255 to 0-100
|
||||
level = brightness / 255 * 100
|
||||
brightness = round(level)
|
||||
@@ -397,6 +413,10 @@ def get_entity_state(config, entity):
|
||||
if entity_features & SUPPORT_BRIGHTNESS:
|
||||
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:
|
||||
level = entity.attributes.get(
|
||||
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": {
|
||||
"user": {
|
||||
"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",
|
||||
"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
|
||||
|
||||
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
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components import enocean
|
||||
@@ -59,4 +59,4 @@ class EnOceanSensor(enocean.EnOceanDevice, Entity):
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
return 'W'
|
||||
return POWER_WATT
|
||||
|
@@ -13,9 +13,13 @@
|
||||
"data": {
|
||||
"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"
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"description": "Vil du tilf\u00f8je ESPHome node `{name}` til Home Assistant?",
|
||||
"title": "Fandt ESPHome node"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "V\u00e6rt",
|
||||
|
@@ -13,9 +13,13 @@
|
||||
"data": {
|
||||
"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"
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?",
|
||||
"title": "Gefundener ESPHome-Knoten"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
|
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9"
|
||||
},
|
||||
"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": {
|
||||
"authenticate": {
|
||||
@@ -11,6 +16,10 @@
|
||||
"description": "Veuillez saisir le mot de passe que vous avez d\u00e9fini dans votre configuration.",
|
||||
"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": {
|
||||
"data": {
|
||||
"host": "H\u00f4te",
|
||||
|
@@ -13,7 +13,7 @@
|
||||
"data": {
|
||||
"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"
|
||||
},
|
||||
"user": {
|
||||
|
@@ -13,9 +13,13 @@
|
||||
"data": {
|
||||
"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"
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?",
|
||||
"title": "ESPHome node ontdekt"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
|
@@ -16,6 +16,10 @@
|
||||
"description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon.",
|
||||
"title": "Skriv Inn Passord"
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?",
|
||||
"title": "Oppdaget ESPHome node"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"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