Merge pull request #31841 from home-assistant/rc

0.105.4
This commit is contained in:
Paulus Schoutsen 2020-02-14 16:29:09 -08:00 committed by GitHub
commit 68d2a1107e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 144 additions and 52 deletions

View File

@ -94,6 +94,7 @@ class BaseEditConfigView(HomeAssistantView):
self.data_schema = data_schema self.data_schema = data_schema
self.post_write_hook = post_write_hook self.post_write_hook = post_write_hook
self.data_validator = data_validator self.data_validator = data_validator
self.mutation_lock = asyncio.Lock()
def _empty_config(self): def _empty_config(self):
"""Empty config if file not found.""" """Empty config if file not found."""
@ -114,8 +115,9 @@ class BaseEditConfigView(HomeAssistantView):
async def get(self, request, config_key): async def get(self, request, config_key):
"""Fetch device specific config.""" """Fetch device specific config."""
hass = request.app["hass"] hass = request.app["hass"]
current = await self.read_config(hass) async with self.mutation_lock:
value = self._get_value(hass, current, config_key) current = await self.read_config(hass)
value = self._get_value(hass, current, config_key)
if value is None: if value is None:
return self.json_message("Resource not found", 404) return self.json_message("Resource not found", 404)
@ -148,10 +150,11 @@ class BaseEditConfigView(HomeAssistantView):
path = hass.config.path(self.path) path = hass.config.path(self.path)
current = await self.read_config(hass) async with self.mutation_lock:
self._write_value(hass, current, config_key, data) current = await self.read_config(hass)
self._write_value(hass, current, config_key, data)
await hass.async_add_executor_job(_write, path, current) await hass.async_add_executor_job(_write, path, current)
if self.post_write_hook is not None: if self.post_write_hook is not None:
hass.async_create_task( hass.async_create_task(
@ -163,15 +166,16 @@ class BaseEditConfigView(HomeAssistantView):
async def delete(self, request, config_key): async def delete(self, request, config_key):
"""Remove an entry.""" """Remove an entry."""
hass = request.app["hass"] hass = request.app["hass"]
current = await self.read_config(hass) async with self.mutation_lock:
value = self._get_value(hass, current, config_key) current = await self.read_config(hass)
path = hass.config.path(self.path) value = self._get_value(hass, current, config_key)
path = hass.config.path(self.path)
if value is None: if value is None:
return self.json_message("Resource not found", 404) return self.json_message("Resource not found", 404)
self._delete_value(hass, current, config_key) self._delete_value(hass, current, config_key)
await hass.async_add_executor_job(_write, path, current) await hass.async_add_executor_job(_write, path, current)
if self.post_write_hook is not None: if self.post_write_hook is not None:
hass.async_create_task(self.post_write_hook(ACTION_DELETE, config_key)) hass.async_create_task(self.post_write_hook(ACTION_DELETE, config_key))

View File

@ -3,7 +3,7 @@
"name": "Home Assistant Frontend", "name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": [ "requirements": [
"home-assistant-frontend==20200130.2" "home-assistant-frontend==20200130.3"
], ],
"dependencies": [ "dependencies": [
"api", "api",

View File

@ -133,7 +133,6 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
(binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR,
(binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR,
(media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV, (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV,
(media_player.DOMAIN, media_player.DEVICE_CLASS_SPEAKER): TYPE_SPEAKER,
(sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR, (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR,
(sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY): TYPE_SENSOR, (sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY): TYPE_SENSOR,
} }
@ -146,3 +145,5 @@ STORE_AGENT_USER_IDS = "agent_user_ids"
SOURCE_CLOUD = "cloud" SOURCE_CLOUD = "cloud"
SOURCE_LOCAL = "local" SOURCE_LOCAL = "local"
NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK}

View File

@ -28,6 +28,7 @@ from .const import (
DOMAIN, DOMAIN,
DOMAIN_TO_GOOGLE_TYPES, DOMAIN_TO_GOOGLE_TYPES,
ERR_FUNCTION_NOT_SUPPORTED, ERR_FUNCTION_NOT_SUPPORTED,
NOT_EXPOSE_LOCAL,
SOURCE_LOCAL, SOURCE_LOCAL,
STORE_AGENT_USER_IDS, STORE_AGENT_USER_IDS,
) )
@ -351,6 +352,18 @@ class GoogleEntity:
"""If entity should be exposed.""" """If entity should be exposed."""
return self.config.should_expose(self.state) return self.config.should_expose(self.state)
@callback
def should_expose_local(self) -> bool:
"""Return if the entity should be exposed locally."""
return (
self.should_expose()
and get_google_type(
self.state.domain, self.state.attributes.get(ATTR_DEVICE_CLASS)
)
not in NOT_EXPOSE_LOCAL
and not self.might_2fa()
)
@callback @callback
def is_supported(self) -> bool: def is_supported(self) -> bool:
"""Return if the entity is supported by Google.""" """Return if the entity is supported by Google."""
@ -401,7 +414,7 @@ class GoogleEntity:
if aliases: if aliases:
device["name"]["nicknames"] = [name] + aliases device["name"]["nicknames"] = [name] + aliases
if self.config.is_local_sdk_active: if self.config.is_local_sdk_active and self.should_expose_local():
device["otherDeviceIds"] = [{"deviceId": self.entity_id}] device["otherDeviceIds"] = [{"deviceId": self.entity_id}]
device["customData"] = { device["customData"] = {
"webhookId": self.config.local_sdk_webhook_id, "webhookId": self.config.local_sdk_webhook_id,

View File

@ -243,9 +243,7 @@ async def async_devices_reachable(hass, data: RequestData, payload):
"devices": [ "devices": [
entity.reachable_device_serialize() entity.reachable_device_serialize()
for entity in async_get_entities(hass, data.config) for entity in async_get_entities(hass, data.config)
if entity.entity_id in google_ids if entity.entity_id in google_ids and entity.should_expose_local()
and entity.should_expose()
and not entity.might_2fa()
] ]
} }

View File

@ -227,7 +227,11 @@ class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if smoke is detected.""" """Return true if smoke is detected."""
return self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF if self._device.smokeDetectorAlarmType:
return (
self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF
)
return False
class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice):

View File

@ -24,7 +24,10 @@ PRESENCE_SENSOR_TYPES = {
} }
TAG_SENSOR_TYPES = {"Tag Vibration": "vibration", "Tag Open": "opening"} TAG_SENSOR_TYPES = {"Tag Vibration": "vibration", "Tag Open": "opening"}
SENSOR_TYPES = {"NACamera": WELCOME_SENSOR_TYPES, "NOC": PRESENCE_SENSOR_TYPES} SENSOR_TYPES = {
"NACamera": WELCOME_SENSOR_TYPES,
"NOC": PRESENCE_SENSOR_TYPES,
}
CONF_HOME = "home" CONF_HOME = "home"
CONF_CAMERAS = "cameras" CONF_CAMERAS = "cameras"
@ -61,12 +64,28 @@ async def async_setup_entry(hass, entry, async_add_entities):
sensor_types.update(SENSOR_TYPES[camera["type"]]) sensor_types.update(SENSOR_TYPES[camera["type"]])
# Tags are only supported with Netatmo Welcome indoor cameras # Tags are only supported with Netatmo Welcome indoor cameras
if camera["type"] == "NACamera" and data.get_modules(camera["id"]): modules = data.get_modules(camera["id"])
sensor_types.update(TAG_SENSOR_TYPES) if camera["type"] == "NACamera" and modules:
for module in modules:
for sensor_type in TAG_SENSOR_TYPES:
_LOGGER.debug(
"Adding camera tag %s (%s)",
module["name"],
module["id"],
)
entities.append(
NetatmoBinarySensor(
data,
camera["id"],
home_id,
sensor_type,
module["id"],
)
)
for sensor_name in sensor_types: for sensor_type in sensor_types:
entities.append( entities.append(
NetatmoBinarySensor(data, camera["id"], home_id, sensor_name) NetatmoBinarySensor(data, camera["id"], home_id, sensor_type)
) )
except pyatmo.NoDevice: except pyatmo.NoDevice:
_LOGGER.debug("No camera entities to add") _LOGGER.debug("No camera entities to add")
@ -115,6 +134,15 @@ class NetatmoBinarySensor(BinarySensorDevice):
"""Return the unique ID for this sensor.""" """Return the unique ID for this sensor."""
return self._unique_id return self._unique_id
@property
def device_class(self):
"""Return the class of this sensor."""
if self._camera_type == "NACamera":
return WELCOME_SENSOR_TYPES.get(self._sensor_type)
if self._camera_type == "NOC":
return PRESENCE_SENSOR_TYPES.get(self._sensor_type)
return TAG_SENSOR_TYPES.get(self._sensor_type)
@property @property
def device_info(self): def device_info(self):
"""Return the device info for the sensor.""" """Return the device info for the sensor."""

View File

@ -88,7 +88,11 @@ _UNDEF = object()
async def async_create_person(hass, name, *, user_id=None, device_trackers=None): async def async_create_person(hass, name, *, user_id=None, device_trackers=None):
"""Create a new person.""" """Create a new person."""
await hass.data[DOMAIN][1].async_create_item( await hass.data[DOMAIN][1].async_create_item(
{ATTR_NAME: name, ATTR_USER_ID: user_id, "device_trackers": device_trackers} {
ATTR_NAME: name,
ATTR_USER_ID: user_id,
CONF_DEVICE_TRACKERS: device_trackers or [],
}
) )
@ -103,14 +107,14 @@ async def async_add_user_device_tracker(
if person.get(ATTR_USER_ID) != user_id: if person.get(ATTR_USER_ID) != user_id:
continue continue
device_trackers = person["device_trackers"] device_trackers = person[CONF_DEVICE_TRACKERS]
if device_tracker_entity_id in device_trackers: if device_tracker_entity_id in device_trackers:
return return
await coll.async_update_item( await coll.async_update_item(
person[collection.CONF_ID], person[collection.CONF_ID],
{"device_trackers": device_trackers + [device_tracker_entity_id]}, {CONF_DEVICE_TRACKERS: device_trackers + [device_tracker_entity_id]},
) )
break break
@ -161,6 +165,23 @@ class PersonStorageCollection(collection.StorageCollection):
super().__init__(store, logger, id_manager) super().__init__(store, logger, id_manager)
self.yaml_collection = yaml_collection self.yaml_collection = yaml_collection
async def _async_load_data(self) -> Optional[dict]:
"""Load the data.
A past bug caused onboarding to create invalid person objects.
This patches it up.
"""
data = await super()._async_load_data()
if data is None:
return data
for person in data["items"]:
if person[CONF_DEVICE_TRACKERS] is None:
person[CONF_DEVICE_TRACKERS] = []
return data
async def async_load(self) -> None: async def async_load(self) -> None:
"""Load the Storage collection.""" """Load the Storage collection."""
await super().async_load() await super().async_load()
@ -179,14 +200,16 @@ class PersonStorageCollection(collection.StorageCollection):
return return
for person in list(self.data.values()): for person in list(self.data.values()):
if entity_id not in person["device_trackers"]: if entity_id not in person[CONF_DEVICE_TRACKERS]:
continue continue
await self.async_update_item( await self.async_update_item(
person[collection.CONF_ID], person[collection.CONF_ID],
{ {
"device_trackers": [ CONF_DEVICE_TRACKERS: [
devt for devt in person["device_trackers"] if devt != entity_id devt
for devt in person[CONF_DEVICE_TRACKERS]
if devt != entity_id
] ]
}, },
) )
@ -315,7 +338,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType):
conf = await entity_component.async_prepare_reload(skip_reset=True) conf = await entity_component.async_prepare_reload(skip_reset=True)
if conf is None: if conf is None:
return return
await yaml_collection.async_load(await filter_yaml_data(hass, conf[DOMAIN])) await yaml_collection.async_load(
await filter_yaml_data(hass, conf.get(DOMAIN, []))
)
service.async_register_admin_service( service.async_register_admin_service(
hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml
@ -406,7 +431,7 @@ class Person(RestoreEntity):
self._unsub_track_device() self._unsub_track_device()
self._unsub_track_device = None self._unsub_track_device = None
trackers = self._config.get(CONF_DEVICE_TRACKERS) trackers = self._config[CONF_DEVICE_TRACKERS]
if trackers: if trackers:
_LOGGER.debug("Subscribe to device trackers for %s", self.entity_id) _LOGGER.debug("Subscribe to device trackers for %s", self.entity_id)
@ -426,7 +451,7 @@ class Person(RestoreEntity):
def _update_state(self): def _update_state(self):
"""Update the state.""" """Update the state."""
latest_non_gps_home = latest_not_home = latest_gps = latest = None latest_non_gps_home = latest_not_home = latest_gps = latest = None
for entity_id in self._config.get(CONF_DEVICE_TRACKERS, []): for entity_id in self._config[CONF_DEVICE_TRACKERS]:
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
if not state or state.state in IGNORE_STATES: if not state or state.state in IGNORE_STATES:

View File

@ -157,7 +157,8 @@ class SpotifyMediaPlayer(MediaPlayerDevice):
@property @property
def media_content_id(self) -> Optional[str]: def media_content_id(self) -> Optional[str]:
"""Return the media URL.""" """Return the media URL."""
return self._currently_playing.get("item", {}).get("name") item = self._currently_playing.get("item") or {}
return item.get("name")
@property @property
def media_content_type(self) -> Optional[str]: def media_content_type(self) -> Optional[str]:
@ -203,7 +204,8 @@ class SpotifyMediaPlayer(MediaPlayerDevice):
@property @property
def media_title(self) -> Optional[str]: def media_title(self) -> Optional[str]:
"""Return the media title.""" """Return the media title."""
return self._currently_playing.get("item", {}).get("name") item = self._currently_playing.get("item") or {}
return item.get("name")
@property @property
def media_artist(self) -> Optional[str]: def media_artist(self) -> Optional[str]:
@ -224,7 +226,8 @@ class SpotifyMediaPlayer(MediaPlayerDevice):
@property @property
def media_track(self) -> Optional[int]: def media_track(self) -> Optional[int]:
"""Track number of current playing media, music track only.""" """Track number of current playing media, music track only."""
return self._currently_playing.get("item", {}).get("track_number") item = self._currently_playing.get("item") or {}
return item.get("track_number")
@property @property
def media_playlist(self): def media_playlist(self):

View File

@ -1,9 +0,0 @@
play_playlist:
description: Play a Spotify playlist.
fields:
media_content_id:
description: Spotify URI of the playlist.
example: 'spotify:playlist:0IpRnqCHSjun48oQRX1Dy7'
random_song:
description: True to select random song at start, False to start from beginning.
example: true

View File

@ -1,7 +1,7 @@
"""Constants used by Home Assistant components.""" """Constants used by Home Assistant components."""
MAJOR_VERSION = 0 MAJOR_VERSION = 0
MINOR_VERSION = 105 MINOR_VERSION = 105
PATCH_VERSION = "3" PATCH_VERSION = "4"
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER = (3, 7, 0) REQUIRED_PYTHON_VER = (3, 7, 0)

View File

@ -158,9 +158,13 @@ class StorageCollection(ObservableCollection):
"""Home Assistant object.""" """Home Assistant object."""
return self.store.hass return self.store.hass
async def _async_load_data(self) -> Optional[dict]:
"""Load the data."""
return cast(Optional[dict], await self.store.async_load())
async def async_load(self) -> None: async def async_load(self) -> None:
"""Load the storage Manager.""" """Load the storage Manager."""
raw_storage = cast(Optional[dict], await self.store.async_load()) raw_storage = await self._async_load_data()
if raw_storage is None: if raw_storage is None:
raw_storage = {"items": []} raw_storage = {"items": []}

View File

@ -11,7 +11,7 @@ cryptography==2.8
defusedxml==0.6.0 defusedxml==0.6.0
distro==1.4.0 distro==1.4.0
hass-nabucasa==0.31 hass-nabucasa==0.31
home-assistant-frontend==20200130.2 home-assistant-frontend==20200130.3
importlib-metadata==1.4.0 importlib-metadata==1.4.0
jinja2>=2.10.3 jinja2>=2.10.3
netdisco==2.6.0 netdisco==2.6.0

View File

@ -679,7 +679,7 @@ hole==0.5.0
holidays==0.9.12 holidays==0.9.12
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20200130.2 home-assistant-frontend==20200130.3
# homeassistant.components.zwave # homeassistant.components.zwave
homeassistant-pyozw==0.1.8 homeassistant-pyozw==0.1.8

View File

@ -247,7 +247,7 @@ hole==0.5.0
holidays==0.9.12 holidays==0.9.12
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20200130.2 home-assistant-frontend==20200130.3
# homeassistant.components.zwave # homeassistant.components.zwave
homeassistant-pyozw==0.1.8 homeassistant-pyozw==0.1.8

View File

@ -7,6 +7,7 @@ import pytest
from homeassistant.components.google_assistant import helpers from homeassistant.components.google_assistant import helpers
from homeassistant.components.google_assistant.const import ( # noqa: F401 from homeassistant.components.google_assistant.const import ( # noqa: F401
EVENT_COMMAND_RECEIVED, EVENT_COMMAND_RECEIVED,
NOT_EXPOSE_LOCAL,
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt from homeassistant.util import dt
@ -46,6 +47,15 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass):
"webhookId": "mock-webhook-id", "webhookId": "mock-webhook-id",
} }
for device_type in NOT_EXPOSE_LOCAL:
with patch(
"homeassistant.components.google_assistant.helpers.get_google_type",
return_value=device_type,
):
serialized = await entity.sync_serialize(None)
assert "otherDeviceIds" not in serialized
assert "customData" not in serialized
async def test_config_local_sdk(hass, hass_client): async def test_config_local_sdk(hass, hass_client):
"""Test the local SDK.""" """Test the local SDK."""

View File

@ -682,7 +682,6 @@ async def test_device_class_cover(hass, device_class, google_type):
"device_class,google_type", "device_class,google_type",
[ [
("non_existing_class", "action.devices.types.SWITCH"), ("non_existing_class", "action.devices.types.SWITCH"),
("speaker", "action.devices.types.SPEAKER"),
("tv", "action.devices.types.TV"), ("tv", "action.devices.types.TV"),
], ],
) )

View File

@ -1,7 +1,7 @@
"""The tests for the person component.""" """The tests for the person component."""
import logging import logging
from unittest.mock import patch
from asynctest import patch
import pytest import pytest
from homeassistant.components import person from homeassistant.components import person
@ -773,3 +773,15 @@ async def test_reload(hass, hass_admin_user):
assert state_2 is None assert state_2 is None
assert state_3 is not None assert state_3 is not None
assert state_3.name == "Person 3" assert state_3.name == "Person 3"
async def test_person_storage_fixing_device_trackers(storage_collection):
"""Test None device trackers become lists."""
with patch.object(
storage_collection.store,
"async_load",
return_value={"items": [{"id": "bla", "name": "bla", "device_trackers": None}]},
):
await storage_collection.async_load()
assert storage_collection.data["bla"]["device_trackers"] == []